From 7d57deeb3f1f58b520b530894f83b43ac48f9b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 02:17:57 +0100 Subject: [PATCH] three tasks on the right-click menu, and a skill for the agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert Audio, Extract Audio, Convert Video. ported from the versions that ran on alpha for months, which is where the rules come from — they are not invented: skip a file already in the target format (re-encoding mp3 to mp3 loses quality for nothing) never overwrite an existing output (the file there may be one somebody made) delete the source only AFTER the output exists, and only when asked `inline: ask`, so the modal offers running it now or as a job — a few files inline, a discography as a job. that was the affordance the old ones had and the reason the choice exists. THIN CLIENTS. every one calls /v1/ffmpeg; none of them runs ffmpeg. what to tell ffmpeg lives once in av-club rather than three times in bash, which is the whole point of the plugin existing. the three files nevertheless repeat their walk and their reporting, because the task runner materialises ONLY index.ts and a script task cannot import a sibling — that duplication is the platform's shape, not a choice, and each file says so. CONVERT VIDEO PROBES BEFORE IT DECIDES. if the codecs already fit the container it asks for `c:v copy, c:a copy` — a remux, lossless, roughly a hundred times faster than re-encoding. guessing from the extension is how a "fast remux" silently re-encodes, since plenty of .avi holds h264. that needed a path probe, so /v1/probe?path= is added beside it, same containment, read-only. EXTRACT AUDIO HAS NO delete_source, deliberately. extracting is additive; offering to delete would be offering to trade a video for its soundtrack. a video with no audio track is reported as a skip rather than a failure — in a folder of screen recordings that is most of them. what is NOT here: track selection, subtitle handling, downmixing, per-file percentage. those belong with jellyfin rather than with basic conversion and are left where they are rather than half-ported. convert-video's TASK.md says so. VERIFIED by running each task the way the runner does — temp dir, env vars, argv: flac→mp3 on a folder; the re-run skipping both files for the two different reasons; extraction skipping the silent clip and converting the other; mp4→mkv copying h264 and aac through untouched; mp4→webm re-encoding to vp9/opus; delete_source removing a source after success and LEAVING it after a failure. the skill tells an agent to use the endpoint and never to shell out to ffmpeg — everything that makes this safe is on the far side of it — and to offer the right-click tasks first when somebody is at a screen. Co-Authored-By: Claude Opus 5 --- sidecar/index.ts | 15 +++ skills/media-conversion/SKILL.md | 108 ++++++++++++++++ tasks/convert-audio/TASK.md | 45 +++++++ tasks/convert-audio/index.ts | 157 ++++++++++++++++++++++++ tasks/convert-video/TASK.md | 46 +++++++ tasks/convert-video/index.ts | 203 +++++++++++++++++++++++++++++++ tasks/extract-audio/TASK.md | 37 ++++++ tasks/extract-audio/index.ts | 159 ++++++++++++++++++++++++ 8 files changed, 770 insertions(+) create mode 100644 skills/media-conversion/SKILL.md create mode 100644 tasks/convert-audio/TASK.md create mode 100644 tasks/convert-audio/index.ts create mode 100644 tasks/convert-video/TASK.md create mode 100644 tasks/convert-video/index.ts create mode 100644 tasks/extract-audio/TASK.md create mode 100644 tasks/extract-audio/index.ts 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);