Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/controller/autoRunner.ts | 3+++
Mcommon/controller/channelSnapshot.ts | 25++++++++++++++++++++++++-
Mcommon/lib/downloadOutcome.ts | 7+++++++
Mcommon/ytdlp/audioCheckedDownload.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/ytdlp/downloadOneManaged.ts | 12+++++++++++-
Mcommon/ytdlp/runYtdlp.ts | 5+++++
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/components/VideoListPane.tsx | 10++++++++--
Meditor/app/channels/[slug]/lib/stageStatus.ts | 10++++++++++
Meditor/app/channels/[slug]/lib/videoRows.ts | 9++++++++-
Meditor/app/channels/[slug]/lib/videoRowsServer.ts | 19++++++++++++++++---
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 4++++
Meditor/e2e/audio-check-scenarios.spec.ts | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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 ({