/** * Convert Audio — a thin client of av-club's `/v1/ffmpeg`. * * ── Why this file repeats itself against its two siblings ── * * The task runner materialises ONLY `index.ts` into a temp directory, so a script task cannot import * a sibling. Extract Audio and Convert Video therefore carry their own copies of the walk, the * skip rules and the reporting. That duplication is the platform's shape, not a choice — and the part * that actually matters, what ffmpeg is told, lives once in av-club rather than three times here. * * ── The rules are the old task's, kept because they were right ── * * Skip a file already in the target format; skip when the output exists; never overwrite. All three * come from the version of this that ran on alpha for months, and each exists because of something * that went wrong without it. */ const API = process.env.OFFICER_API_URL; const HOST = process.env.OFFICER_API_HOST; const TOKEN = process.env.OFFICER_AUTH_TOKEN; if (!API || !TOKEN) { console.error('No OFFICER_API_URL / OFFICER_AUTH_TOKEN — this only runs as a task.'); process.exit(1); } const target = (process.env.INPUT_TARGET_FORMAT || 'mp3').toLowerCase(); const deleteSource = (process.env.INPUT_DELETE_SOURCE || 'false') === 'true'; const start = process.argv[2] || process.env.INPUT_FILE_PATH; if (!start) { console.error('No file or folder given.'); process.exit(1); } const SOURCE_EXT = new Set([ 'mp3', 'flac', 'wav', 'ogg', 'oga', 'wma', 'aac', 'm4a', 'opus', 'aiff', 'aif', 'ape', 'wv', 'alac', 'dsf', 'dff', ]); /** Quality per format, chosen for how it sounds rather than how big it is. Mirrors the old task. */ const ENCODING: Record> = { mp3: { 'c:a': 'libmp3lame', 'b:a': '320k', id3v2_version: '3' }, m4a: { 'c:a': 'aac', 'b:a': '256k' }, flac: { 'c:a': 'flac' }, wav: { 'c:a': 'pcm_s16le' }, ogg: { 'c:a': 'libvorbis', 'q:a': 6 }, opus: { 'c:a': 'libopus', 'b:a': '192k' }, }; if (!ENCODING[target]) { console.error(`Cannot convert to ${target}. Choose one of: ${Object.keys(ENCODING).join(', ')}`); process.exit(1); } const { readdir, stat, unlink } = await import('node:fs/promises'); const { join, extname, dirname, basename } = await import('node:path'); const headers: Record = { Authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json', ...(HOST ? { Host: HOST } : {}), }; const exists = async (p: string) => !!(await stat(p).catch(() => null)); /** Every audio file under a folder, deepest-last, sorted so a run is reproducible. */ async function collect(dir: string): Promise { const found: string[] = []; for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { const full = join(dir, entry.name); if (entry.isDirectory()) found.push(...(await collect(full))); else if (SOURCE_EXT.has(extname(entry.name).slice(1).toLowerCase())) found.push(full); } return found; } const info = await stat(start).catch(() => null); if (!info) { console.error(`Not there: ${start}`); process.exit(1); } const files = info.isDirectory() ? await collect(start) : [start]; if (files.length === 0) { console.log(`No audio files in ${start}`); process.exit(0); } let done = 0; let skipped = 0; let failed = 0; for (const [index, input] of files.entries()) { const name = basename(input); const ext = extname(input).slice(1).toLowerCase(); const output = join(dirname(input), `${basename(input, extname(input))}.${target}`); // The sentinel the jobs view reads for its progress bar. Printed before the work, so a long // conversion shows which file it is on rather than jumping when it finishes. console.log(`@@officer:progress@@ ${JSON.stringify({ current: index + 1, total: files.length, label: name })}`); if (ext === target) { console.log(`Skipping ${name} — already ${target}`); skipped++; continue; } if (await exists(output)) { console.log(`Skipping ${name} — ${basename(output)} already exists`); skipped++; continue; } try { const res = await fetch(`${API}/api/av-club/v1/ffmpeg`, { method: 'POST', headers, body: JSON.stringify({ inputPath: input, outputPath: output, format: target, // Tags carried across. Without this a converted album loses its artist and track numbers, // which is the kind of thing nobody notices until the library is already rebuilt. outputOptions: { ...ENCODING[target], map_metadata: '0' }, }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; console.error(`Failed ${name}: ${body.error?.message ?? `HTTP ${res.status}`}`); failed++; continue; } const result = (await res.json()) as { bytes: number }; console.log(`Converted ${name} → ${basename(output)} (${(result.bytes / 1_048_576).toFixed(1)} MB)`); done++; /* Deleted only AFTER a successful write, and only when asked. av-club writes into a temp file and moves it into place, so reaching this line means the output is complete and on disk. That ordering is the whole reason deleting is safe to offer at all. */ if (deleteSource) { await unlink(input); console.log(` removed ${name}`); } } catch (error) { console.error(`Failed ${name}: ${(error as Error).message}`); failed++; } } console.log(''); console.log(`${done} converted, ${skipped} skipped, ${failed} failed.`); process.exit(failed > 0 ? 1 : 0);