commit 7614b292e5e274558b528443581e94c8addf8033
parent 41bbb51efcaa5917fcaae18bce0a51147417bae8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 29 Jun 2026 23:01:40 -0400
fix(audio-check): keep complete-but-malformed downloads instead of looping re-downloads
A download that finished (yt-dlp exit 0, all bytes) but whose final ffmpeg
integrity probe was malformed used to roll back and re-download the whole file
repeatedly. For a fast download that completes inside one checkpoint interval no
.good baseline exists, so each rollback discarded the entire file and re-fetched
it from scratch until the rollback cap (a 2.46 GB Odysee video looped, leaving no
audio). A cancel landing mid-loop was deferred behind the next full re-download.
Bound the final-probe path: a malformed final probe now triggers exactly one
re-download; if still malformed, keep the downloaded container on disk for
inspection and record a new terminal `corrupt-full-source` status (re-downloading
a complete file can't change a deterministic verdict). Kept separate from
`failed-corrupt-source` (a download that never finished, via the checkpoint cap).
Wire the new classification end-to-end: status union + values, downloadOneManaged
mapping (kept, not a failure → no failureClass/backoff), auto-runner (terminal,
not retried), batch counting (skipped), a new `corruptFullSource` snapshot bucket
(excluded from the no-transcript/cleanup buckets so the kept file survives and
isn't re-transcoded), and UI surfaces (stage summary line, amber row dot +
`corrupt_full_source` status folded into the No-audio filter, video-page badge).
Cancel reliability: a pending abort now wins over an in-flight rollback decision,
and the loop re-checks the abort signal right after the final probe.
Tests: audio-check-scenarios.spec.ts gains a deterministic corrupt-full-source
test (keeps audio.mp4, one retry, no failureClass) and a cancel-during-loop test;
the existing failed-corrupt-source test now drives the cap via checkpoint restarts
(slower download) and counts restarts. 14/14 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
13 files changed, 244 insertions(+), 16 deletions(-)
diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts
@@ -652,6 +652,9 @@ async function launchUnit(args: LaunchArgs): Promise<UnitResult> {
// Cancelled (runner stopped, or dropped while still queued) → not a failure.
if (term.status === "cancelled") return { outcome: "skipped" };
if (unitStatus === "skipped-filtered") return { outcome: "skipped" };
+ // Complete-but-malformed source: terminal and kept on disk. Treat as skipped
+ // (not failed) so it doesn't drive backoff and isn't re-picked for download.
+ if (unitStatus === "corrupt-full-source") return { outcome: "skipped" };
if (unitStatus && unitStatus.startsWith("ok")) return { outcome: "transcribed" };
// failureClass drives the runner's per-platform backoff (rate_limit/network).
return { outcome: "failed", failureClass: unitFailureClass };
diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts
@@ -70,6 +70,13 @@ export type ChannelSnapshot = {
// transcript. Surfaced as a download/source problem — NOT counted as a failed
// transcription. Optional: older snapshots lack it; readers default to [].
corruptSource: string[];
+ // Videos whose download COMPLETED (yt-dlp exit 0, all bytes) but whose final
+ // audio integrity probe stayed malformed even after one re-download
+ // (download-outcome.json "corrupt-full-source"). The downloaded container is
+ // KEPT on disk for inspection; re-downloading is futile, so this is terminal
+ // and informational — NOT counted as a failed transcription and NOT re-queued
+ // for download. Optional: older snapshots lack it; readers default to [].
+ corruptFullSource: string[];
// Videos whose transcript rides on a non-canonical VTT name (e.g. only
// transcript.en-US.vtt, or a foreign-only transcript.<lang>.vtt) instead of
// the standard transcript.en.vtt — surfaced so the user can normalize/switch
@@ -351,6 +358,7 @@ export async function generateChannelSnapshot(
const noMetadata: string[] = [];
const partialDownloads: string[] = [];
const corruptSource: string[] = [];
+ const corruptFullSource: string[] = [];
const nonStandardVtt: string[] = [];
const skippedByFilter: string[] = [];
const incompleteTranscript: string[] = [];
@@ -362,6 +370,16 @@ export async function generateChannelSnapshot(
for (const { id, files, audioSizes, outcome, coverage } of perVideo) {
if (isVideoTranscribed(files)) transcribed++;
if (isVideoDownloaded(files)) downloaded++;
+ // A download that completed but stayed malformed after one re-download. The
+ // raw container (e.g. audio.mp4) is kept on disk for inspection. It IS an
+ // artifact (so it won't be re-queued for download), but it is NOT usable
+ // audio — short-circuit so it doesn't land in untranscoded /
+ // downloadedNoTranscript (which would re-transcode/transcribe corrupt audio)
+ // or the wrong-format cleanup estimate (which would delete the kept file).
+ if (outcome?.status === "corrupt-full-source") {
+ corruptFullSource.push(id);
+ continue;
+ }
if (!files.hasMeta && !excludedById.has(id)) noMetadata.push(id);
// A transcribed video whose cues stop far short of its duration — the audio
// download truncated silently. Threshold lives in transcriptCoverage.
@@ -508,6 +526,7 @@ export async function generateChannelSnapshot(
: undefined;
const corruptSourceSet = new Set(corruptSource);
+ const corruptFullSourceSet = new Set(corruptFullSource);
const snapshot: ChannelSnapshot = {
generatedAt: new Date().toISOString(),
totals: {
@@ -524,12 +543,16 @@ export async function generateChannelSnapshot(
untranscribable: untranscribable.sort(),
noMetadata: noMetadata.sort(),
failedListed: failedListed.filter(
- (id) => !excludedById.has(id) && !corruptSourceSet.has(id),
+ (id) =>
+ !excludedById.has(id) &&
+ !corruptSourceSet.has(id) &&
+ !corruptFullSourceSet.has(id),
),
missingFromArchive: missingFromArchive.sort(),
duplicateDirs: [],
partialDownloads: partialDownloads.sort(),
corruptSource: corruptSource.sort(),
+ corruptFullSource: corruptFullSource.sort(),
nonStandardVtt: nonStandardVtt.sort(),
skippedByFilter: skippedByFilter.sort(),
incompleteTranscript: incompleteTranscript.sort(),
diff --git a/common/lib/downloadOutcome.ts b/common/lib/downloadOutcome.ts
@@ -11,6 +11,12 @@ export type DownloadOutcomeStatus =
| "ok-audio-checked"
| "failed"
| "failed-corrupt-source"
+ // A download that COMPLETED (yt-dlp exit 0, all bytes) but whose final audio
+ // integrity probe is malformed even after one re-download. Terminal and
+ // NOT retried: re-downloading a complete file yields identical bytes. The
+ // downloaded container is KEPT on disk for inspection. Distinct from
+ // failed-corrupt-source, which means the download never finished.
+ | "corrupt-full-source"
// The app-level filter pass (e.g. skip-live) declined to download this video.
// Not a failure and not archived — the next sync/download-missing retries it
// once the filter no longer matches (e.g. a live stream becomes a VOD).
@@ -23,6 +29,7 @@ export const DOWNLOAD_OUTCOME_STATUS_VALUES: ReadonlyArray<DownloadOutcomeStatus
"ok-audio-checked",
"failed",
"failed-corrupt-source",
+ "corrupt-full-source",
"skipped-filtered",
];
diff --git a/common/ytdlp/audioCheckedDownload.ts b/common/ytdlp/audioCheckedDownload.ts
@@ -89,6 +89,12 @@ export type CheckpointRecord = {
export type AudioCheckOutcomeKind =
| "ok"
| "failed-corrupt-source"
+ // A download that COMPLETED (yt-dlp exit 0, all bytes) but whose final
+ // integrity probe is malformed, even after one re-download. Re-downloading
+ // a complete file can't change a deterministic verdict, so we stop, KEEP the
+ // downloaded container on disk for inspection, and flag it as a distinct
+ // terminal state (separate from failed-corrupt-source, which never finished).
+ | "corrupt-full-source"
| "aborted"
| "ytdlp-error";
@@ -561,6 +567,11 @@ export async function runAudioCheckedYtdlp(
let rollbacks = 0;
let restarts = 0;
let consecutiveRollbacks = 0;
+ // Counts malformed verdicts on the FINAL probe (a complete yt-dlp exit-0
+ // download), as opposed to mid-download checkpoint rollbacks. Re-downloading
+ // a complete file yields identical bytes and thus the same verdict, so we
+ // allow exactly one re-download before declaring corrupt-full-source.
+ let finalProbeMalformed = 0;
let lastExit: number | null = null;
let lastStderrTail = "";
let lastArchiveLine: string | null = null;
@@ -640,8 +651,26 @@ export async function runAudioCheckedYtdlp(
});
finalProbeVerdict = probe.verdict;
if (probe.verdict === "malformed") {
+ // A cancel that landed during/just after the (potentially long) final
+ // probe must win — don't discard the file or kick off another full
+ // re-download on the user's way out.
+ if (opts.signal.aborted) return buildOutcome("aborted");
+ finalProbeMalformed++;
+ if (finalProbeMalformed > 1) {
+ // Second malformed final probe on a complete download. Re-downloading
+ // can't change a deterministic verdict, so stop and KEEP the file
+ // (no rename-to-.part, no rm, no transcode) for inspection. Drop only
+ // the audio-check scratch snapshots.
+ opts.onLog(
+ `Final probe verdict: malformed again after re-download. ` +
+ `Keeping the downloaded file and flagging corrupt-full-source: ${finalFile}\n`,
+ );
+ await rm(goodPath(partPathFor(finalFile)), { force: true });
+ await rm(testingPath(partPathFor(finalFile)), { force: true });
+ return buildOutcome("corrupt-full-source");
+ }
opts.onLog(
- `Final probe verdict: malformed. Rolling back to last good snapshot.\n`,
+ `Final probe verdict: malformed. Re-downloading once before giving up.\n`,
);
// Rename back to .part, then restore .good if we have one.
const partAgain = partPathFor(finalFile);
@@ -653,8 +682,11 @@ export async function runAudioCheckedYtdlp(
} else {
await rm(partAgain, { force: true });
}
+ // Counts toward the rollback STAT, but NOT consecutiveRollbacks: the
+ // final-probe retry is governed by finalProbeMalformed (one re-download
+ // then corrupt-full-source), kept independent of the checkpoint-driven
+ // consecutiveRollbacks cap that yields failed-corrupt-source.
rollbacks++;
- consecutiveRollbacks++;
continue;
}
// Clean (or partial — partial at finalize is OK for a complete download
@@ -1088,6 +1120,20 @@ export async function runAudioCheckedYtdlp(
};
}
+ // A cancel that raced a rollback decision must win: the user asked to
+ // stop, so don't report a rollback/restart that would loop the caller into
+ // another launch. (The race above can resolve "exit" because the watcher
+ // SIGTERMs yt-dlp on a malformed verdict, so check the signal directly.)
+ if (opts.signal.aborted) {
+ return {
+ kind: "aborted",
+ exitCode: childExitCode,
+ stderrTail,
+ archiveLine,
+ videoDir: launchVideoDir,
+ };
+ }
+
// yt-dlp has exited. If the watcher decided a rollback/restart, that
// wins — the exit was triggered by our SIGTERM, not by yt-dlp finishing.
if (pendingDecision) {
diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts
@@ -589,6 +589,9 @@ async function runManagedDownload(
let primaryRes: AttemptOutcome;
let audioCheckStats: AudioCheckAttemptStats | undefined;
let audioCheckCorruptSource = false;
+ // Complete download (yt-dlp exit 0) whose final probe stayed malformed after
+ // one re-download. Terminal, kept on disk, NOT a failure (no retry/backoff).
+ let audioCheckCorruptFullSource = false;
// Orchestrator resolves data/<id>/ by scanning the on-disk tree. We use
// this in preference to extractVideoId(url), which doesn't know yt-dlp's
// internal id (e.g. Odysee claim hashes vs URL slugs).
@@ -637,6 +640,7 @@ async function runManagedDownload(
: {}),
};
audioCheckCorruptSource = audioOutcome.kind === "failed-corrupt-source";
+ audioCheckCorruptFullSource = audioOutcome.kind === "corrupt-full-source";
audioCheckVideoDir = audioOutcome.videoDir;
} else {
const primaryArgs = [
@@ -671,11 +675,17 @@ async function runManagedDownload(
if (primaryRes.archiveLine) lastArchiveLine = primaryRes.archiveLine;
let lastSucceeded =
- !audioCheckCorruptSource && attemptSucceeded(primaryRes.exitCode);
+ !audioCheckCorruptSource &&
+ !audioCheckCorruptFullSource &&
+ attemptSucceeded(primaryRes.exitCode);
if (lastSucceeded) {
status = audioCheckEnabled ? "ok-audio-checked" : "ok";
} else if (audioCheckCorruptSource) {
status = "failed-corrupt-source";
+ } else if (audioCheckCorruptFullSource) {
+ // Kept file, terminal: not "ok" (no usable audio), not a failure (the
+ // bytes are on disk; re-downloading is futile). Drops into its own bucket.
+ status = "corrupt-full-source";
}
// ---------- Attempt 2: auth retry ----------
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -640,6 +640,11 @@ async function runManagedDownloads(
}
if (outcome.status === "skipped-filtered") {
skippedCount++;
+ } else if (outcome.status === "corrupt-full-source") {
+ // Completed but stayed malformed after one re-download: terminal and
+ // kept on disk, but not a usable download. Count as skipped (not ok,
+ // not a retryable failure) so the batch summary stays honest.
+ skippedCount++;
} else if (outcome.status === "failed") {
failedCount++;
// downloadOneManaged classifies against the full stderr tail; fall
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A complete-but-corrupt audio download is no longer re-downloaded forever — it's kept and flagged instead.** When an audio-checked download finished (yt-dlp exit 0, all bytes) but its final integrity probe came back `malformed`, the orchestrator rolled the whole file back and re-downloaded it — repeatedly. For a fast download that completes inside one checkpoint interval no `.good` baseline ever exists, so each "rollback" discarded the entire file and re-fetched it from scratch (a 2.46 GB Odysee video looped until the rollback cap, leaving no audio behind), and a cancel landing mid-loop was deferred behind the next full re-download. Now the final-probe path is bounded: a malformed final probe triggers **exactly one** re-download; if it's still malformed, the orchestrator **keeps the downloaded container on disk** (for inspection) and records a new terminal **`corrupt-full-source`** status — re-downloading a complete file can't change a deterministic verdict. This is distinct from `failed-corrupt-source` (a download that never finished, driven by the checkpoint rollback cap). Surfaced as a new **`corruptFullSource`** channel-snapshot bucket → a Download-stage summary line ("corrupt full source (file kept)"), an **amber download-dot** + `corrupt_full_source` row status in the per-channel video list (folded into the *No audio* filter), and a **"Corrupt full source — download completed but audio is malformed (file kept)"** badge on the video page. It's terminal and **not** retried (auto-runner treats it as skipped; the kept file is an artifact, so it isn't re-queued) and **not** counted as a failed transcription or a transcode-pending video. Cancellation is also fixed: a pending abort now wins over an in-flight rollback decision, and the loop re-checks the abort signal after the final probe so Cancel stops it promptly. See `common/ytdlp/audioCheckedDownload.ts`, `common/ytdlp/downloadOneManaged.ts`, `common/lib/downloadOutcome.ts`, `common/controller/{autoRunner.ts,channelSnapshot.ts}`, `common/ytdlp/runYtdlp.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/components/VideoPanel.tsx}`, and `editor/e2e/audio-check-scenarios.spec.ts`.
- **The Deploy page is reworked around a clearer build/deploy lifecycle, with one-click build-then-deploy and batch multi-site builds.** The page now reads top-to-bottom as you'd actually ship: **Release notes** (the `## [Unreleased]` changelog preview + Cut release) → **Build & deploy** → optional **Individual steps** → **Build multiple sites**. A new **Build & deploy** button runs the build and, only if it succeeds (and wasn't cancelled), deploys it — as a single managed job with one combined streamed log and one Cancel (`buildAndDeployAction`, a composite `runManagedFunction`; cancelling mid-build skips the deploy). The new **Build multiple sites** panel kicks off a build (optionally build+deploy) for several sites at once, each rendered as its own live status-chipped log lane (`BuildSitesPanel` + `JobLane`); in Basic mode the jobs serialize on the shared build/deploy queue (the `export/` output tree is shared), with a note that true parallelism arrives with Docker mode. A **Build mode** toggle (Basic | Docker) on the page persists the choice as the default (`settings.buildPipeline`, also editable on Settings); Docker mode is a follow-up and currently falls back to a basic build with an inline notice. The build/deploy commands now share a child-streaming helper (`common/jobs/runChild.ts`) and mode-routing core (`editor/app/deploy/buildDeployCore.ts`). See `editor/app/deploy/{page.tsx,buildAction usage,components/*}`, `editor/app/build/buildAction.ts`, and `common/lib/settings.ts`.
- **Truncated transcripts are now detected and flagged for re-download.** When an audio download silently stops early (yt-dlp exits `ok`, `download-outcome.json` records success), whisper transcribes only the few minutes that landed — so a 2h22m video ends up with a ~7-minute transcript and nothing warns you. A new coverage check (last cue end ÷ video duration) flags any non-livestream video ≥10min whose transcript covers <50% of its runtime. The single source of truth is `common/lib/transcriptCoverage.ts` (`transcriptCoverage` + `isIncompleteTranscript`, with named thresholds), read from each video's `transcript.cues.json` so the existing corpus is flagged with no migration. Surfaced everywhere: a new **`incompleteTranscript`** channel-snapshot bucket → an **"Incomplete transcript"** filter chip and an **amber transcribed-dot** in the per-channel video list; a warning banner on the video page ("Transcript covers 6:52 of 2:22:21 (4.8%)…") with a one-click **Re-download & re-transcribe** button; and an **"Channels with incomplete (truncated) transcripts"** section on `/actionable`. The fix action (`redownloadIncompleteTranscriptAction`) deletes the truncated audio first, then re-downloads and re-transcribes — re-running whisper alone would just reproduce the short transcript. See `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/{components/VideoPanel.tsx,videoActions.ts,page.tsx},page.tsx}`, and `editor/app/actionable/{lib/loadActionable.ts,page.tsx}`.
- **Fix truncated transcripts in bulk — two buttons, in three places.** The per-video fix now has channel-wide and cross-channel counterparts, each offered as a **batch re-fix** (queues one job that removes the truncated audio → re-downloads → re-transcribes every flagged video in place; the transcript is never gapped) **and** a **clear & re-queue** (deletes the truncated audio + transcript so the videos drop back into the normal *undownloaded → needs-transcript* pipeline, then enables + starts the auto-download/auto-transcribe runners so they reprocess automatically). Both appear on the **`/actionable`** "incomplete transcripts" section — per-channel **Re-download & re-transcribe** / **Clear & re-queue** buttons (replacing the old "Review"-only link) plus a section-header **Re-fix all** / **Clear & re-queue all** that acts across every affected channel — and on the **channel page bulk bar** as two new Action options with a new **Select incomplete** quick-select. The clear path needs no archive pruning: `undownloadedIds` is derived purely from on-disk artifacts, and a single-video re-download isn't archive-gated. Destructive clears are confirm-gated everywhere; enabling the runners is disclosed in the confirm (note: the auto-queue policy must cover the channel for auto-reprocessing — cleared videos also surface in the existing "Download missing" / "Transcribe pending" sections as a fallback). New shared helper `editor/app/channels/[slug]/lib/fixIncompleteTranscript.ts` is the single source of truth for the per-video fix/clear, reused by the per-video action, the new `redownload-incomplete-bucket` batch job (bookmarkable; re-derives the live `incompleteTranscript` bucket), the bulk-bar wrappers, and the global actions. See `editor/app/channels/[slug]/{incompleteTranscriptActions.ts,bulkVideoActions.ts,components/VideoListPane.tsx}`, `editor/app/actionable/{actions.ts,page.tsx,components/{InlineActionButton.tsx,FixAllIncompleteButton.tsx}}`, `common/jobs/{jobKinds.ts,jobSpec.ts}`, `editor/app/jobs/jobReplayRegistry.ts`, and `editor/e2e/incomplete-transcript.spec.ts`.
diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx
@@ -640,7 +640,7 @@ export function VideoListPane({
function StatusGlyphs({ row }: { row: VideoRow }) {
const dlColor =
- row.partial || row.corruptSource
+ row.partial || row.corruptSource || row.corruptFullSource
? "bg-amber-500"
: row.downloaded
? "bg-emerald-500"
@@ -666,7 +666,13 @@ function StatusGlyphs({ row }: { row: VideoRow }) {
className="inline-flex items-center gap-1 flex-shrink-0"
>
<span
- title={row.corruptSource ? "corrupt source (needs re-download)" : "downloaded"}
+ title={
+ row.corruptSource
+ ? "corrupt source (needs re-download)"
+ : row.corruptFullSource
+ ? "corrupt full source (download completed but audio is malformed; file kept for inspection)"
+ : "downloaded"
+ }
className={`w-2 h-2 rounded-full ${dlColor}`}
/>
<span title="transcoded" className={`w-2 h-2 rounded-full ${tcColor}`} />
diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts
@@ -25,6 +25,7 @@ export function normalizeBuckets(
duplicateDirs: raw?.duplicateDirs ?? [],
partialDownloads: raw?.partialDownloads ?? [],
corruptSource: raw?.corruptSource ?? [],
+ corruptFullSource: raw?.corruptFullSource ?? [],
nonStandardVtt: raw?.nonStandardVtt ?? [],
skippedByFilter: raw?.skippedByFilter ?? [],
incompleteTranscript: raw?.incompleteTranscript ?? [],
@@ -205,6 +206,15 @@ export function computeStageStatuses(
),
);
}
+ if (buckets.corruptFullSource.length > 0) {
+ downloadParts.push(
+ pluralize(
+ buckets.corruptFullSource.length,
+ "corrupt full source (file kept)",
+ "corrupt full sources (files kept)",
+ ),
+ );
+ }
const download: StageStatus = {
id: "download",
title: "Download",
diff --git a/editor/app/channels/[slug]/lib/videoRows.ts b/editor/app/channels/[slug]/lib/videoRows.ts
@@ -6,6 +6,7 @@ export type VideoRowStatus =
| "partial_download"
| "not_downloaded"
| "corrupt_source"
+ | "corrupt_full_source"
| "failed";
export type VideoRow = {
@@ -20,6 +21,11 @@ export type VideoRow = {
// "failed-corrupt-source"), leaving no real audio. A download/source problem —
// surfaced distinctly so it isn't conflated with a failed transcription.
corruptSource: boolean;
+ // The download COMPLETED but its audio stream stayed malformed after one
+ // re-download (download-outcome "corrupt-full-source"). The raw container is
+ // kept on disk for inspection; re-downloading is futile. Terminal and distinct
+ // from corruptSource (which never finished) and from a failed transcription.
+ corruptFullSource: boolean;
failedTranscription: boolean;
failedTranscoding: boolean;
// Has at least one finalized audio file not in the channel's target format —
@@ -83,7 +89,8 @@ function matchesFilter(r: VideoRow, filter: VideoFilter): boolean {
return (
r.status === "no_audio" ||
r.status === "not_downloaded" ||
- r.status === "corrupt_source"
+ r.status === "corrupt_source" ||
+ r.status === "corrupt_full_source"
);
case "downloaded_no_transcript":
return r.status === "downloaded_no_transcript";
diff --git a/editor/app/channels/[slug]/lib/videoRowsServer.ts b/editor/app/channels/[slug]/lib/videoRowsServer.ts
@@ -42,6 +42,7 @@ export function computeVideoRows(input: ComputeRowsInput): VideoRow[] {
const partial = new Set(buckets.partialDownloads);
const incompleteTranscript = new Set(buckets.incompleteTranscript);
const corruptSourceSet = new Set(buckets.corruptSource);
+ const corruptFullSourceSet = new Set(buckets.corruptFullSource);
const failedTranscription = new Set(input.failedTranscriptionIds);
const failedTranscoding = new Set(input.failedTranscodingIds);
@@ -60,23 +61,34 @@ export function computeVideoRows(input: ComputeRowsInput): VideoRow[] {
const isUntranscribable = untranscribable.has(id);
const isPartial = partial.has(id);
const isCorruptSource = corruptSourceSet.has(id);
+ const isCorruptFullSource = corruptFullSourceSet.has(id);
// A corrupt-source video is a download/source problem, not a failed
// transcription — don't let a stale failed-transcriptions entry (pruned on
- // the next transcribe pass) mark it failed here.
- const isFailedT = failedTranscription.has(id) && !isCorruptSource;
+ // the next transcribe pass) mark it failed here. Same for a kept
+ // corrupt-full-source (it has no transcript and isn't a transcription error).
+ const isFailedT =
+ failedTranscription.has(id) && !isCorruptSource && !isCorruptFullSource;
const isFailedX = failedTranscoding.has(id);
const inNoTranscript = noTranscript.has(id);
const inDownloadedNoTranscript = downloadedNoTranscript.has(id);
const inUntranscoded = untranscoded.has(id);
const downloaded = isOnDisk && !inNoTranscript;
- const transcribed = isOnDisk && !inNoTranscript && !inDownloadedNoTranscript;
+ // A kept corrupt-full-source has a raw container on disk but no usable
+ // transcript and is excluded from the no-transcript buckets, so guard the
+ // "transcribed" fallback explicitly or it would read as done.
+ const transcribed =
+ isOnDisk &&
+ !inNoTranscript &&
+ !inDownloadedNoTranscript &&
+ !isCorruptFullSource;
const transcoded = input.transcodeApplies ? !inUntranscoded : null;
const excluded = input.excludedIds.has(id);
let status: VideoRowStatus;
if (isFailedT || isFailedX) status = "failed";
else if (isCorruptSource) status = "corrupt_source";
+ else if (isCorruptFullSource) status = "corrupt_full_source";
else if (isPartial) status = "partial_download";
else if (isUndownloaded) status = "not_downloaded";
else if (isUntranscribable) status = "untranscribable";
@@ -92,6 +104,7 @@ export function computeVideoRows(input: ComputeRowsInput): VideoRow[] {
untranscribable: isUntranscribable,
partial: isPartial,
corruptSource: isCorruptSource,
+ corruptFullSource: isCorruptFullSource,
failedTranscription: isFailedT,
failedTranscoding: isFailedX,
wrongFormatAudio:
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -1240,6 +1240,10 @@ function DownloadOutcomeBadge({
const cls = lastAttempt?.availabilityClass;
return cls ? `Download failed: ${cls}` : "Download failed";
}
+ case "failed-corrupt-source":
+ return "Corrupt source — download couldn't finish";
+ case "corrupt-full-source":
+ return "Corrupt full source — download completed but audio is malformed (file kept)";
default:
return outcome.status;
}
diff --git a/editor/e2e/audio-check-scenarios.spec.ts b/editor/e2e/audio-check-scenarios.spec.ts
@@ -214,27 +214,120 @@ test.describe("audio-checked download scenarios", () => {
expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe(true);
});
+ test("deterministic final-probe corruption keeps the file and flags corrupt-full-source", async ({
+ page,
+ }) => {
+ // A download that COMPLETES (yt-dlp exit 0, all bytes) but whose final
+ // integrity probe stays malformed. Re-downloading yields identical bytes,
+ // so after exactly ONE retry the orchestrator stops, KEEPS the downloaded
+ // container for inspection, and records the terminal corrupt-full-source
+ // status instead of looping a full re-download until the rollback cap.
+ await resetData("audio-check-channel");
+ await writeFakeConfig({
+ mode: "corrupt-final",
+ deterministicCorrupt: true,
+ totalChunks: 4,
+ chunkDelayMs: 100,
+ });
+ await triggerDownload(page);
+
+ const log = page.getByLabel("Download videos output");
+ await expect(log).toContainText("flagging corrupt-full-source", {
+ timeout: 30_000,
+ });
+
+ const outcome = await waitForOutcome(
+ (o) => o.status === "corrupt-full-source",
+ "corrupt-full-source outcome",
+ );
+ // Exactly one re-download (1st malformed → retry; 2nd malformed → keep).
+ expect(outcome.attempts[0].audioCheck?.rollbacks).toBe(1);
+ expect(outcome.attempts[0].audioCheck?.finalProbeVerdict).toBe("malformed");
+ // Not a failure → no failureClass, and the bytes are kept on disk.
+ expect(outcome.failureClass).toBeUndefined();
+ expect(
+ await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp4`),
+ ).toBe(true);
+ // No usable target-format audio was produced (we don't transcode corrupt
+ // audio), and the scratch snapshots are cleaned up.
+ expect(
+ await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`),
+ ).toBe(false);
+ expect(
+ await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp4.part.good`),
+ ).toBe(false);
+ expect(
+ await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp4.part.testing`),
+ ).toBe(false);
+ });
+
test("deterministic corruption exhausts retry cap and surfaces failed-corrupt-source", async ({
page,
}) => {
await resetData("audio-check-channel");
+ // Slow download (8 × 100ms = 800ms) vs the 300ms test probe interval, so a
+ // mid-stream checkpoint reliably fires and catches the corruption before the
+ // download completes. Each restart bumps consecutiveRollbacks; once it
+ // exceeds maxRollbacks=3 the orchestrator gives up with failed-corrupt-source
+ // (the download never completed cleanly — distinct from corrupt-full-source,
+ // which is a COMPLETE download whose final probe is malformed).
await writeFakeConfig({
mode: "corrupt-from-start",
deterministicCorrupt: true,
- totalChunks: 4,
+ totalChunks: 8,
chunkDelayMs: 100,
});
await triggerDownload(page);
- // The fixture sets maxRollbacks=3, so we expect status=failed-corrupt-source
- // after the cap is exhausted. With these timings most rollbacks come from
- // the final-stage probe (the .part is gone by the time the watcher fires).
const outcome = await waitForOutcome(
(o) => o.status === "failed-corrupt-source",
"failed-corrupt-source outcome",
);
- expect(outcome.attempts[0].audioCheck?.rollbacks ?? 0)
- .toBeGreaterThanOrEqual(1);
+ // Corruption from byte 0 means no .good baseline ever forms, so each
+ // checkpoint RESTARTS (rather than rolling back). The cap is exhausted via
+ // restarts; rollbacks may legitimately be 0.
+ expect(
+ (outcome.attempts[0].audioCheck?.rollbacks ?? 0) +
+ (outcome.attempts[0].audioCheck?.restarts ?? 0),
+ ).toBeGreaterThanOrEqual(1);
+ });
+
+ test("cancelling during the malformed loop stops it instead of looping a re-download", async ({
+ page,
+ }) => {
+ // Regression for "I can't cancel it to stop the rollback": a download whose
+ // final probe stays malformed used to loop full re-downloads, and a cancel
+ // landing mid-rollback was deferred behind the next re-download. slowProbe
+ // makes each integrity probe take ~600ms, widening the window so the cancel
+ // lands during the loop; the orchestrator must abort promptly.
+ await resetData("audio-check-channel");
+ await writeFakeConfig({
+ mode: "corrupt-final",
+ deterministicCorrupt: true,
+ slowProbe: true,
+ totalChunks: 6,
+ chunkDelayMs: 100,
+ });
+ await triggerDownload(page);
+
+ const log = page.getByLabel("Download videos output");
+ // Wait until we're in the malformed loop (first final-probe failure), then
+ // cancel before it can finish the second re-download + probe.
+ await expect(log).toContainText("Final probe verdict: malformed", {
+ timeout: 30_000,
+ });
+ await page
+ .getByRole("button", { name: "Cancel Download videos" })
+ .click();
+
+ // The job stops: the Cancel control goes away and the run button re-enables,
+ // proving the cancel took effect rather than being swallowed by the loop.
+ await expect(
+ page.getByRole("button", { name: "Cancel Download videos" }),
+ ).not.toBeVisible({ timeout: 15_000 });
+ await expect(
+ page.getByRole("button", { name: "Download videos", exact: true }),
+ ).toBeEnabled();
});
test("first-checkpoint malformed with no baseline restarts from byte 0 and recovers", async ({