skip non-extractable subs (dvdsub/dvbsub/unknown), summarise ffmpeg errors
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
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:
49
server/api/__tests__/execute.test.ts
Normal file
49
server/api/__tests__/execute.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user