docs: add audio conversion implementation plan
This commit is contained in:
@@ -0,0 +1,788 @@
|
||||
# Audio Conversion 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 `audio` conversion family (mp3, wav, ogg, flac, aac, m4a; any-to-any, 30 pairs) backed by an external `ffmpeg` binary, following the exact `CALIBRE_PATH`/`ebook.js` pattern already in this codebase.
|
||||
|
||||
**Architecture:** One generic `convert()` in `src/converters/audio.js` that shells out to `ffmpeg` via `execFile` (no ffmpeg npm wrapper — ffmpeg's own demuxer auto-detects the input format, so there is no per-source branching, only per-target codec selection). Wired into `app.js`/`worker.js` next to the other seven `registerXConverters()` calls. Bitrate (kbps) is stored in the existing nullable `ConversionJob.quality` column — no schema change.
|
||||
|
||||
**Tech Stack:** Node.js, `node:child_process` (`execFile`), Express, Prisma/MySQL, Vitest + Supertest, React (frontend, manual verification only — no frontend test suite exists in this repo).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Spec: `docs/superpowers/specs/2026-08-01-audio-conversion-design.md` — read it before starting if anything below is unclear.
|
||||
- Formats: exactly `mp3`, `wav`, `ogg`, `flac`, `aac`, `m4a`. All-pairs excluding `sourceFormat === targetFormat`.
|
||||
- ffmpeg is **not installed on this local dev machine**. Every automated test in this plan mocks `node:child_process`'s `execFile` — none may shell out to a real ffmpeg binary. Real end-to-end conversion is verified only manually on a machine/environment where ffmpeg is present (o2switch), never in this plan's automated tests.
|
||||
- Bitrate is a fixed chip set only: `128`, `192`, `256`, `320` (kbps). No arbitrary numeric range is valid.
|
||||
- `wav` and `flac` never take a quality/bitrate value — only `null` is valid for them.
|
||||
- Timeout: reuse the existing global `JOB_TIMEOUT_MS` (60s) in `src/worker.js`. Do **not** add a new `AUDIO_JOB_TIMEOUT_MS`.
|
||||
- No new npm dependency. ffmpeg is invoked as an external binary (`process.env.FFMPEG_PATH || 'ffmpeg'`), the same way `ebook.js` invokes `process.env.CALIBRE_PATH || 'ebook-convert'`.
|
||||
- No DB/schema changes anywhere in this feature.
|
||||
- Run backend tests with the project's documented local-env-var pattern (see `CLAUDE.md`): `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 <path>`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Audio converter core (`src/converters/audio.js`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/converters/audio.js`
|
||||
- Test: `test/converters/audio.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `register` from `./registry.js` (signature: `register({ family, sourceFormat, targetFormat, convert })`, already used identically by every other converter file).
|
||||
- Produces: `registerAudioConverters(): void` and `AUDIO_FORMATS: string[]` (both named exports), for Task 2 to import and call/reference. Each registered `convert(inputPath, outputPath, { quality, timeoutMs } = {})` returns a `Promise<void>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test for registration coverage**
|
||||
|
||||
Create `test/converters/audio.test.js` with this content (mirrors `test/converters/ebook.test.js`'s structure exactly, including the `execFile` mock):
|
||||
|
||||
```js
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { registerAudioConverters, AUDIO_FORMATS } from '../../src/converters/audio.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('registerAudioConverters — registration', () => {
|
||||
it('registers every pair among the 6 audio formats, and nothing for source === target', () => {
|
||||
registerAudioConverters();
|
||||
|
||||
for (const sourceFormat of AUDIO_FORMATS) {
|
||||
for (const targetFormat of AUDIO_FORMATS) {
|
||||
const entry = resolve(sourceFormat, targetFormat);
|
||||
if (sourceFormat === targetFormat) {
|
||||
expect(entry).toBeNull();
|
||||
} else {
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry.family).toBe('audio');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes exactly the 6 requested formats', () => {
|
||||
expect(AUDIO_FORMATS.sort()).toEqual(['aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav'].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/audio.test.js`
|
||||
Expected: FAIL — `Cannot find module '../../src/converters/audio.js'` (or similar "does not provide an export named" once the file exists but before exports are added).
|
||||
|
||||
- [ ] **Step 3: Write the converter implementation**
|
||||
|
||||
Create `src/converters/audio.js`:
|
||||
|
||||
```js
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { register } from './registry.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const AUDIO_FORMATS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
||||
|
||||
const CODEC_ARGS = {
|
||||
mp3: ['-c:a', 'libmp3lame'],
|
||||
ogg: ['-c:a', 'libvorbis'],
|
||||
aac: ['-c:a', 'aac'],
|
||||
m4a: ['-c:a', 'aac'],
|
||||
wav: ['-c:a', 'pcm_s16le'],
|
||||
flac: ['-c:a', 'flac'],
|
||||
};
|
||||
|
||||
const BITRATE_CAPABLE_FORMATS = ['mp3', 'ogg', 'aac', 'm4a'];
|
||||
|
||||
export function registerAudioConverters() {
|
||||
for (const sourceFormat of AUDIO_FORMATS) {
|
||||
for (const targetFormat of AUDIO_FORMATS) {
|
||||
if (sourceFormat === targetFormat) continue;
|
||||
register({
|
||||
family: 'audio',
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
convert: async (inputPath, outputPath, { quality, timeoutMs } = {}) => {
|
||||
const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
const args = ['-y', '-i', inputPath, ...CODEC_ARGS[targetFormat]];
|
||||
if (quality != null && BITRATE_CAPABLE_FORMATS.includes(targetFormat)) {
|
||||
args.push('-b:a', `${quality}k`);
|
||||
}
|
||||
args.push(outputPath);
|
||||
await execFileAsync(ffmpegPath, args, { timeout: timeoutMs });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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/audio.test.js`
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
- [ ] **Step 5: Add subprocess-invocation tests (codec args, bitrate, env var, timeout, error propagation)**
|
||||
|
||||
Append to `test/converters/audio.test.js`:
|
||||
|
||||
```js
|
||||
describe('registerAudioConverters — subprocess invocation', () => {
|
||||
beforeEach(() => {
|
||||
registerAudioConverters();
|
||||
});
|
||||
|
||||
it('calls ffmpeg with -i, the codec args, and the output path, omitting -b:a when no quality is given', async () => {
|
||||
await resolve('wav', 'mp3').convert('/tmp/in.wav', '/tmp/out.mp3');
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
const [file, args, options] = execFileMock.mock.calls[0];
|
||||
expect(file).toBe('ffmpeg');
|
||||
expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'libmp3lame', '/tmp/out.mp3']);
|
||||
expect(options).toEqual({ timeout: undefined });
|
||||
});
|
||||
|
||||
it('appends -b:a when a quality/bitrate is given for a bitrate-capable target', async () => {
|
||||
await resolve('wav', 'mp3').convert('/tmp/in.wav', '/tmp/out.mp3', { quality: 192 });
|
||||
|
||||
const [, args] = execFileMock.mock.calls[0];
|
||||
expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'libmp3lame', '-b:a', '192k', '/tmp/out.mp3']);
|
||||
});
|
||||
|
||||
it('never appends -b:a for a wav target even if a quality is passed', async () => {
|
||||
await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav', { quality: 192 });
|
||||
|
||||
const [, args] = execFileMock.mock.calls[0];
|
||||
expect(args).toEqual(['-y', '-i', '/tmp/in.mp3', '-c:a', 'pcm_s16le', '/tmp/out.wav']);
|
||||
});
|
||||
|
||||
it('never appends -b:a for a flac target even if a quality is passed', async () => {
|
||||
await resolve('wav', 'flac').convert('/tmp/in.wav', '/tmp/out.flac', { quality: 320 });
|
||||
|
||||
const [, args] = execFileMock.mock.calls[0];
|
||||
expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'flac', '/tmp/out.flac']);
|
||||
});
|
||||
|
||||
it('uses the aac codec for both aac and m4a targets', async () => {
|
||||
await resolve('wav', 'aac').convert('/tmp/in.wav', '/tmp/out.aac');
|
||||
expect(execFileMock.mock.calls[0][1]).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'aac', '/tmp/out.aac']);
|
||||
|
||||
execFileMock.mockClear();
|
||||
await resolve('wav', 'm4a').convert('/tmp/in.wav', '/tmp/out.m4a');
|
||||
expect(execFileMock.mock.calls[0][1]).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'aac', '/tmp/out.m4a']);
|
||||
});
|
||||
|
||||
it('forwards options.timeoutMs to execFile as its timeout', async () => {
|
||||
await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav', { timeoutMs: 60000 });
|
||||
|
||||
const [, , options] = execFileMock.mock.calls[0];
|
||||
expect(options).toEqual({ timeout: 60000 });
|
||||
});
|
||||
|
||||
it('uses FFMPEG_PATH from the environment when set', async () => {
|
||||
const previous = process.env.FFMPEG_PATH;
|
||||
process.env.FFMPEG_PATH = '/opt/ffmpeg/bin/ffmpeg';
|
||||
|
||||
await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav');
|
||||
|
||||
expect(execFileMock.mock.calls[0][0]).toBe('/opt/ffmpeg/bin/ffmpeg');
|
||||
|
||||
if (previous === undefined) delete process.env.FFMPEG_PATH;
|
||||
else process.env.FFMPEG_PATH = previous;
|
||||
});
|
||||
|
||||
it('propagates a rejection from execFile as a rejected promise', async () => {
|
||||
execFileMock.mockImplementation((file, args, options, callback) =>
|
||||
callback(new Error('ffmpeg exited with code 1'), '', 'Unknown encoder')
|
||||
);
|
||||
|
||||
await expect(resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav')).rejects.toThrow(
|
||||
/exited with code 1/
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
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/audio.test.js`
|
||||
Expected: PASS (9 tests total)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/converters/audio.js test/converters/audio.test.js
|
||||
git commit -m "feat(audio): add ffmpeg-backed audio converter for mp3/wav/ogg/flac/aac/m4a"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire registration + bitrate validation into `app.js`/`worker.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app.js` (import, `registerAllConverters()`, `isValidQuality`)
|
||||
- Modify: `src/worker.js` (import, `main()`)
|
||||
- Test: `test/api/jobs.test.js` (new describe block)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `registerAudioConverters` from `./converters/audio.js` (Task 1).
|
||||
- Produces: `POST /api/jobs` now accepts `sourceFormat`/`targetFormat` pairs among the 6 audio formats with `family: 'audio'`, and validates `quality` as one of `[128, 192, 256, 320]` (or `null`) for `mp3`/`ogg`/`aac`/`m4a`, and only `null` for `wav`/`flac`.
|
||||
|
||||
- [ ] **Step 1: Write the failing integration tests**
|
||||
|
||||
Append to `test/api/jobs.test.js`, after the `POST /api/jobs — archives` block (before its closing nothing — just append at end of file):
|
||||
|
||||
```js
|
||||
describe('POST /api/jobs — audio', () => {
|
||||
it('creates a pending job converting a wav upload to mp3 with a valid bitrate', async () => {
|
||||
const fixturePath = path.join(config.storageDir, 'clip.wav');
|
||||
// Minimal RIFF/WAVE header: 'RIFF' + 4-byte size (unchecked) + 'WAVE'.
|
||||
await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.field('targetFormats', JSON.stringify(['mp3']))
|
||||
.field('qualities', JSON.stringify([192]))
|
||||
.attach('files', fixturePath, 'clip.wav');
|
||||
|
||||
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('wav');
|
||||
expect(job.targetFormat).toBe('mp3');
|
||||
expect(job.family).toBe('audio');
|
||||
expect(job.quality).toBe(192);
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('rejects a bitrate outside the fixed chip set for an mp3 target', async () => {
|
||||
const fixturePath = path.join(config.storageDir, 'bad-bitrate.wav');
|
||||
await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.field('targetFormats', JSON.stringify(['mp3']))
|
||||
.field('qualities', JSON.stringify([200]))
|
||||
.attach('files', fixturePath, 'bad-bitrate.wav');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.jobs[0].error).toMatch(/Invalid quality/);
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('rejects a quality value for a wav target (lossless, no bitrate control)', async () => {
|
||||
const fixturePath = path.join(config.storageDir, 'to-wav.wav');
|
||||
await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.field('targetFormats', JSON.stringify(['flac']))
|
||||
.field('qualities', JSON.stringify([192]))
|
||||
.attach('files', fixturePath, 'to-wav.wav');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.jobs[0].error).toMatch(/Invalid quality/);
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('creates a pending job converting flac to wav with no quality value', async () => {
|
||||
const fixturePath = path.join(config.storageDir, 'clip.flac');
|
||||
await fs.writeFile(fixturePath, Buffer.from('fLaC'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.field('targetFormats', JSON.stringify(['wav']))
|
||||
.attach('files', fixturePath, 'clip.flac');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const job = await getJobByUuid(prisma, response.body.jobs[0].id);
|
||||
expect(job.sourceFormat).toBe('flac');
|
||||
expect(job.targetFormat).toBe('wav');
|
||||
expect(job.quality).toBeNull();
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('lists the other 5 audio formats as targets for mp3, and never lists mp3 as its own target', async () => {
|
||||
const response = await request(app).get('/api/formats').query({ source: 'mp3' });
|
||||
|
||||
expect(response.body.targets).toEqual(expect.arrayContaining(['wav', 'ogg', 'flac', 'aac', 'm4a']));
|
||||
expect(response.body.targets).not.toContain('mp3');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **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 — jobs come back with `error: "Unsupported conversion: wav to mp3"` (registry has no `audio` entries yet), and the `mp3` case in `GET /api/formats` returns an empty/unrelated list.
|
||||
|
||||
- [ ] **Step 3: Wire registration into `src/app.js`**
|
||||
|
||||
In `src/app.js`, add the import alongside the other converter imports (after line 16, `import { registerArchiveConverters } from './converters/archive.js';`):
|
||||
|
||||
```js
|
||||
import { registerAudioConverters } from './converters/audio.js';
|
||||
```
|
||||
|
||||
In `registerAllConverters()` (around line 42-54), add the call alongside the other eight:
|
||||
|
||||
```js
|
||||
function registerAllConverters() {
|
||||
if (convertersRegistered) return;
|
||||
registerImageConverters();
|
||||
registerImageToPdfConverter();
|
||||
registerDocumentConverters();
|
||||
registerIcoConverter();
|
||||
registerHeicConverter();
|
||||
registerFontConverter();
|
||||
registerDfontConverter();
|
||||
registerEbookConverter();
|
||||
registerArchiveConverters();
|
||||
registerAudioConverters();
|
||||
convertersRegistered = true;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add bitrate validation to `isValidQuality` in `src/app.js`**
|
||||
|
||||
Replace the existing `isValidQuality` function (lines 25-32):
|
||||
|
||||
```js
|
||||
function isValidQuality(targetFormat, quality) {
|
||||
if (quality === null || quality === undefined) return true;
|
||||
if (!Number.isInteger(quality)) return false;
|
||||
if (targetFormat === 'gif' || targetFormat === 'ico' || targetFormat === 'tar') return false;
|
||||
if (targetFormat === 'png') return quality >= 0 && quality <= 9;
|
||||
if (['zip', 'tar.gz', 'tar.bz2', '7z', 'tar.7z'].includes(targetFormat)) return quality >= 0 && quality <= 9;
|
||||
if (['wav', 'flac'].includes(targetFormat)) return false;
|
||||
if (['mp3', 'ogg', 'aac', 'm4a'].includes(targetFormat)) return [128, 192, 256, 320].includes(quality);
|
||||
return quality >= 1 && quality <= 100;
|
||||
}
|
||||
```
|
||||
|
||||
(The `wav`/`flac` branch must come before the final fallthrough so a non-null quality for those formats is rejected rather than falling into the generic `1-100` range check.)
|
||||
|
||||
- [ ] **Step 5: Wire registration into `src/worker.js`**
|
||||
|
||||
Add the import alongside the other converter imports (after line 16, `import { registerArchiveConverters } from './converters/archive.js';`):
|
||||
|
||||
```js
|
||||
import { registerAudioConverters } from './converters/audio.js';
|
||||
```
|
||||
|
||||
In `main()` (around lines 85-93), add the call alongside the other eight:
|
||||
|
||||
```js
|
||||
registerImageConverters();
|
||||
registerImageToPdfConverter();
|
||||
registerDocumentConverters();
|
||||
registerIcoConverter();
|
||||
registerHeicConverter();
|
||||
registerFontConverter();
|
||||
registerDfontConverter();
|
||||
registerEbookConverter();
|
||||
registerArchiveConverters();
|
||||
registerAudioConverters();
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
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: PASS (all tests in the file, including the new `POST /api/jobs — audio` block)
|
||||
|
||||
- [ ] **Step 7: Run the full backend test suite to check for regressions**
|
||||
|
||||
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: PASS, except the two pre-existing unrelated failures documented in `CLAUDE.md` (`test/cleanup.test.js` and `test/jobs/jobRepository.test.js`, both clock/timezone issues predating this change — confirm no *new* failures beyond those two).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app.js src/worker.js test/api/jobs.test.js
|
||||
git commit -m "feat(audio): wire audio converters and bitrate validation into app.js/worker.js"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: MIME types for audio formats
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/mime.js`
|
||||
- Test: `test/mime.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new (existing `OUTPUT_MIME_TYPES` map and `resolveInputFormat`/`detectInputMime` in `src/mime.js`).
|
||||
- Produces: `outputMimeType('mp3'|'wav'|'ogg'|'flac'|'aac'|'m4a')` returns a real MIME string instead of throwing; `resolveInputFormat` accepts real content declared under each of these 6 formats.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `test/mime.test.js`, after the `describe('resolveInputFormat — compound archive extensions', ...)` block at the end of the file:
|
||||
|
||||
```js
|
||||
describe('outputMimeType — audio', () => {
|
||||
it('returns the correct MIME type for each audio target format', () => {
|
||||
expect(outputMimeType('mp3')).toBe('audio/mpeg');
|
||||
expect(outputMimeType('wav')).toBe('audio/wav');
|
||||
expect(outputMimeType('ogg')).toBe('audio/ogg');
|
||||
expect(outputMimeType('flac')).toBe('audio/flac');
|
||||
expect(outputMimeType('aac')).toBe('audio/aac');
|
||||
expect(outputMimeType('m4a')).toBe('audio/x-m4a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveInputFormat — audio formats', () => {
|
||||
it('accepts a WAV file (RIFF....WAVE) declared as wav', async () => {
|
||||
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.wav');
|
||||
await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));
|
||||
|
||||
const result = await resolveInputFormat(fixturePath, 'wav');
|
||||
expect(result).toEqual({ mime: 'audio/wav', valid: true });
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('accepts a FLAC file (fLaC magic) declared as flac', async () => {
|
||||
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.flac');
|
||||
await fs.writeFile(fixturePath, Buffer.from('fLaC'));
|
||||
|
||||
const result = await resolveInputFormat(fixturePath, 'flac');
|
||||
expect(result).toEqual({ mime: 'audio/flac', valid: true });
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('accepts an MP3 file (MPEG layer 3 frame sync) declared as mp3', async () => {
|
||||
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.mp3');
|
||||
// Frame sync 0xFF 0xFB: byte1 & 0xE0 == 0xE0 (sync), byte1 & 0x06 == 0x02 (layer 3, not ADTS).
|
||||
await fs.writeFile(fixturePath, Buffer.from([0xff, 0xfb, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00]));
|
||||
|
||||
const result = await resolveInputFormat(fixturePath, 'mp3');
|
||||
expect(result).toEqual({ mime: 'audio/mpeg', valid: true });
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('accepts an AAC/ADTS file declared as aac', async () => {
|
||||
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.aac');
|
||||
// Frame sync 0xFF 0xF1: byte1 & 0xE0 == 0xE0 (sync), byte1 & 0x16 == 0x10 (ADTS, not layer 3).
|
||||
await fs.writeFile(fixturePath, Buffer.from([0xff, 0xf1, 0x4c, 0x80, 0x01, 0x3f, 0xfc, 0x00]));
|
||||
|
||||
const result = await resolveInputFormat(fixturePath, 'aac');
|
||||
expect(result).toEqual({ mime: 'audio/aac', valid: true });
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('accepts an OGG/Vorbis file declared as ogg', async () => {
|
||||
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.ogg');
|
||||
// 'OggS' + 28 ignored bytes + 8-byte packet-type field starting '\x01vorbis'.
|
||||
const body = Buffer.concat([
|
||||
Buffer.from('OggS'),
|
||||
Buffer.alloc(28),
|
||||
Buffer.concat([Buffer.from([0x01]), Buffer.from('vorbis'), Buffer.alloc(1)]),
|
||||
]);
|
||||
await fs.writeFile(fixturePath, body);
|
||||
|
||||
const result = await resolveInputFormat(fixturePath, 'ogg');
|
||||
expect(result).toEqual({ mime: 'audio/ogg', valid: true });
|
||||
|
||||
await fs.unlink(fixturePath);
|
||||
});
|
||||
|
||||
it('accepts an M4A file (ISO-BMFF ftyp box, M4A brand) declared as m4a', async () => {
|
||||
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.m4a');
|
||||
// 4-byte box size (unchecked) + 'ftyp' + major brand 'M4A ' (space-padded, 4 bytes).
|
||||
const body = Buffer.concat([Buffer.alloc(4), Buffer.from('ftyp'), Buffer.from('M4A ')]);
|
||||
await fs.writeFile(fixturePath, body);
|
||||
|
||||
const result = await resolveInputFormat(fixturePath, 'm4a');
|
||||
expect(result).toEqual({ mime: 'audio/x-m4a', 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('mp3')` etc. throw `No known MIME type for target format "mp3"`.
|
||||
|
||||
- [ ] **Step 3: Add the MIME entries in `src/mime.js`**
|
||||
|
||||
In `src/mime.js`, add to `OUTPUT_MIME_TYPES` (after the existing `'tar.7z': 'application/x-7z-compressed',` line):
|
||||
|
||||
```js
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
aac: 'audio/aac',
|
||||
m4a: 'audio/x-m4a',
|
||||
```
|
||||
|
||||
No other changes to `src/mime.js` — `resolveInputFormat`/`normalizeFormat` need no alias entries for these six formats (each detects with an `ext` that already matches the declared format string exactly).
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
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: PASS (all tests in the file)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mime.js test/mime.test.js
|
||||
git commit -m "feat(audio): add MIME types for mp3/wav/ogg/flac/aac/m4a"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — recognize the `audio` family (icon, color, formats grid)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/data/formats.js`
|
||||
- Modify: `frontend/src/utils/fileFamily.js`
|
||||
- Modify: `frontend/src/components/FormatsGrid.jsx`
|
||||
- Modify: `frontend/src/index.css`
|
||||
- Modify: `frontend/src/locales/en.json`
|
||||
- Modify: `frontend/src/locales/fr.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new from earlier tasks (this task only needs the format strings `mp3`/`wav`/`ogg`/`flac`/`aac`/`m4a`, which are plain data, not an import).
|
||||
- Produces: `familyForExtension('mp3')` (etc.) returns `'audio'`; `fileTypeMeta('mp3')` returns `{ Icon: MusicNotes, colorVar: '--color-family-audio' }`; the "Supported formats" section on the homepage shows an Audio tile listing the 6 formats. No automated test — this repo has no frontend test suite; verified manually in Task 5 once the upload flow can exercise it end-to-end.
|
||||
|
||||
- [ ] **Step 1: Add the `audio` family to `frontend/src/data/formats.js`**
|
||||
|
||||
Add a new entry to the `FORMAT_FAMILIES` array (after the `archives` entry):
|
||||
|
||||
```js
|
||||
{
|
||||
key: 'audio',
|
||||
formats: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'],
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the icon mapping in `frontend/src/utils/fileFamily.js`**
|
||||
|
||||
Change the import line to add `MusicNotes`:
|
||||
|
||||
```js
|
||||
import { Image, FileText, TextAa, BookOpen, Archive, MusicNotes, File as FileIcon } from '@phosphor-icons/react';
|
||||
```
|
||||
|
||||
Add to `FAMILY_ICONS`:
|
||||
|
||||
```js
|
||||
const FAMILY_ICONS = {
|
||||
images: Image,
|
||||
documents: FileText,
|
||||
fonts: TextAa,
|
||||
ebooks: BookOpen,
|
||||
archives: Archive,
|
||||
audio: MusicNotes,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the icon mapping in `frontend/src/components/FormatsGrid.jsx`**
|
||||
|
||||
Change the import line to add `MusicNotes`:
|
||||
|
||||
```jsx
|
||||
import { Image, FileText, TextAa, BookOpen, Archive, MusicNotes } from '@phosphor-icons/react';
|
||||
```
|
||||
|
||||
Add to `FAMILY_ICONS`:
|
||||
|
||||
```js
|
||||
const FAMILY_ICONS = {
|
||||
images: Image,
|
||||
documents: FileText,
|
||||
fonts: TextAa,
|
||||
ebooks: BookOpen,
|
||||
archives: Archive,
|
||||
audio: MusicNotes,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the color variable in `frontend/src/index.css`**
|
||||
|
||||
In the light-theme block (after `--color-family-archives: #64748b;`):
|
||||
|
||||
```css
|
||||
--color-family-audio: #ec4899;
|
||||
```
|
||||
|
||||
In the dark-theme block (after `--color-family-archives: #94a3b8;`):
|
||||
|
||||
```css
|
||||
--color-family-audio: #f472b6;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add locale strings**
|
||||
|
||||
In `frontend/src/locales/en.json`, in the `formats` object (after `"archives": "Archives"`):
|
||||
|
||||
```json
|
||||
"archives": "Archives",
|
||||
"audio": "Audio"
|
||||
```
|
||||
|
||||
In `frontend/src/locales/fr.json`, in the `formats` object (after `"archives": "Archives"`):
|
||||
|
||||
```json
|
||||
"archives": "Archives",
|
||||
"audio": "Audio"
|
||||
```
|
||||
|
||||
(Note: this adds a trailing comma to the existing `archives` line in both files — check the JSON is still valid after editing, since it's currently the last key in the object before the closing brace.)
|
||||
|
||||
- [ ] **Step 6: Manually verify in the browser**
|
||||
|
||||
Run: `npm run dev --prefix frontend` (check first whether a dev server is already running per `CLAUDE.md`'s guidance on pre-existing processes before starting a new one)
|
||||
|
||||
Open the homepage, scroll to "Supported formats" / "Formats pris en charge", and confirm an "Audio" tile appears listing `MP3, WAV, OGG, FLAC, AAC, M4A` with a pink music-note icon, matching the visual style of the other family tiles.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/data/formats.js frontend/src/utils/fileFamily.js frontend/src/components/FormatsGrid.jsx frontend/src/index.css frontend/src/locales/en.json frontend/src/locales/fr.json
|
||||
git commit -m "feat(audio): recognize the audio family in the frontend formats grid"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — bitrate chips, default quality, marquee pair
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/FileConfigCard.jsx`
|
||||
- Modify: `frontend/src/pages/HomePage.jsx`
|
||||
- Modify: `frontend/src/components/FormatMarquee.jsx`
|
||||
- Modify: `frontend/src/locales/en.json`
|
||||
- Modify: `frontend/src/locales/fr.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /api/formats?source=<audio-format>` (Task 2) to populate `item.targets` for an uploaded audio file; the `quality` field on `pendingFiles` items (existing state shape in `HomePage.jsx`, unchanged).
|
||||
- Produces: uploading an audio file shows bitrate chips (`128`/`192`/`256`/`320` kbps) for `mp3`/`ogg`/`aac`/`m4a` targets, no control for `wav`/`flac` targets, and `POST /api/jobs` receives a `qualities` entry matching the selected chip.
|
||||
|
||||
- [ ] **Step 1: Add the bitrate chip group to `frontend/src/components/FileConfigCard.jsx`**
|
||||
|
||||
Add a new constant near the top of the file (alongside `QUALITY_FORMATS`/`ICON_SIZES`):
|
||||
|
||||
```js
|
||||
const AUDIO_BITRATE_FORMATS = ['mp3', 'ogg', 'aac', 'm4a'];
|
||||
const AUDIO_BITRATES = [128, 192, 256, 320];
|
||||
```
|
||||
|
||||
Add a new conditional block inside `file-config-controls`, after the `ARCHIVE_COMPRESSION_FORMATS` block and before the `item.targetFormat === 'ico'` block:
|
||||
|
||||
```jsx
|
||||
{AUDIO_BITRATE_FORMATS.includes(item.targetFormat) && (
|
||||
<div className="chip-field">
|
||||
<span>{t('quality.bitrate')}</span>
|
||||
<div className="chip-group" role="group" aria-label={t('quality.bitrate')}>
|
||||
{AUDIO_BITRATES.map((bitrate) => (
|
||||
<button
|
||||
key={bitrate}
|
||||
type="button"
|
||||
className={`chip${item.quality === bitrate ? ' chip-active' : ''}`}
|
||||
aria-pressed={item.quality === bitrate}
|
||||
onClick={() => onQualityChange(index, bitrate)}
|
||||
>
|
||||
{bitrate}kbps
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add default bitrates to `frontend/src/pages/HomePage.jsx`**
|
||||
|
||||
In the `DEFAULT_QUALITY` object, add the 4 bitrate-capable audio formats:
|
||||
|
||||
```js
|
||||
const DEFAULT_QUALITY = {
|
||||
jpg: 80,
|
||||
jpeg: 80,
|
||||
webp: 80,
|
||||
avif: 50,
|
||||
tiff: 80,
|
||||
png: 6,
|
||||
zip: 6,
|
||||
'tar.gz': 6,
|
||||
'tar.bz2': 9,
|
||||
'7z': 5,
|
||||
'tar.7z': 5,
|
||||
mp3: 192,
|
||||
ogg: 192,
|
||||
aac: 192,
|
||||
m4a: 192,
|
||||
};
|
||||
```
|
||||
|
||||
(`wav`/`flac` are intentionally omitted — `defaultQualityFor` already returns `null` for anything absent from this map.)
|
||||
|
||||
- [ ] **Step 3: Add a marquee pair to `frontend/src/components/FormatMarquee.jsx`**
|
||||
|
||||
Add one pair to `PAIRS`:
|
||||
|
||||
```js
|
||||
const PAIRS = [
|
||||
['heic', 'jpg'],
|
||||
['png', 'webp'],
|
||||
['docx', 'pdf'],
|
||||
['ttf', 'woff'],
|
||||
['epub', 'mobi'],
|
||||
['rar', 'zip'],
|
||||
['wav', 'mp3'],
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `bitrate` locale key**
|
||||
|
||||
In `frontend/src/locales/en.json`, in the `quality` object (after `"compressPdf": "Compress as JPEG"` or whatever the last existing key is — check the file first since only `fr.json` was read directly; match its existing key order/style):
|
||||
|
||||
```json
|
||||
"bitrate": "Bitrate"
|
||||
```
|
||||
|
||||
In `frontend/src/locales/fr.json`, in the `quality` object (after `"compressPdf": "Compresser en JPEG"`):
|
||||
|
||||
```json
|
||||
"bitrate": "Bitrate"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Manually verify in the browser**
|
||||
|
||||
With the frontend dev server running (from Task 4, Step 6), and the backend server/worker running per `CLAUDE.md`'s guidance on pre-existing processes:
|
||||
|
||||
1. Upload a `.wav` file. Confirm the target-format chips include `MP3`, `OGG`, `FLAC`, `AAC`, `M4A` (not `WAV`).
|
||||
2. Select `MP3` as the target. Confirm bitrate chips `128kbps`/`192kbps`/`256kbps`/`320kbps` appear, with `192kbps` pre-selected.
|
||||
3. Switch the target to `FLAC`. Confirm the bitrate chips disappear (no quality control at all).
|
||||
4. Switch back to `MP3`, click convert. Confirm the job is created as `pending` (note: since ffmpeg is not installed locally, per `CLAUDE.md` guidance the job will end up `failed` once a worker picks it up and shells out to a nonexistent `ffmpeg` binary — that failure is expected in this environment and is not a regression; it confirms the *job creation and validation* path, not the actual conversion, which can only be confirmed where ffmpeg is present).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/FileConfigCard.jsx frontend/src/pages/HomePage.jsx frontend/src/components/FormatMarquee.jsx frontend/src/locales/en.json frontend/src/locales/fr.json
|
||||
git commit -m "feat(audio): add bitrate chip control, default bitrates, and marquee pair"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-plan note (not a task — informational only)
|
||||
|
||||
Deploying this to o2switch requires no migration and no new dependency install beyond what's already there — `FFMPEG_PATH` is already set per the user. Confirm `FFMPEG_PATH` points at a real, executable ffmpeg binary on that host before relying on this in production (the existing `.env.local` mirrors `CALIBRE_PATH` with a real o2switch path as a reference for what `FFMPEG_PATH` should look like there).
|
||||
Reference in New Issue
Block a user