31 KiB
Ebook Conversion (Calibre wrapper) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a new ebook conversion family (epub, fb2, lrf, mobi, pdb, rb, snb, tcr, azw3, pdf — all pairs, both directions) by wrapping Calibre's ebook-convert CLI as a subprocess.
Architecture: A new src/converters/ebook.js module registers all 90 source→target pairs into the existing converters/registry.js, each delegating to ebook-convert via node:child_process's execFile (never exec). The worker gets a per-family conversion timeout (ebook jobs get a longer one than the existing 60s default) and forwards it to execFile's own timeout option so an orphaned Calibre subprocess is actually killed, not just abandoned. mime.js gains output MIME types for the new formats and two input-validation fixes verified against file-type's actual source: an azw3/mobi detection-collision alias, and a trust-the-declared-format fallback for the formats file-type cannot sniff at all (fb2, lrf, pdb, rb, snb, tcr).
Tech Stack: Node.js (ESM), node:child_process (execFile + util.promisify), Vitest with vi.mock/vi.hoisted for subprocess mocking (Calibre is not installed on the dev machine).
Global Constraints
- No system binary may be installed via a compiler/root-requiring step on o2switch (shared hosting, no root, no
apt-get, no compilation) — Calibre is deployed there as its self-contained Linux tarball via manual SSH upload, never throughnpm install. This plan's code changes don't perform that deployment step; they only assumeebook-convertis reachable onPATHor viaCALIBRE_PATH. - Subprocess invocation must use
execFilewith an argv array, neverexec/string interpolation (shell-injection safety). - Project is ESM throughout (
"type": "module"inpackage.json) — useimport/export, notrequire. - Calibre is confirmed absent from
PATHon this Windows dev machine — no task in this plan runs a realebook-convertinvocation. Every test that exercisessrc/converters/ebook.jsmocksnode:child_process. - Local test runs must use
.env.local-equivalent values passed as inline env vars (never load real.env), e.g.:DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run - Follow the existing registry contract exactly:
register({ family, sourceFormat, targetFormat, convert })whereconverthas signature(inputPath, outputPath, options = {}) => Promise<void>. - MIME types for
lrf,rb,snb,tcrhave no established registry entry anywhere (confirmed against the installedmime-dbpackage) — useapplication/octet-streamfor these, don't invent a more specific value.
Task 1: src/converters/ebook.js — Calibre subprocess wrapper and registration
Files:
- Create:
src/converters/ebook.js - Create:
test/converters/ebook.test.js
Interfaces:
-
Consumes:
registerfromsrc/converters/registry.js(existing:register({ family, sourceFormat, targetFormat, convert })). -
Produces:
registerEbookConverter()(no args, void) — called bysrc/worker.jsandsrc/app.jsin later tasks.EBOOK_FORMATS(exported array of the 10 format strings) — consumed by this task's own test and available for later tasks/tests. -
Step 1: Write the failing test for registration coverage
Create test/converters/ebook.test.js:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { registerEbookConverter, EBOOK_FORMATS } from '../../src/converters/ebook.js';
import { resolve, _resetForTests } from '../../src/converters/registry.js';
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', () => ({ execFile: execFileMock }));
beforeEach(() => {
_resetForTests();
execFileMock.mockReset();
execFileMock.mockImplementation((file, args, options, callback) => callback(null, '', ''));
});
describe('registerEbookConverter — registration', () => {
it('registers every pair among the 10 ebook formats, and nothing for source === target', () => {
registerEbookConverter();
for (const sourceFormat of EBOOK_FORMATS) {
for (const targetFormat of EBOOK_FORMATS) {
const entry = resolve(sourceFormat, targetFormat);
if (sourceFormat === targetFormat) {
expect(entry).toBeNull();
} else {
expect(entry).not.toBeNull();
expect(entry.family).toBe('ebook');
}
}
}
});
it('exposes exactly the 10 requested formats', () => {
expect(EBOOK_FORMATS.sort()).toEqual(
['azw3', 'epub', 'fb2', 'lrf', 'mobi', 'pdb', 'pdf', 'rb', 'snb', 'tcr'].sort()
);
});
});
- Step 2: Run test to verify it fails
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/ebook.test.js
Expected: FAIL — Cannot find module '../../src/converters/ebook.js' (or similar resolution error), since the module doesn't exist yet.
- Step 3: Write the implementation
Create src/converters/ebook.js:
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { register } from './registry.js';
const execFileAsync = promisify(execFile);
export const EBOOK_FORMATS = ['epub', 'fb2', 'lrf', 'mobi', 'pdb', 'rb', 'snb', 'tcr', 'azw3', 'pdf'];
export function registerEbookConverter() {
for (const sourceFormat of EBOOK_FORMATS) {
for (const targetFormat of EBOOK_FORMATS) {
if (sourceFormat === targetFormat) continue;
register({
family: 'ebook',
sourceFormat,
targetFormat,
convert: async (inputPath, outputPath, options = {}) => {
const calibrePath = process.env.CALIBRE_PATH || 'ebook-convert';
await execFileAsync(calibrePath, [inputPath, outputPath], { timeout: options.timeoutMs });
},
});
}
}
}
Note calibrePath is read from process.env inside the convert closure (call time), not at module load time — this matches how test/config.test.js already tests env-driven defaults (set process.env, then call), and avoids any need for vi.resetModules() gymnastics in this task's tests.
- Step 4: Run test to verify it passes
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/ebook.test.js
Expected: PASS (2 tests).
- Step 5: Write the failing test for the subprocess call itself
Add to test/converters/ebook.test.js:
describe('registerEbookConverter — subprocess invocation', () => {
beforeEach(() => {
registerEbookConverter();
});
it('calls ebook-convert with the input and output paths, and no timeout when none is given', async () => {
await resolve('epub', 'pdf').convert('/tmp/in.epub', '/tmp/out.pdf');
expect(execFileMock).toHaveBeenCalledTimes(1);
const [file, args, options] = execFileMock.mock.calls[0];
expect(file).toBe('ebook-convert');
expect(args).toEqual(['/tmp/in.epub', '/tmp/out.pdf']);
expect(options).toEqual({ timeout: undefined });
});
it('forwards options.timeoutMs to execFile as its timeout', async () => {
await resolve('mobi', 'epub').convert('/tmp/in.mobi', '/tmp/out.epub', { timeoutMs: 180000 });
const [, , options] = execFileMock.mock.calls[0];
expect(options).toEqual({ timeout: 180000 });
});
it('uses CALIBRE_PATH from the environment when set', async () => {
const previous = process.env.CALIBRE_PATH;
process.env.CALIBRE_PATH = '/opt/calibre/ebook-convert';
await resolve('fb2', 'pdf').convert('/tmp/in.fb2', '/tmp/out.pdf');
expect(execFileMock.mock.calls[0][0]).toBe('/opt/calibre/ebook-convert');
if (previous === undefined) delete process.env.CALIBRE_PATH;
else process.env.CALIBRE_PATH = previous;
});
it('propagates a rejection from execFile as a rejected promise', async () => {
execFileMock.mockImplementation((file, args, options, callback) =>
callback(new Error('ebook-convert exited with code 1'), '', 'error: unknown format')
);
await expect(resolve('epub', 'pdf').convert('/tmp/in.epub', '/tmp/out.pdf')).rejects.toThrow(
/exited with code 1/
);
});
});
- Step 6: Run test to verify it fails
Run: same command as Step 2, targeting ebook.test.js.
Expected: FAIL — the new describe block should already pass against the Step 3 implementation except verify by running it first; if any assertion fails, it should only be because the implementation is genuinely missing that behavior (it isn't — Step 3's implementation already satisfies all four cases). Confirm all 6 tests pass together.
- Step 7: Run full test file to confirm everything passes
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/ebook.test.js
Expected: PASS (6 tests).
- Step 8: Commit
git add src/converters/ebook.js test/converters/ebook.test.js
git commit -m "feat: add ebook converter wrapping Calibre's ebook-convert CLI"
Task 2: src/config.js — per-family ebook job timeout
Files:
- Modify:
src/config.js:20-26 - Modify:
test/config.test.js
Interfaces:
-
Consumes: nothing new.
-
Produces:
config.ebookJobTimeoutMs(number, default180000) — consumed bysrc/worker.jsin Task 3. -
Step 1: Write the failing test
Add to test/config.test.js, inside the existing describe('loadConfig', ...) block:
it('defaults ebookJobTimeoutMs to 180000ms and reads EBOOK_JOB_TIMEOUT_MS when set', () => {
process.env.STORAGE_DIR = './storage';
process.env.DB_HOST = 'localhost';
process.env.DB_USER = 'user';
process.env.DB_PASSWORD = 'pass';
process.env.DB_NAME = 'db';
delete process.env.EBOOK_JOB_TIMEOUT_MS;
expect(loadConfig().ebookJobTimeoutMs).toBe(180000);
process.env.EBOOK_JOB_TIMEOUT_MS = '240000';
expect(loadConfig().ebookJobTimeoutMs).toBe(240000);
});
- Step 2: Run test to verify it fails
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/config.test.js
Expected: FAIL — expect(loadConfig().ebookJobTimeoutMs).toBe(180000) receives undefined.
- Step 3: Write the implementation
In src/config.js, add one line to the object returned by loadConfig(), right after the existing workerConcurrency line:
workerConcurrency: Number(process.env.WORKER_CONCURRENCY ?? 3),
ebookJobTimeoutMs: Number(process.env.EBOOK_JOB_TIMEOUT_MS ?? 180000),
- Step 4: Run test to verify it passes
Run: same command as Step 2. Expected: PASS.
- Step 5: Commit
git add src/config.js test/config.test.js
git commit -m "feat: add configurable ebookJobTimeoutMs to config"
Task 3: src/worker.js — per-family timeout selection + ebook registration
Files:
- Modify:
src/worker.js:1-17(imports +JOB_TIMEOUT_MS),src/worker.js:27-60(processJob),src/worker.js:77-92(main) - Modify:
test/worker.test.js
Interfaces:
-
Consumes:
registerEbookConverterfromsrc/converters/ebook.js(Task 1),config.ebookJobTimeoutMsfromsrc/config.js(Task 2),entry.familyfromresolveConverter(...)(existing,registry.js). -
Produces: no new exports —
processPendingJobs/startWorkersignatures are unchanged. -
Step 1: Write the failing test
Add to test/worker.test.js. First, add the mock and a sync fs import near the top of the file (mocks must be declared before any other code in the file per Vitest hoisting, but since vi.mock calls are auto-hoisted by Vitest to the top of the module regardless of source position, placing this block right after the existing imports is fine):
import { writeFileSync } from 'node:fs';
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', () => ({ execFile: execFileMock }));
Add vi to the existing import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; line (becomes import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';).
Add import { registerEbookConverter } from '../src/converters/ebook.js'; alongside the other converter imports, and call registerEbookConverter(); inside the existing beforeAll alongside registerImageConverters();/registerIcoConverter();.
Then add a new test inside describe('processPendingJobs', ...):
it('converts a pending ebook job to done using config.ebookJobTimeoutMs, not the default 60s timeout', async () => {
execFileMock.mockReset();
execFileMock.mockImplementation((file, args, options, callback) => {
writeFileSync(args[1], 'fake converted ebook output');
callback(null, '', '');
});
const uuid = '11111111-1111-4111-8111-111111111111';
const inputFilePath = uploadPath(config, uuid, 'epub');
await fs.writeFile(inputFilePath, 'fake epub content');
await createJob(prisma, {
uuid,
family: 'ebook',
sourceFormat: 'epub',
targetFormat: 'pdf',
originalFilename: 'book.epub',
inputPath: `${uuid}.epub`,
inputMimeType: 'application/epub+zip',
inputSizeBytes: 18,
expiresAt: new Date(Date.now() + 3600 * 1000),
});
await processPendingJobs(prisma, config);
const job = await getJobByUuid(prisma, uuid);
expect(job.status).toBe('done');
expect(execFileMock).toHaveBeenCalledWith(
'ebook-convert',
[inputFilePath, outputPath(config, uuid, 'pdf')],
{ timeout: config.ebookJobTimeoutMs }
);
expect(config.ebookJobTimeoutMs).not.toBe(60000);
});
- Step 2: Run test to verify it fails
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/worker.test.js
Expected: FAIL — either No converter registered for epub -> pdf (registration missing) or the execFileMock assertion shows { timeout: 60000 } instead of config.ebookJobTimeoutMs (per-family selection missing), depending on how much of Task 1/2 is already wired into worker.js.
- Step 3: Write the implementation
In src/worker.js, add the import next to the other register* imports:
import { registerEbookConverter } from './converters/ebook.js';
In processJob, replace the single JOB_TIMEOUT_MS use with a per-family choice. Current code:
const entry = resolveConverter(job.sourceFormat, job.targetFormat);
if (!entry) {
throw new Error(`No converter registered for ${job.sourceFormat} -> ${job.targetFormat}`);
}
const startedAt = Date.now();
await withTimeout(
entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize }),
JOB_TIMEOUT_MS
);
Replace with:
const entry = resolveConverter(job.sourceFormat, job.targetFormat);
if (!entry) {
throw new Error(`No converter registered for ${job.sourceFormat} -> ${job.targetFormat}`);
}
const timeoutMs = entry.family === 'ebook' ? config.ebookJobTimeoutMs : JOB_TIMEOUT_MS;
const startedAt = Date.now();
await withTimeout(
entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize, timeoutMs }),
timeoutMs
);
In main(), add the registration call next to the other six:
registerImageConverters();
registerImageToPdfConverter();
registerDocumentConverters();
registerIcoConverter();
registerHeicConverter();
registerFontConverter();
registerDfontConverter();
registerEbookConverter();
- Step 4: Run test to verify it passes
Run: same command as Step 2.
Expected: PASS. Also re-run the full worker suite to confirm no regression in the existing image-family tests (they don't set execFileMock, so they're unaffected by the mock — node:child_process is only ever touched by the ebook converter):
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/worker.test.js
Expected: all tests PASS.
- Step 5: Commit
git add src/worker.js test/worker.test.js
git commit -m "feat: give ebook conversions their own worker timeout and register the converter"
Task 4: src/mime.js — output MIME types, azw3/mobi collision fix, undetectable-format fallback
Files:
- Modify:
src/mime.js(whole file — small, ~74 lines) - Modify:
test/mime.test.js
Interfaces:
- Consumes:
EBOOK_FORMATSis not needed here (formats are spelled out directly, matching the existing style ofOUTPUT_MIME_TYPES/UNDETECTABLE_TEXT_FORMATS). - Produces:
outputMimeType('epub'|'fb2'|'lrf'|'mobi'|'pdb'|'rb'|'snb'|'tcr'|'azw3')now return values instead of throwing;resolveInputFormat(path, 'azw3')now accepts real mobi/azw3-family content;resolveInputFormat(path, 'fb2'|'lrf'|'pdb'|'rb'|'snb'|'tcr')now trusts the declared format when content can't be sniffed.
Verified facts this task relies on (confirmed by reading the installed file-type@22 source and the installed mime-db package directly, not assumed):
-
file-type's zip detector already returns{ ext: 'epub', mime: 'application/epub+zip' }for real EPUB content (checks the zip's mimetype entry for the literal stringapplication/epub+zip) — no code change needed for epub detection, it already works and needs no fallback. -
file-typedetects both real.mobiand real.azw3files identically as{ ext: 'mobi', mime: 'application/x-mobipocket-ebook' }, because its check is only an 8-byte"BOOKMOBI"string match at byte offset 60 (the PDB header's type+creator fields) — a byte pattern both formats share, since AZW3 is itself a MOBI/PDB container. This means a declaredazw3upload is currently rejected (normalizeFormat('azw3') !== normalizeFormat('mobi')) — needs the same kind of aliasnormalizeFormatalready has forjpg/jpegandheif/heic. -
fb2,lrf,pdb,rb,snb,tcrare absent fromfile-type'ssupportedExtensionsentirely (confirmed) — sniffing real content of these types always returnsnull, hitting the same code path as today'sUNDETECTABLE_TEXT_FORMATSfallback. -
MIME values, cross-checked against the installed
mime-dbpackage (ground truth, not memory):epub→application/epub+zip(IANA),mobi→application/x-mobipocket-ebook(Apache),pdb→application/vnd.palm(IANA),lrf→ no dedicated entry, IANA lists it only underapplication/octet-stream.azw3/rb/snb/tcrare absent frommime-dbentirely;azw3usesapplication/vnd.amazon.ebook(the siblingazwextension's Apache-sourced value — the closest established convention, since AZW3 has no separate registration);rb/snb/tcruseapplication/octet-stream(no established convention found anywhere).fb2is also absent frommime-db, butapplication/x-fictionbook+xmlis confirmed (via the freedesktop shared-mime-info project and KDE Dolphin) as the de facto standard value used across Linux desktop environments. -
Step 1: Write the failing tests
Add to test/mime.test.js:
describe('outputMimeType — ebooks', () => {
it('returns the correct MIME type for each ebook target format', () => {
expect(outputMimeType('epub')).toBe('application/epub+zip');
expect(outputMimeType('fb2')).toBe('application/x-fictionbook+xml');
expect(outputMimeType('mobi')).toBe('application/x-mobipocket-ebook');
expect(outputMimeType('azw3')).toBe('application/vnd.amazon.ebook');
expect(outputMimeType('pdb')).toBe('application/vnd.palm');
expect(outputMimeType('lrf')).toBe('application/octet-stream');
expect(outputMimeType('rb')).toBe('application/octet-stream');
expect(outputMimeType('snb')).toBe('application/octet-stream');
expect(outputMimeType('tcr')).toBe('application/octet-stream');
});
});
describe('resolveInputFormat — azw3/mobi collision', () => {
function buildMobiFamilyBuffer() {
// PDB header: 8-byte "BOOKMOBI" type+creator magic at offset 60, shared by both
// real .mobi and real .azw3 files (confirmed in file-type's source).
const buffer = Buffer.alloc(68);
buffer.write('BOOKMOBI', 60, 'ascii');
return buffer;
}
it('confirms file-type reports mobi-family content as ext "mobi" regardless of which of the two formats it is (documents the collision this fix works around)', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin');
await fs.writeFile(fixturePath, buildMobiFamilyBuffer());
const detected = await detectInputMime(fixturePath);
expect(detected).toEqual({ ext: 'mobi', mime: 'application/x-mobipocket-ebook' });
await fs.unlink(fixturePath);
});
it('accepts a real mobi-family file declared as mobi', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin');
await fs.writeFile(fixturePath, buildMobiFamilyBuffer());
const result = await resolveInputFormat(fixturePath, 'mobi');
expect(result).toEqual({ mime: 'application/x-mobipocket-ebook', valid: true });
await fs.unlink(fixturePath);
});
it('accepts a real mobi-family file declared as azw3 despite file-type reporting it as mobi', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin');
await fs.writeFile(fixturePath, buildMobiFamilyBuffer());
const result = await resolveInputFormat(fixturePath, 'azw3');
expect(result).toEqual({ mime: 'application/x-mobipocket-ebook', valid: true });
await fs.unlink(fixturePath);
});
});
describe('resolveInputFormat — undetectable ebook formats', () => {
const undetectableFormats = {
fb2: 'application/x-fictionbook+xml',
lrf: 'application/octet-stream',
pdb: 'application/vnd.palm',
rb: 'application/octet-stream',
snb: 'application/octet-stream',
tcr: 'application/octet-stream',
};
it.each(Object.entries(undetectableFormats))(
'trusts the declared format for undetectable %s files',
async (declaredFormat, expectedMime) => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', `sample.${declaredFormat}`);
await fs.writeFile(fixturePath, 'arbitrary bytes with no recognizable magic number');
const result = await resolveInputFormat(fixturePath, declaredFormat);
expect(result).toEqual({ mime: expectedMime, valid: true });
await fs.unlink(fixturePath);
}
);
});
- Step 2: Run test to verify it fails
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js
Expected: FAIL — outputMimeType('epub') throws No known MIME type for target format "epub"; the azw3 test gets { mime: null, valid: false }; the undetectable-format tests get { mime: null, valid: false }.
- Step 3: Write the implementation
Replace the full contents of src/mime.js:
import { fileTypeFromFile } from 'file-type';
import fs from 'node:fs/promises';
import { probeDfont } from './converters/dfont.js';
const OUTPUT_MIME_TYPES = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
tiff: 'image/tiff',
avif: 'image/avif',
bmp: 'image/bmp',
ico: 'image/x-icon',
pdf: 'application/pdf',
html: 'text/html',
txt: 'text/plain',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
md: 'text/markdown',
csv: 'text/csv',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ttf: 'font/ttf',
otf: 'font/otf',
woff: 'font/woff',
epub: 'application/epub+zip',
fb2: 'application/x-fictionbook+xml',
lrf: 'application/octet-stream',
mobi: 'application/x-mobipocket-ebook',
pdb: 'application/vnd.palm',
rb: 'application/octet-stream',
snb: 'application/octet-stream',
tcr: 'application/octet-stream',
azw3: 'application/vnd.amazon.ebook',
};
export async function detectInputMime(filePath) {
const result = await fileTypeFromFile(filePath);
return result ?? null;
}
export function outputMimeType(targetFormat) {
const mime = OUTPUT_MIME_TYPES[targetFormat];
if (!mime) {
throw new Error(`No known MIME type for target format "${targetFormat}"`);
}
return mime;
}
const UNDETECTABLE_TEXT_FORMATS = {
txt: 'text/plain',
html: 'text/html',
md: 'text/markdown',
csv: 'text/csv',
};
const UNDETECTABLE_EBOOK_FORMATS = {
fb2: 'application/x-fictionbook+xml',
lrf: 'application/octet-stream',
pdb: 'application/vnd.palm',
rb: 'application/octet-stream',
snb: 'application/octet-stream',
tcr: 'application/octet-stream',
};
function normalizeFormat(format) {
if (format === 'jpg') return 'jpeg';
if (format === 'heif') return 'heic';
if (format === 'azw3') return 'mobi';
return format;
}
export async function resolveInputFormat(filePath, declaredFormat) {
if (declaredFormat === 'dfont') {
const buffer = await fs.readFile(filePath);
return probeDfont(buffer)
? { mime: 'application/x-dfont', valid: true }
: { mime: null, valid: false };
}
const detected = await detectInputMime(filePath);
if (!detected) {
const fallbackMime = UNDETECTABLE_TEXT_FORMATS[declaredFormat] ?? UNDETECTABLE_EBOOK_FORMATS[declaredFormat];
if (fallbackMime) {
return { mime: fallbackMime, valid: true };
}
return { mime: null, valid: false };
}
const valid = normalizeFormat(detected.ext) === normalizeFormat(declaredFormat);
return { mime: detected.mime, valid };
}
The only changes from the current file: nine new OUTPUT_MIME_TYPES entries, the new UNDETECTABLE_EBOOK_FORMATS map, the azw3 → mobi line in normalizeFormat, and resolveInputFormat's fallback line now checking both undetectable-format maps.
- Step 4: Run test to verify it passes
Run: same command as Step 2. Expected: PASS. Also re-run the whole file to confirm no regression in the pre-existing dfont/heic/font/image cases:
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js
Expected: all tests PASS.
- Step 5: Commit
git add src/mime.js test/mime.test.js
git commit -m "feat: add ebook MIME types, fix azw3/mobi detection collision, trust undetectable ebook formats"
Task 5: src/app.js — wire the converter into the API + end-to-end job-creation coverage
Files:
- Modify:
src/app.js:8-14(imports),src/app.js:38-48(registerAllConverters) - Modify:
test/api/jobs.test.js
Interfaces:
-
Consumes:
registerEbookConverterfromsrc/converters/ebook.js(Task 1); relies on Task 4'smime.jsfallback for thefb2fixture used in the new test. -
Produces: nothing new —
GET /api/formatsandPOST /api/jobsnow also serve theebookfamily, with zero route/handler changes (confirmed: both endpoints are entirely registry-driven already). -
Step 1: Write the failing tests
Add to test/api/jobs.test.js:
describe('GET /api/formats — ebooks', () => {
it('lists the other 9 ebook formats as targets for epub, and never lists epub as its own target', async () => {
const response = await request(app).get('/api/formats').query({ source: 'epub' });
expect(response.body.targets).toEqual(
expect.arrayContaining(['fb2', 'lrf', 'mobi', 'pdb', 'rb', 'snb', 'tcr', 'azw3', 'pdf'])
);
expect(response.body.targets).not.toContain('epub');
});
});
describe('POST /api/jobs — ebooks', () => {
it('creates a pending job converting an fb2 upload to epub, trusting the declared format since fb2 has no sniffable magic bytes', async () => {
const fixturePath = path.join(config.storageDir, 'book.fb2');
await fs.writeFile(
fixturePath,
'<?xml version="1.0" encoding="utf-8"?><FictionBook><body><p>hello</p></body></FictionBook>'
);
const response = await request(app)
.post('/api/jobs')
.field('targetFormats', JSON.stringify(['epub']))
.attach('files', fixturePath, 'book.fb2');
expect(response.status).toBe(201);
expect(response.body.jobs[0].status).toBe('pending');
const job = await getJobByUuid(prisma, response.body.jobs[0].id);
expect(job.sourceFormat).toBe('fb2');
expect(job.targetFormat).toBe('epub');
expect(job.family).toBe('ebook');
expect(job.inputMimeType).toBe('application/x-fictionbook+xml');
await fs.unlink(fixturePath);
});
});
- Step 2: Run test to verify it fails
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js
Expected: FAIL — GET /api/formats?source=epub returns an empty targets array (no converter registered yet in app.js's registry instance), and the POST /api/jobs case gets error: 'Unsupported conversion: fb2 to epub' instead of a pending job.
- Step 3: Write the implementation
In src/app.js, add the import next to the other register* imports:
import { registerEbookConverter } from './converters/ebook.js';
In registerAllConverters(), add the call next to the other six:
function registerAllConverters() {
if (convertersRegistered) return;
registerImageConverters();
registerImageToPdfConverter();
registerDocumentConverters();
registerIcoConverter();
registerHeicConverter();
registerFontConverter();
registerDfontConverter();
registerEbookConverter();
convertersRegistered = true;
}
- Step 4: Run test to verify it passes
Run: same command as Step 2. Expected: PASS.
- Step 5: Run the full test suite to confirm no regressions anywhere
Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run
Expected: all tests PASS, except the two pre-existing unrelated failures documented in CLAUDE.md (test/cleanup.test.js and test/jobs/jobRepository.test.js, both known to fail on main independent of this change — confirm by checking they're the only failures, nothing else new).
- Step 6: Commit
git add src/app.js test/api/jobs.test.js
git commit -m "feat: register ebook converter in the app so the API serves the new family"
Deliberately out of scope (documented, not silently dropped)
- Actually installing Calibre on o2switch. This plan only makes the app able to call
ebook-convert; deploying the self-contained Linux Calibre build via SSH and confirmingCALIBRE_PATH/PATHon the production host is an infrastructure step outside this codebase change, to be done once before these jobs can succeed in production. - Real end-to-end conversion verification. Every test here mocks the subprocess. The first real Calibre invocation against real files happens either after o2switch deployment or if Calibre for Windows is installed locally later — both explicitly deferred per the approved design.
- A separate
WORKER_CONCURRENCYlimit for theebookfamily. Noted as a future consideration in the design ifebooktraffic turns out to dominate the shared-hosting resource budget; not implemented now (YAGNI).