import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, readJson, resetData, resolvePath, } from "./helpers"; import { baseUrl } from "./baseUrl"; import type { DownloadOutcomeRecord, } from "../../common/lib/downloadOutcome"; const CHANNEL = "test-audio-check"; const CHANNEL_ROOT = `test-transcripts/channels/${CHANNEL}`; // urlIdYouTube on "https://odysee.com/abc123" returns "abc123". const VIDEO_ID = "abc123"; type FakeSidecar = { mode: "happy" | "corrupt-after" | "corrupt-final" | "corrupt-from-start"; recoverOnResume?: boolean; // Serve corruption on the first N audio-check invocations, then behave. // Generalizes recoverOnResume (= corruptRuns:1) to drive several successive // rollbacks (e.g. to watch the adaptive probe interval shrink). corruptRuns?: number; deterministicCorrupt?: boolean; totalChunks?: number; chunkDelayMs?: number; corruptAfterChunk?: number; partExtension?: string; // Embed the slow-probe marker in chunk 0 (so fake-ffmpeg's probe sleeps) and // log each chunk's write time to fake-ytdlp.chunks. Used by the pause test. slowProbe?: boolean; }; async function writeFakeConfig(sidecar: FakeSidecar) { await writeFile( resolvePath(`${CHANNEL_ROOT}/.fake-ytdlp-audio-check.json`), JSON.stringify(sidecar, null, 2), ); } // Patch the channel's audioCheck.resumeDuringProbe after resetData restored the // fixture default (which omits it → paused-during-probe). Re-invalidates the // editor's config cache so the next download job reads the new value. async function setResumeDuringProbe(value: boolean) { const p = resolvePath(`${CHANNEL_ROOT}/config.json`); const cfg = JSON.parse(await readFile(p, "utf8")); cfg.audioCheck = { ...(cfg.audioCheck ?? {}), resumeDuringProbe: value }; await writeFile(p, JSON.stringify(cfg, null, 2)); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); } // Largest gap (ms) between consecutive chunk writes recorded by the slowProbe // fake. A gap near the ~600ms probe means yt-dlp was frozen during the probe; // a gap near the chunk cadence means it kept downloading through it. async function maxChunkGapMs(): Promise { const raw = await readFile( resolvePath(`${CHANNEL_ROOT}/fake-ytdlp.chunks`), "utf8", ); const times = raw .split("\n") .map((l) => l.trim()) .filter(Boolean) .map((l) => Number.parseInt(l.split(/\s+/)[1], 10)) .filter((n) => Number.isFinite(n)); let max = 0; for (let i = 1; i < times.length; i++) { max = Math.max(max, times[i] - times[i - 1]); } return max; } async function triggerDownload(page: import("@playwright/test").Page) { await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); } async function readOutcome(): Promise { return readJson( `${CHANNEL_ROOT}/data/${VIDEO_ID}/download-outcome.json`, ); } async function waitForOutcome( predicate: (o: DownloadOutcomeRecord) => boolean, message: string, ): Promise { // The orchestrator writes the outcome sidecar after a few additional // steps (final ffmpeg probe + transcode + archive append) past the last // "audio-check download complete" log line. Poll until both the file // exists and matches the predicate. let last: DownloadOutcomeRecord | null = null; await expect.poll( async () => { try { last = await readOutcome(); return predicate(last); } catch { return false; } }, { timeout: 30_000, message, }, ).toBe(true); return last!; } test.describe("audio-checked download scenarios", () => { test("happy path: two clean checkpoints, finalize succeeds", async ({ page }) => { await resetData("audio-check-channel"); await writeFakeConfig({ mode: "happy", totalChunks: 8, chunkDelayMs: 150 }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("audio-check download complete", { timeout: 30_000, }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "happy path outcome", ); expect(outcome.attempts).toHaveLength(1); expect(outcome.attempts[0].kind).toBe("audio-checked-primary"); expect(outcome.attempts[0].audioCheck?.rollbacks).toBe(0); expect(outcome.attempts[0].audioCheck?.restarts).toBe(0); // Extracted final audio exists; intermediates removed. expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe(true); expect( await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp4.part`), ).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("download already in target format skips transcode", async ({ page }) => { // Regression: bestaudio sometimes serves a stream already in the target // format (Odysee mp3). The orchestrator must skip the transcode step // instead of calling ffmpeg with src === out (which previously threw // "Source and target are the same file" and rolled the download back). await resetData("audio-check-channel"); await writeFakeConfig({ mode: "happy", partExtension: "mp3", totalChunks: 6, chunkDelayMs: 150, }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("skipping transcode", { timeout: 30_000 }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "same-format outcome", ); expect(outcome.attempts[0].audioCheck?.rollbacks).toBe(0); expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe(true); expect( await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3.part.good`), ).toBe(false); expect( await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3.part.testing`), ).toBe(false); }); test("mid-stream corruption rolls back to last good snapshot and recovers", async ({ page, }) => { await resetData("audio-check-channel"); await writeFakeConfig({ mode: "corrupt-after", corruptAfterChunk: 3, recoverOnResume: true, totalChunks: 8, chunkDelayMs: 150, }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("Rolling back to .good", { timeout: 30_000, }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "mid-rollback outcome", ); expect(outcome.attempts[0].audioCheck?.rollbacks).toBeGreaterThanOrEqual(1); expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe(true); }); test("adaptive interval: repeated corruption halves the probe cadence, then it recovers", async ({ page, }) => { // The source serves corruption on its first two download attempts (each // caught mid-stream and rolled back to the .good baseline), then behaves. // With the test server's adaptive-cadence env (300ms interval, 50ms floor, // ×0.5 backoff, +100ms recovery after 2 clean probes), each malformed // checkpoint halves the live interval (300→150→75), and the ensuing clean // run steps it back up — proving the AIMD backoff end-to-end, not just in // the unit test (common/ytdlp/audioCheckCadence.test.ts). await resetData("audio-check-channel"); await writeFakeConfig({ mode: "corrupt-after", corruptAfterChunk: 3, corruptRuns: 2, totalChunks: 10, chunkDelayMs: 150, }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); // Two successive malformed checkpoints, each halving the live interval. The // reduced cadence persists across the yt-dlp relaunch between them. await expect(log).toContainText( "Audio-check interval backoff: 300ms -> 150ms", { timeout: 30_000 }, ); await expect(log).toContainText( "Audio-check interval backoff: 150ms -> 75ms", { timeout: 30_000 }, ); // Once the source stabilises, a run of clean checkpoints relaxes it back up. await expect(log).toContainText("Audio-check interval recovering:", { timeout: 30_000, }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "adaptive-interval recovery outcome", ); // Two corruption episodes → at least two rollback/restart decisions; the // download then finalizes cleanly. expect( (outcome.attempts[0].audioCheck?.rollbacks ?? 0) + (outcome.attempts[0].audioCheck?.restarts ?? 0), ).toBeGreaterThanOrEqual(2); expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe(true); }); test("final-extraction probe failure triggers rollback and retry", async ({ page }) => { await resetData("audio-check-channel"); await writeFakeConfig({ mode: "corrupt-final", recoverOnResume: true, totalChunks: 6, chunkDelayMs: 100, }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("Final probe verdict: malformed", { timeout: 30_000, }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "final-malformed outcome", ); expect(outcome.attempts[0].audioCheck?.rollbacks).toBeGreaterThanOrEqual(1); 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: 8, chunkDelayMs: 100, }); await triggerDownload(page); const outcome = await waitForOutcome( (o) => o.status === "failed-corrupt-source", "failed-corrupt-source outcome", ); // 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 ({ page, }) => { await resetData("audio-check-channel"); await writeFakeConfig({ mode: "corrupt-from-start", recoverOnResume: true, totalChunks: 4, chunkDelayMs: 100, }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); // Either the watcher catches mid-stream and logs "restarting from 0", // or the final probe catches it; with deterministicCorrupt=false the // resumed run is clean either way. await expect(log).toContainText(/restarting from 0|Final probe verdict: malformed/, { timeout: 30_000, }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "no-baseline restart outcome", ); expect( (outcome.attempts[0].audioCheck?.restarts ?? 0) + (outcome.attempts[0].audioCheck?.rollbacks ?? 0), ).toBeGreaterThanOrEqual(1); expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe(true); }); test("pre-check rolls back to .good when an existing .part is malformed", async ({ page, }) => { await resetData("audio-check-channel"); // Seed a malformed .part (contains the fake-ffmpeg CORRUPT_MARKER) and // a clean .good baseline so the pre-check has something to fall back to. const dir = resolvePath(`${CHANNEL_ROOT}/data/${VIDEO_ID}`); await mkdir(dir, { recursive: true }); await writeFile( `${dir}/audio.mp4.part`, "__CORRUPT__ leftover bytes from a prior interrupted attempt", ); await writeFile( `${dir}/audio.mp4.part.good`, "clean validated bytes from a prior checkpoint", ); await writeFakeConfig({ mode: "happy", recoverOnResume: true, totalChunks: 4, chunkDelayMs: 100, }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("rolled back to .good", { timeout: 30_000, }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "pre-check rollback outcome", ); // The pre-check appends a rollback checkpoint before yt-dlp launches. expect(outcome.attempts[0].audioCheck?.checkpoints ?? 0) .toBeGreaterThanOrEqual(1); expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)) .toBe(true); }); test("ignores stale .parts and final outputs from other video subdirs", async ({ page, }) => { // Regression for the cornbreadman bug: with many in-progress .part // files lingering under data//, discoverPartFile used to scan // the whole tree and latch onto the first match — often a stale .part // from a prior interrupted download of a *different* video. The // success-path fallback then resolved an unrelated subdir's // audio.mp3 as "the final file", skipped transcode, and let the // actual download finalize as audio.mp4 with no integrity probe. // // After the fix, the orchestrator scopes its discovery to the hinted // subdir (extractVideoId returns null for "https://odysee.com/abc123", // so this test exercises the new-subdir fallback) and ignores subdirs // that already existed at launch time. await resetData("audio-check-channel"); const dataRoot = resolvePath(`${CHANNEL_ROOT}/data`); // Both seeds sort before "abc123" (digits < letters), so the buggy // first-match scan would prefer them. await mkdir(`${dataRoot}/0aaaaaaa-stale-part`, { recursive: true }); await writeFile( `${dataRoot}/0aaaaaaa-stale-part/audio.mp4.part`, "clean bytes from a prior interrupted download of a different video", ); await mkdir(`${dataRoot}/0zzzzzzz-already-done`, { recursive: true }); await writeFile( `${dataRoot}/0zzzzzzz-already-done/audio.mp3`, "another video's already-finalized audio output", ); await writeFakeConfig({ mode: "happy", totalChunks: 6, chunkDelayMs: 100 }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("audio-check download complete", { timeout: 30_000, }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "scoped-discovery outcome", ); expect(outcome.attempts[0].audioCheck?.rollbacks).toBe(0); // The real download was probed and transcoded under its own subdir. expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe( true, ); // The stale neighbours were left untouched — no checkpoint snapshots, // no rollbacks, no accidental probes of the wrong final. expect( await pathExists(`${CHANNEL_ROOT}/data/0aaaaaaa-stale-part/audio.mp4.part`), ).toBe(true); expect( await pathExists(`${CHANNEL_ROOT}/data/0zzzzzzz-already-done/audio.mp3`), ).toBe(true); }); test("pre-check discards a malformed .part with no .good baseline", async ({ page, }) => { await resetData("audio-check-channel"); const dir = resolvePath(`${CHANNEL_ROOT}/data/${VIDEO_ID}`); await mkdir(dir, { recursive: true }); await writeFile( `${dir}/audio.mp4.part`, "__CORRUPT__ leftover bytes with no clean baseline to roll back to", ); await writeFakeConfig({ mode: "happy", recoverOnResume: true, totalChunks: 4, chunkDelayMs: 100, }); await triggerDownload(page); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("discarded", { timeout: 30_000 }); const outcome = await waitForOutcome( (o) => o.status === "ok-audio-checked", "pre-check discard outcome", ); expect(outcome.attempts[0].audioCheck?.checkpoints ?? 0) .toBeGreaterThanOrEqual(1); expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)) .toBe(true); }); test("default mode pauses the download while the integrity probe runs", async ({ page, }) => { // Fast chunk cadence + a deliberately slow (~600ms) probe. In the default // (paused) mode yt-dlp is held SIGSTOPped across the probe, so a large gap // opens between two chunk writes — proving it downloads zero bytes that a // malformed verdict could discard and force a re-fetch (the 429 win). await resetData("audio-check-channel"); await writeFakeConfig({ mode: "happy", slowProbe: true, totalChunks: 24, chunkDelayMs: 30, }); await triggerDownload(page); await waitForOutcome( (o) => o.status === "ok-audio-checked", "paused-mode outcome", ); const gap = await maxChunkGapMs(); expect(gap).toBeGreaterThan(300); expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe( true, ); }); test("integrity probe surfaces a 'probing' phase on /api/jobs/active and in the log", async ({ page, request, }) => { // Slow (~600ms) probe + fast chunks: while yt-dlp is paused for the probe // the download task should report phase "probing" in the active-jobs // payload (the data behind the violet scanning bar), and the orchestrator // should emit DLOM_PROBE markers into the job log. await resetData("audio-check-channel"); await writeFakeConfig({ mode: "happy", slowProbe: true, totalChunks: 24, chunkDelayMs: 30, }); await triggerDownload(page); await expect .poll( async () => { const res = await request.get(`${baseUrl}/api/jobs/active`); if (!res.ok()) return false; const body = await res.json(); for (const job of body.jobs ?? []) { for (const t of job.tasks ?? []) { if (t.phase === "probing") return true; } } return false; }, { timeout: 20_000, intervals: [50] }, ) .toBe(true); await waitForOutcome( (o) => o.status === "ok-audio-checked", "probing-phase outcome", ); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("DLOM_PROBE status=start"); }); test("resumeDuringProbe keeps the download flowing through the probe (legacy)", async ({ page, }) => { // Same slow probe, but legacy resume-during-probe is opted back in. yt-dlp // keeps downloading while the probe runs, so the largest inter-chunk gap // stays near the 30ms cadence — nowhere near the ~600ms probe. Also covers // that the legacy path still finalizes correctly. await resetData("audio-check-channel"); await setResumeDuringProbe(true); await writeFakeConfig({ mode: "happy", slowProbe: true, totalChunks: 24, chunkDelayMs: 30, }); await triggerDownload(page); await waitForOutcome( (o) => o.status === "ok-audio-checked", "resume-mode outcome", ); const gap = await maxChunkGapMs(); expect(gap).toBeLessThan(250); expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe( true, ); }); });