/** * Extract 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(); /* There is no delete_source here, and that is deliberate rather than an omission. Extracting is ADDITIVE: it produces a new file and leaves the video alone. Offering to delete the source would mean offering to delete a video in exchange for its soundtrack, which is not a trade anybody makes on purpose and is not one to put behind a checkbox. */ 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([ 'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', ]); /** 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 } = 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 video 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 (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, // `vn: true` is what makes this an extraction rather than a conversion — the video stream is // dropped and only the audio is encoded. outputOptions: { vn: true, ...ENCODING[target], map_metadata: '0' }, }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; const message = body.error?.message ?? `HTTP ${res.status}`; /* A video with no audio track is not a failure, it is a fact about the video — and in a folder of screen recordings it is most of them. Reported as a skip so the summary does not read as broken. ffmpeg says "Output file does not contain any stream" for exactly this. */ if (/does not contain any stream|Invalid argument/i.test(message)) { console.log(`Skipping ${name} — no audio track`); skipped++; } else { console.error(`Failed ${name}: ${message}`); failed++; } continue; } const result = (await res.json()) as { bytes: number }; console.log(`Extracted ${name} → ${basename(output)} (${(result.bytes / 1_048_576).toFixed(1)} MB)`); done++; } catch (error) { console.error(`Failed ${name}: ${(error as Error).message}`); failed++; } } console.log(''); console.log(`${done} extracted, ${skipped} skipped, ${failed} failed.`); process.exit(failed > 0 ? 1 : 0);