diff --git a/sidecar/index.ts b/sidecar/index.ts index 3456365..4fc2945 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -5,6 +5,7 @@ import { contentPath, deleteAsset, putAsset, readAsset } from "./assets/store"; import { ffprobe } from "./probe/ffprobe"; import { runSharp, type SharpRequest } from "./sharp/run"; import { runFfmpeg, type FfmpegRequest } from "./ffmpeg/run"; +import { safeInputPath } from "./paths"; import sharp from "sharp"; import { createSidecarConnector } from "@@/sidecar/connect"; import { API_URL } from "@@/officer-url.mjs"; @@ -148,6 +149,20 @@ async function route(request: Request): Promise { if (path === "/v1/assets" && request.method === "POST") return handleUpload(request); + /* + What is inside a file already on disk. + + The same answer as `/v1/assets/:id/probe`, for the same reason the ffmpeg endpoint grew a path + mode: a caller deciding whether a conversion can be a lossless remux has to know the codecs, and + uploading a two-hour film to ask is not a way to find out. Confined by the same `safeInputPath`. + + Read-only, so there is no output path to check — ffprobe is told a file and prints what it sees. + */ + if (path === "/v1/probe" && request.method === "GET") { + const target = await safeInputPath(url.searchParams.get("path"), "path"); + return Response.json(await withSlot(() => ffprobe(target))); + } + const asset = path.match(/^\/v1\/assets\/([^/]+)(\/content|\/probe)?$/); if (asset) { const [, id, suffix] = asset; diff --git a/skills/media-conversion/SKILL.md b/skills/media-conversion/SKILL.md new file mode 100644 index 0000000..dd4f0c8 --- /dev/null +++ b/skills/media-conversion/SKILL.md @@ -0,0 +1,108 @@ +--- +name: media-conversion +title: Converting media +description: Use when asked to convert, re-encode, remux or extract audio from a video or audio file, to make a web-friendly version of something, or to pull a still out of a video — "make this mp4", "get the audio off this", "these need to be smaller for the site". Carries the one endpoint, the copy-or-encode decision, and the rule that decides where the output goes. +version: 1 +--- + +You convert media through **av-club**, a plugin on this machine. One endpoint does the work and you +already have the key. + +```bash +AV=(-H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" + -H 'content-type: application/json') +curl -s "${AV[@]}" "$OFFICER_API_URL/api/av-club/health" +``` + +## Never shell out to ffmpeg yourself + +Not because you cannot, but because everything that makes this safe lives on the other side of that +endpoint: no shell, protocols disabled, filters allowlisted, work bounded, paths confined. A hand-built +`ffmpeg` command has none of it, and the first one somebody writes with a filename from a web page is +the incident. If av-club cannot express what is being asked, say so — that is a gap to report, not a +reason to go around it. + +## Files stay where they are + +```bash +curl -s "${AV[@]}" -X POST "$OFFICER_API_URL/api/av-club/v1/ffmpeg" \ + -d '{"inputPath":"/home/you/Movies/film.mkv","outputPath":"/home/you/Movies/film.mp4", + "format":"mp4","outputOptions":{"c:v":"copy","c:a":"copy","movflags":"+faststart"}}' +# {"path":"/home/you/Movies/film.mp4","bytes":734003200} +``` + +Both paths must be **absolute** and inside the owner's home. A relative path, a `..`, a symlink +pointing out, or a write outside the root are each a 400 — do not try to work around one, it is +telling you the answer is no. + +There is also an asset mode (`{"input":"as_…"}`) for files that are not on disk. Prefer paths: a +conversion of something already on the filesystem should not copy it twice. + +## Look before you convert + +```bash +curl -s "${AV[@]}" "$OFFICER_API_URL/api/av-club/v1/probe?path=/home/you/Movies/film.mkv" +``` + +**This decides the most important thing you will decide.** If the codecs already fit the target +container, ask for `"c:v":"copy","c:a":"copy"` — that is a *remux*: lossless, and about a hundred +times faster. Re-encoding a two-hour film that only needed rewrapping costs an hour of CPU and quality, +for nothing. + +- **mp4** carries h264, hevc, mpeg4 · aac, mp3 +- **mov** carries h264, hevc, prores · aac, pcm +- **mkv** carries almost anything, so converting *into* mkv is nearly always a remux +- **webm** carries only vp8, vp9, av1 · opus, vorbis — so mp4 → webm is always a re-encode + +Guessing from the extension is how a "fast remux" silently re-encodes: plenty of `.avi` holds h264. + +## Extracting audio + +`vn: true` drops the video. It is a boolean — `"1"` is refused. + +```bash +curl -s "${AV[@]}" -X POST "$OFFICER_API_URL/api/av-club/v1/ffmpeg" \ + -d '{"inputPath":"/home/you/ep.mkv","outputPath":"/home/you/ep.mp3","format":"mp3", + "outputOptions":{"vn":true,"c:a":"libmp3lame","b:a":"320k","map_metadata":"0"}}' +``` + +**A video with no audio track fails**, and that is not a broken video — screen recordings usually have +none. Report it as "no audio track", not as an error. + +## Quality settings worth reusing + +| Want | Say | +| --- | --- | +| mp3 | `c:a libmp3lame`, `b:a 320k`, `id3v2_version 3` | +| m4a | `c:a aac`, `b:a 256k` | +| opus | `c:a libopus`, `b:a 192k` | +| ogg | `c:a libvorbis`, `q:a 6` | +| flac / wav | `c:a flac` / `c:a pcm_s16le` | +| h264 re-encode | `c:v libx264`, `crf 21`, `preset medium`, `pix_fmt yuv420p` | +| for the web | add `movflags +faststart`, or playback waits for the whole file | + +**`map_metadata: "0"` or the tags are gone.** A converted album with no artist and no track numbers is +the kind of loss nobody notices until the library has been rebuilt. + +CRF, not a bitrate: constant quality means a difficult scene gets the bits it needs and a static one +does not waste them. + +## Three tasks already exist — offer them first + +**Convert Audio**, **Extract Audio** and **Convert Video** are on the right-click menu of any media +file or folder in the file browser. They do everything above, with the skip rules and a delete-source +checkbox. If the owner is at a screen, pointing at the menu is better than running curl for them. + +Reach for the API when it is not a right-click: a folder being watched, a batch inside a larger job, +or something the tasks do not offer. + +## Rules that are not negotiable + +- **The input is never modified.** av-club writes to a temp file and moves it into place, so a job + that dies leaves the original intact. Preserve that: never convert a file onto itself. +- **Never overwrite an existing output** without saying so first. The file that is there may be one + somebody made deliberately. +- **Deleting a source is the owner's decision**, and only ever after the output exists and is + non-empty. If you were not asked to delete, do not. +- **Say what you did in their words** — "made film.mp4 beside the original, 700 MB, copied both + streams so nothing was re-encoded" — not the JSON. diff --git a/tasks/convert-audio/TASK.md b/tasks/convert-audio/TASK.md new file mode 100644 index 0000000..bd99508 --- /dev/null +++ b/tasks/convert-audio/TASK.md @@ -0,0 +1,45 @@ +--- +name: Convert Audio +description: Convert audio files between formats through av-club, preserving tags. Works on one file or a whole folder. +category: Audio +version: 1 +mode: script +language: typescript +inline: ask +args: + - file_path +inputs: + file_path: + type: string + description: An audio file, or a folder to convert recursively. + target_format: + type: string + default: mp3 + options: [mp3, m4a, flac, wav, ogg, opus] + description: Format to convert to. + delete_source: + type: boolean + default: false + description: Delete each source file once its conversion has been written. +trigger: + - type: file + extensions: [mp3, flac, wav, ogg, oga, wma, aac, m4a, opus, aiff, aif, ape, wv, alac, dsf, dff] + - type: directory +--- + +# Convert Audio + +Writes the result **beside the original** — `song.flac` becomes `song.mp3` in the same folder. The +original is never modified, and is only deleted if you tick the box. + +Quality is chosen for the format rather than for size: 320k for mp3, 256k for m4a, 192k for opus, +q6 for ogg, and lossless for flac and wav. Tags are carried across. + +Two things it will not do, both deliberate: + +- **A file already in the target format is skipped.** Re-encoding mp3 to mp3 loses quality for nothing. +- **An existing output is never overwritten.** If `song.mp3` is already there, the file is skipped and + said so — you may have edited that one. + +The work happens in the av-club plugin, so the conversion rules live in one place rather than in this +script. Run it inline for a few files, or as a job for a discography. diff --git a/tasks/convert-audio/index.ts b/tasks/convert-audio/index.ts new file mode 100644 index 0000000..ada8add --- /dev/null +++ b/tasks/convert-audio/index.ts @@ -0,0 +1,157 @@ +/** + * 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); diff --git a/tasks/convert-video/TASK.md b/tasks/convert-video/TASK.md new file mode 100644 index 0000000..f738cc8 --- /dev/null +++ b/tasks/convert-video/TASK.md @@ -0,0 +1,46 @@ +--- +name: Convert Video +description: Convert video between containers through av-club — copying streams when the codec already fits, re-encoding when it does not. +category: Video +version: 1 +mode: script +language: typescript +inline: ask +args: + - file_path +inputs: + file_path: + type: string + description: A video file, or a folder to convert recursively. + target_format: + type: string + default: mp4 + options: [mp4, mkv, webm, mov] + description: Container to convert to. + delete_source: + type: boolean + default: false + description: Delete each source file once its conversion has been written. +trigger: + - type: file + extensions: [mp4, mkv, webm, mov, avi, wmv, flv, m4v, mpg, mpeg, m2ts, mts, 3gp, ogv, vob, divx, asf, f4v] + - type: directory +--- + +# Convert Video + +Writes the result **beside the original** — `film.mkv` becomes `film.mp4` in the same folder. + +**Streams are copied when they can be.** If the video and audio codecs already fit the target +container, this is a remux: lossless, and a hundred times faster than re-encoding. Only when a codec +does not fit is it re-encoded, at CRF 21 — constant quality, so the bitrate follows how difficult the +picture is rather than a number somebody guessed. Resolution and frame rate are never changed. + +Skips a file already in the target container, and never overwrites an existing output. + +## What this does not do yet + +The version of this on the old machine also picked which audio and subtitle tracks to keep, downmixed +surround to stereo, and showed per-file percentage. None of that is here. Those belong with the +Jellyfin side of things rather than with basic conversion, and are deliberately left where they are +rather than half-ported. diff --git a/tasks/convert-video/index.ts b/tasks/convert-video/index.ts new file mode 100644 index 0000000..ba365eb --- /dev/null +++ b/tasks/convert-video/index.ts @@ -0,0 +1,203 @@ +/** + * Convert Video — 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([ + 'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg', + 'mpeg', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', +]); + +/** + * What each container can hold as-is, and what to encode to when it cannot. + * + * `copy` is the whole point: if the codecs already fit, this is a REMUX — the bytes are moved into a + * new container, losslessly, in about the time it takes to read the file. Re-encoding a two-hour film + * that only needed rewrapping is an hour of CPU and a quality loss for nothing. + * + * CRF 21 rather than a bitrate for the cases that do need encoding: constant quality, so a busy scene + * gets the bits it needs and a static one does not waste them. Resolution and frame rate are never + * touched — this converts containers, it does not resize video. + */ +const CONTAINERS: Record }> = { + mp4: { + video: ['h264', 'hevc', 'mpeg4'], + audio: ['aac', 'mp3'], + encode: { 'c:v': 'libx264', crf: 21, preset: 'medium', pix_fmt: 'yuv420p', 'c:a': 'aac', 'b:a': '192k', movflags: '+faststart' }, + }, + mov: { + video: ['h264', 'hevc', 'prores'], + audio: ['aac', 'pcm_s16le'], + encode: { 'c:v': 'libx264', crf: 21, preset: 'medium', pix_fmt: 'yuv420p', 'c:a': 'aac', 'b:a': '192k' }, + }, + mkv: { + // Matroska carries essentially anything, so a conversion INTO it is nearly always a remux. + video: ['h264', 'hevc', 'vp8', 'vp9', 'av1', 'mpeg4', 'mpeg2video', 'theora'], + audio: ['aac', 'mp3', 'opus', 'vorbis', 'flac', 'ac3', 'eac3', 'dts', 'pcm_s16le'], + encode: { 'c:v': 'libx264', crf: 21, preset: 'medium', 'c:a': 'aac', 'b:a': '192k' }, + }, + webm: { + video: ['vp8', 'vp9', 'av1'], + audio: ['opus', 'vorbis'], + encode: { 'c:v': 'libvpx-vp9', crf: 32, 'c:a': 'libopus', 'b:a': '128k' }, + }, +}; + +if (!CONTAINERS[target]) { + console.error(`Cannot convert to ${target}. Choose one of: ${Object.keys(CONTAINERS).join(', ')}`); + process.exit(1); +} + +/** + * Ask av-club what is inside, so the copy-or-encode decision is made on the actual codecs. + * + * Guessing from the extension is what makes a "fast remux" silently re-encode: plenty of .avi holds + * h264 and plenty of .mp4 holds something mp4 cannot legally carry. + */ +async function streams(path: string): Promise<{ video: string | null; audio: string | null } | null> { + const res = await fetch(`${API}/api/av-club/v1/probe?path=${encodeURIComponent(path)}`, { headers }); + if (!res.ok) return null; + const body = (await res.json()) as { streams?: Array<{ codec_type?: string; codec_name?: string }> }; + const find = (kind: string) => body.streams?.find((s) => s.codec_type === kind)?.codec_name ?? null; + return { video: find('video'), audio: find('audio') }; +} + +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 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 (ext === target) { + console.log(`Skipping ${name} — already ${target}`); + skipped++; + continue; + } + if (await exists(output)) { + console.log(`Skipping ${name} — ${basename(output)} already exists`); + skipped++; + continue; + } + + // What is actually inside decides copy-or-encode. Unprobeable → encode, which is always correct + // and merely slower; assuming copy on an unknown codec produces a file that will not play. + const inside = await streams(input); + const spec = CONTAINERS[target]!; + const canCopyVideo = !!inside?.video && spec.video.includes(inside.video); + const canCopyAudio = !inside?.audio || spec.audio.includes(inside.audio); + + const outputOptions: Record = canCopyVideo + ? { 'c:v': 'copy', 'c:a': canCopyAudio ? 'copy' : (spec.encode['c:a'] as string), ...(canCopyAudio ? {} : { 'b:a': spec.encode['b:a'] }) } + : { ...spec.encode }; + if (target === 'mp4' && canCopyVideo) outputOptions.movflags = '+faststart'; + + console.log(` ${canCopyVideo ? (canCopyAudio ? 'remux (copying both streams)' : 'remux, re-encoding audio') : 're-encoding'}`); + + try { + const res = await fetch(`${API}/api/av-club/v1/ffmpeg`, { + method: 'POST', + headers, + body: JSON.stringify({ inputPath: input, outputPath: output, format: target, outputOptions }), + }); + + 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); diff --git a/tasks/extract-audio/TASK.md b/tasks/extract-audio/TASK.md new file mode 100644 index 0000000..36905d5 --- /dev/null +++ b/tasks/extract-audio/TASK.md @@ -0,0 +1,37 @@ +--- +name: Extract Audio +description: Pull the audio out of a video into its own file, at the best quality for the chosen format. +category: Audio +version: 1 +mode: script +language: typescript +inline: ask +args: + - file_path +inputs: + file_path: + type: string + description: A video file, or a folder to process recursively. + target_format: + type: string + default: mp3 + options: [mp3, m4a, flac, wav, ogg, opus] + description: Audio format to write. +trigger: + - type: file + extensions: [mp4, mkv, webm, mov, avi, wmv, flv, m4v, mpg, mpeg, m2ts, mts, 3gp, ogv, vob, divx, asf, f4v] + - type: directory +--- + +# Extract Audio + +Writes the audio beside the video — `episode.mkv` produces `episode.mp3` in the same folder. + +**The video is never modified and never deleted**, which is why this task has no delete-source option +where Convert Audio has one: extracting is additive, and there is nothing here you would want gone. + +Quality matches Convert Audio: 320k for mp3, 256k for m4a, 192k for opus, q6 for ogg, lossless for +flac and wav. + +An existing output is skipped rather than overwritten, so a re-run will not quietly replace a file +you have since edited. A video with no audio track at all is reported and skipped. diff --git a/tasks/extract-audio/index.ts b/tasks/extract-audio/index.ts new file mode 100644 index 0000000..c7aa043 --- /dev/null +++ b/tasks/extract-audio/index.ts @@ -0,0 +1,159 @@ +/** + * 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);