// Download-time duration guard + selectable / per-source download format. // // The guard compares a freshly-downloaded audio file's actual duration (via // ffprobe) to the video's metadata duration; a large shortfall is recorded as // "failed-short-audio" BEFORE transcription, the stub is kept on disk, and the // video is surfaced (channel filter, per-video banner, actionable section). // The format selector is platform-aware: Odysee "auto" downloads `original`. // // The fake yt-dlp embeds a `__DUR=120__` marker in audio for ids containing // `truncaud` (fake-ffprobe reports 120s); ids with `longvideo` get a 6000s // metadata duration; ids with `odyseevid` advertise the lbry extractor. The // fake logs the resolved `-f` selector to fake-ytdlp.invocations. import { mkdir, writeFile, readFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { channelStage, channelVideos, generateReport, pathExists, readJson, resetData, resolvePath, } from "./helpers"; type DownloadOutcome = { status: string; shortAudio?: { audioDurationSec: number; expectedDurationSec: number; coverage: number; }; }; async function invalidate() { await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); } // Create a transcribe-handling channel (no audio-check → the -x extraction // path) with a playlist of the given video ids. async function makeChannel( slug: string, ids: string[], config: Record = {}, ): Promise { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(`${root}/data`, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "transcribe", name: slug, platform: "youtube", url: `https://www.youtube.com/@${slug}`, audioFormat: "mp3", ...config, }), ); await writeFile( `${root}/playlist`, ids.map((id) => `https://www.youtube.com/watch?v=${id}\n`).join(""), ); await invalidate(); } async function downloadAll(page: import("@playwright/test").Page, slug: string) { await generateReport(page, slug); await page.goto(channelStage(slug, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("download complete", { timeout: 30_000 }); } async function outcome(slug: string, id: string): Promise { return readJson( `test-transcripts/channels/${slug}/data/${id}/download-outcome.json`, ); } async function waitForOutcome( slug: string, id: string, ): Promise { let last: DownloadOutcome | null = null; await expect .poll( async () => { try { last = await outcome(slug, id); return last.status; } catch { return null; } }, { timeout: 30_000 }, ) .not.toBe(null); return last!; } test("duration guard flags a source-truncated download and keeps the stub", async ({ page, }) => { const SLUG = "guard-trips"; // truncaud + longvideo: 6000s metadata, 120s audio → 2% → short. const TRUNC = "truncaudlongvideo1"; // longvideo only: 6000s metadata, full audio → passes the guard. const FULL = "fulllongvideo1"; await resetData(); await makeChannel(SLUG, [TRUNC, FULL]); await downloadAll(page, SLUG); // Truncated download: recorded failed-short-audio, audio KEPT, NOT transcribed. const truncOutcome = await waitForOutcome(SLUG, TRUNC); expect(truncOutcome.status).toBe("failed-short-audio"); expect(truncOutcome.shortAudio?.expectedDurationSec).toBe(6000); expect(truncOutcome.shortAudio?.audioDurationSec).toBe(120); expect( await pathExists(`test-transcripts/channels/${SLUG}/data/${TRUNC}/audio.mp3`), ).toBe(true); expect( await pathExists( `test-transcripts/channels/${SLUG}/data/${TRUNC}/transcript.json`, ), ).toBe(false); // The full-length download passed the guard (not flagged). const fullOutcome = await waitForOutcome(SLUG, FULL); expect(fullOutcome.status).not.toBe("failed-short-audio"); // Channel list: the short_audio chip filters to exactly the truncated video. const list = page.getByLabel("videos", { exact: true }); await expect .poll( async () => { await page.goto(channelVideos(SLUG, { filter: "short_audio" })); return list.getByLabel(`open ${TRUNC}`).count(); }, { timeout: 15_000 }, ) .toBe(1); await expect(list.getByLabel(`open ${FULL}`)).toBeHidden(); // The kept stub is excluded from auto-transcribe (downloadedNoTranscript). await page.goto(channelVideos(SLUG, { filter: "downloaded_no_transcript" })); await expect(list.getByLabel(`open ${TRUNC}`)).toBeHidden(); // Video page: the short-audio banner offers a re-download as Original. await page.goto(`/channels/${SLUG}/videos/${TRUNC}`); const banner = page.getByLabel("short audio"); await expect(banner).toBeVisible(); await expect( banner.getByRole("button", { name: /Re-download as Original/ }), ).toBeVisible(); // The download page: the channel is listed under truncated downloads. await page.goto(`/operations/download`); const section = page.getByRole("region", { name: "short-audio", exact: true }); await expect(section).toBeVisible(); await expect(section.getByLabel(`short-audio row ${SLUG}`)).toBeVisible(); }); // The re-download offered by the short-audio banner is what CLEARS the flag: a // full download rewrites download-outcome.json, and the banner's condition is // gone by the time the run ends. StreamActionLog fires that refresh itself, so a // parent that stops rendering the banner on it destroys the run's log, the //
 included. The warning text disappearing is the proof the
// refresh landed; the log is read only after that. See plans/FACTS.md, "A run
// log lives in the panel's React state".
test("the short-audio re-download's log survives the refresh that clears the banner", async ({
  page,
}) => {
  // See saved-videos.spec.ts: sequential waits need their own budget.
  test.setTimeout(120_000);
  const SLUG = "shortaudio-log";
  // No `truncaud` sentinel: the re-download lands full-length audio, so the
  // duration guard passes and the outcome is rewritten as a success.
  const ID = "shortaudiofix1";
  await resetData();
  await makeChannel(SLUG, [ID]);
  // Seed the flagged state directly (the kept short stub plus the guard's
  // sidecar) rather than re-running a truncated download for it.
  const dir = resolvePath(`test-transcripts/channels/${SLUG}/data/${ID}`);
  await mkdir(dir, { recursive: true });
  await writeFile(`${dir}/audio.mp3`, "fake truncated audio __DUR=120__\n");
  await writeFile(
    `${dir}/download-outcome.json`,
    JSON.stringify({
      videoId: ID,
      status: "failed-short-audio",
      startedAt: "2026-06-01T00:00:00.000Z",
      finishedAt: "2026-06-01T00:00:10.000Z",
      attempts: [],
      shortAudio: {
        audioDurationSec: 120,
        expectedDurationSec: 6000,
        coverage: 0.02,
      },
    }),
  );
  await invalidate();

  await page.goto(`/channels/${SLUG}/videos/${ID}`);
  const banner = page.getByLabel("short audio");
  await expect(banner).toContainText("Download was truncated at the source");
  const run = banner.getByRole("button", { name: /Re-download as Original/ });
  await expect(run).toBeEnabled();
  await run.click();

  const log = page.getByLabel(`Re-download ${ID} as Original output`);
  await expect(log).toContainText(`[download] Fetching ${ID}`, {
    timeout: 15_000,
  });

  // The state the run produces: a successful download outcome, so the truncated
  // warning is gone.
  await expect(
    page.getByText("Download was truncated at the source"),
  ).toHaveCount(0, { timeout: 15_000 });
  // And the log is STILL on screen, carrying the run's closing line.
  await expect(log).toContainText("download complete");
  // Kept for its log, not for a second run: the download that cleared the flag
  // is the one whose output this is.
  await expect(
    page.getByRole("button", { name: "Re-download as Original" }),
  ).toBeDisabled();
});

test("download format: Odysee auto picks original; YouTube uses bestaudio; channel override forces original", async ({
  page,
}) => {
  const SLUG = "fmt-auto";
  // odyseevid → lbry extractor → auto resolves "odysee" → original.
  const ODY = "odyseevidlongvideo2";
  // plain youtube → bestaudio/worst.
  const YT = "ytlongvideo2";
  await resetData();
  await makeChannel(SLUG, [ODY, YT]);
  await downloadAll(page, SLUG);
  await waitForOutcome(SLUG, ODY);
  await waitForOutcome(SLUG, YT);

  const invocations = await readFile(
    resolvePath(`test-transcripts/channels/${SLUG}/fake-ytdlp.invocations`),
    "utf8",
  );
  expect(invocations).toContain("format:original/bestaudio/worst");
  expect(invocations).toContain("format:bestaudio/worst");

  // A per-channel downloadFormat override forces original even for YouTube.
  const SLUG2 = "fmt-override";
  const YT2 = "ytlongvideo3";
  await makeChannel(SLUG2, [YT2], { downloadFormat: "original" });
  await downloadAll(page, SLUG2);
  await waitForOutcome(SLUG2, YT2);
  const inv2 = await readFile(
    resolvePath(`test-transcripts/channels/${SLUG2}/fake-ytdlp.invocations`),
    "utf8",
  );
  expect(inv2).toContain("format:original/bestaudio/worst");
});