skip non-extractable subs (dvdsub/dvbsub/unknown), summarise ffmpeg errors
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s

Abraham Lincoln crashed with exit 234 because the file had 14 dvd_subtitle
streams: our extraction dict only keyed on the long form (dvd_subtitle)
while jellyfin stores the short form (dvdsub), so the lookup fell back
to .srt, ffmpeg picked the srt muxer, and srt can't encode image-based
subs. textbook silent dict miss.

replaced the extension dict with an EXTRACTABLE map that pairs codec →
{ext, codecArg} and explicitly enumerates every codec we can route to a
single-file sidecar. everything else (dvd_subtitle/dvdsub, dvb_subtitle/
dvbsub, unknown codecs) is now skipped at command-build time. the plan
picks up a note like '14 subtitle(s) dropped: dvdsub (eng, est, ind,
kor, jpn, lav, lit, may, chi, chi, tha, vie, rus, ukr) — not extractable
to sidecar' so the user sees exactly what didn't make it.

also added extractErrorSummary in execute.ts: when a job errors, scan
the last 60 stderr lines for fatal keywords (Error:, Conversion failed!,
Unsupported, Invalid argument, Permission denied, No space left, …),
dedupe, prepend the summary to the job's stored output. the review_plan
notes get the same summary — surfaces the real cause next to the plan
instead of burying it under ffmpeg's 200-line banner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 18:42:05 +02:00
parent afd95f06df
commit d2983d5f38
5 changed files with 147 additions and 32 deletions

View File

@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { extractErrorSummary } from "../execute";
describe("extractErrorSummary", () => {
test("pulls the real error line out of ffmpeg's banner", () => {
const lines = [
"[stderr] ffmpeg version 7.1.3 ...",
"[stderr] built with gcc 14",
"[stderr] Stream #0:2(eng): Subtitle: dvd_subtitle (dvdsub), 1280x720",
"[stderr] Stream mapping:",
"[stderr] Stream #0:2 -> #0:0 (copy)",
"[stderr] [srt @ 0x55] Unsupported subtitles codec: dvd_subtitle",
"[stderr] [out#0/srt @ 0x55] Could not write header (incorrect codec parameters ?): Invalid argument",
"[stderr] Conversion failed!",
];
const summary = extractErrorSummary(lines, new Error("FFmpeg exited with code 234"));
expect(summary).toContain("Unsupported subtitles codec: dvd_subtitle");
expect(summary).toContain("Invalid argument");
expect(summary).toContain("Conversion failed!");
// Should NOT include the banner lines.
expect(summary).not.toContain("ffmpeg version");
expect(summary).not.toContain("Stream #0:2");
});
test("dedupes identical fatal lines (e.g. repeated warnings)", () => {
const lines = ["[stderr] Conversion failed!", "[stderr] Conversion failed!", "[stderr] Conversion failed!"];
const summary = extractErrorSummary(lines);
expect(summary?.split("\n").length).toBe(1);
});
test("falls back to the thrown error when no fatal line is found", () => {
const lines = ["[stderr] ffmpeg version 7", "[stderr] Duration: 00:10:00"];
const summary = extractErrorSummary(lines, new Error("FFmpeg exited with code 1"));
expect(summary).toBe("Error: FFmpeg exited with code 1");
});
test("returns null when neither a fatal line nor a thrown error is available", () => {
expect(extractErrorSummary([])).toBe(null);
expect(extractErrorSummary(["[stderr] ffmpeg version 7"])).toBe(null);
});
test("only scans the tail — a banner from a prior run doesn't leak through", () => {
// 70 filler lines, real error at the very end; scan window is 60.
const filler = Array.from({ length: 70 }, (_, i) => `[stderr] banner line ${i}`);
const lines = [...filler, "[stderr] Error: no space left on device"];
const summary = extractErrorSummary(lines);
expect(summary).toBe("Error: no space left on device");
});
});

View File

@@ -562,17 +562,55 @@ async function runJob(job: Job): Promise<void> {
} catch (err) {
logError(`Job ${job.id} failed:`, err);
const fullOutput = `${outputLines.join("\n")}\n${String(err)}`;
const summary = extractErrorSummary(outputLines, err);
// Prepend the scraped summary so the job log starts with what broke.
// ffmpeg's 200-line stream+config banner buries the real error; this
// gives the UI a crisp hook for the failure cause.
const annotatedOutput = summary ? `${summary}\n\n---\n\n${fullOutput}` : fullOutput;
db
.prepare("UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?")
.run(fullOutput, job.id);
emitJobUpdate(job.id, "error", fullOutput);
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
.run(annotatedOutput, job.id);
emitJobUpdate(job.id, "error", annotatedOutput);
db
.prepare("UPDATE review_plans SET status = 'error', notes = ? WHERE item_id = ?")
.run(summary ?? String(err), job.item_id);
} finally {
runningProc = null;
runningJobId = null;
}
}
/**
* Extract a short, human-readable reason from a failed job's stderr.
*
* ffmpeg prints a ~200-line banner (version, config, every stream in the
* input file) before the real error shows up. We scan the tail of the
* output for the last line matching fatal keywords, plus anything ffmpeg
* explicitly labels "Error:" or "Conversion failed!". Returns up to three
* lines so the UI can show a crisp summary without users scrolling the
* full log.
*/
export function extractErrorSummary(outputLines: string[], thrown?: unknown): string | null {
const FATAL =
/(Error:|Conversion failed!|Unsupported\b|Invalid argument|Permission denied|No such file|Cannot allocate|No space left|Killed|Segmentation fault)/;
// Only scan the last 60 lines — anything earlier is the banner or stream
// mapping. The real cause sits near the end.
const tail = outputLines.slice(-60).filter((l) => l.trim());
const hits: string[] = [];
for (const line of tail) {
if (FATAL.test(line)) hits.push(line.replace(/^\[stderr]\s*/, ""));
}
const unique = [...new Set(hits)].slice(-3);
if (unique.length === 0) {
// Fell off the end with no recognisable fatal line — fall back to the
// thrown error (usually "FFmpeg exited with code N"). Better than
// showing nothing, since the exit code at least tells someone *where*
// to look.
return thrown ? String(thrown) : null;
}
return unique.join("\n");
}
// Scheduler endpoints live on /api/settings/schedule now — see server/api/settings.ts.
// ─── FFmpeg progress parsing ───────────────────────────────────────────────────