commit b536c4512042b2efe4fa37da9f13f072bbd3cd26
parent 6f589af28d110b17ec1682df3e276dfdfcad925c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 20 May 2026 10:29:09 -0400
Fix false-negative audio integrity checks
Diffstat:
3 files changed, 56 insertions(+), 18 deletions(-)
diff --git a/common/ytdlp/ffmpegStreamClassify.ts b/common/ytdlp/ffmpegStreamClassify.ts
@@ -3,10 +3,12 @@
// detect mid-stream corruption that would render the final transcode useless.
//
// The probe runs a real transcode (e.g. libmp3lame) to /dev/null. ffmpeg's
-// exit code is the authoritative signal: it tolerates truncated containers
-// (returns 0 with a "partial file" stderr line) but exits non-zero when a
-// codec hits genuinely malformed data — e.g. AAC's "Sample rate index in
-// program config element does not match".
+// exit code is one signal, but it is unreliable on its own: a stream can
+// fail to decode on hundreds of packets and ffmpeg still exits 0 because
+// the encoder kept producing output from the packets that did decode. So
+// we also count per-packet decoder failures in stderr — above a small
+// threshold, the stream is malformed regardless of exit code or whether
+// the container is also truncated.
//
// Pure-function module: no side effects, no IO. Keeps test specs from having
// to pull execa/etc. into the test process.
@@ -18,14 +20,24 @@ export type StreamVerdict = "clean" | "partial" | "malformed";
// valid output. Presence of this line means the file is partial-but-clean.
export const PARTIAL_STDERR_PATTERN = /partial file/i;
-// Exit-code-driven classification. The transcode probe is the authoritative
-// signal; stderr is consulted only to distinguish a clean tail (no errors)
-// from a partial-but-OK tail (ffmpeg reported "partial file" but encoded
-// the available audio fine).
+// Per-packet decoder failure ffmpeg prints (at -v error) when the av_codec
+// layer rejects a packet as undecodable. A genuinely truncated container
+// produces at most one or two of these at the tail; mid-stream corruption
+// produces hundreds.
+export const DECODER_ERROR_PATTERN =
+ /Error submitting packet to decoder: Invalid data found when processing input/g;
+
+// Above this many decoder failures we classify as malformed. Tuned from
+// observed logs: clean/partial probes show 0–2 trailing errors; corrupt
+// streams show 100+.
+export const DECODER_ERROR_THRESHOLD = 5;
+
export function classifyFfmpegProbe(
exitCode: number | null,
stderr: string,
): StreamVerdict {
if (exitCode !== 0) return "malformed";
+ const decoderErrors = stderr.match(DECODER_ERROR_PATTERN)?.length ?? 0;
+ if (decoderErrors > DECODER_ERROR_THRESHOLD) return "malformed";
return PARTIAL_STDERR_PATTERN.test(stderr) ? "partial" : "clean";
}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -17,4 +17,5 @@
- `/channels` rows are now dimmed when a channel is excluded from Sync all, matching the existing treatment for build exclusion.
### Fixed
+- Audio-check probe now flags `.part` files with heavy mid-stream codec corruption as malformed even when ffmpeg exits 0. Previously a stream could rack up hundreds of per-packet AAC decode failures (`Error submitting packet to decoder`) while ffmpeg still exited 0, and the file was promoted to a finished download. The classifier now also treats >5 decoder-error lines in stderr as malformed.
- Editor's `/changelog` page no longer renders unstyled. The `@source` directive in `editor/app/globals.css` pointed at a non-existent path, so Tailwind never scanned the shared `<Changelog>` component for class usage.
diff --git a/editor/e2e/audio-check-classifier.spec.ts b/editor/e2e/audio-check-classifier.spec.ts
@@ -20,16 +20,41 @@ test.describe("classifyFfmpegProbe", () => {
).toBe("partial");
});
- test("exit 0 even with noisy decoder warnings → clean (partial detected only via 'partial file')", () => {
- // ffmpeg often prints decoder warnings for a partial container without
- // any 'partial file' line — exit code is still 0 and the encoder still
- // produces output. We trust the exit code.
- expect(
- classifyFfmpegProbe(
- 0,
- "[h264 @ 0x1] Invalid NAL unit size (17306 > 1576).\n[h264 @ 0x1] missing picture in access unit with size 1586\n",
- ),
- ).toBe("clean");
+ test("exit 0 with many decoder errors and no 'partial file' → malformed", () => {
+ // ffmpeg can exit 0 even when the av_codec layer rejects hundreds of
+ // packets — the encoder keeps producing output from whatever decoded.
+ // A wall of "Error submitting packet to decoder" lines without a
+ // "partial file" demuxer warning is mid-stream corruption, not a
+ // clean truncation.
+ const aacStorm = Array.from(
+ { length: 50 },
+ (_, i) =>
+ `[aac @ 0x1] channel element ${i % 3}.${i % 16} is not allocated\n` +
+ `[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n`,
+ ).join("");
+ expect(classifyFfmpegProbe(0, aacStorm)).toBe("malformed");
+ });
+
+ test("exit 0 with many decoder errors AND 'partial file' → malformed (corruption wins over truncation)", () => {
+ const aacStormPlusPartial =
+ Array.from(
+ { length: 50 },
+ () =>
+ `[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n`,
+ ).join("") +
+ "[in#0/mov,mp4,m4a,3gp,3g2,mj2 @ 0x4] stream 1, offset 0x1626f8a: partial file\n";
+ expect(classifyFfmpegProbe(0, aacStormPlusPartial)).toBe("malformed");
+ });
+
+ test("exit 0 with a small tail of decoder errors AND 'partial file' → partial", () => {
+ // Truncated containers often emit a couple of trailing decoder errors
+ // as the encoder eats the last partial packets. Below threshold, the
+ // file is still classifiable as partial.
+ const tail =
+ "[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n" +
+ "[aist#0:1/aac @ 0x2] [dec:aac @ 0x3] Error submitting packet to decoder: Invalid data found when processing input\n" +
+ "[in#0/mov,mp4,m4a,3gp,3g2,mj2 @ 0x4] stream 1, offset 0x1626f8a: partial file\n";
+ expect(classifyFfmpegProbe(0, tail)).toBe("partial");
});
test("non-zero exit → malformed (regardless of stderr)", () => {