import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, channelVideos, generateReport, pathExists, resetData, resolvePath, } from "./helpers"; // A video the downloader couldn't produce real audio for: the dir exists but the // only audio is an in-progress `.part`, and the audio-check pipeline recorded a // `failed-corrupt-source` outcome. Helper writes that state plus a stale // failed-transcriptions entry (the bug being fixed wrongly left these behind). async function writeCorruptSourceVideo(id: string, alreadyFailed: boolean) { const dir = `test-transcripts/channels/test-transcribe/data/${id}`; await mkdir(resolvePath(dir), { recursive: true }); await writeFile(resolvePath(`${dir}/audio.m4a.part`), "partial-bytes"); await writeFile( resolvePath(`${dir}/download-outcome.json`), JSON.stringify({ videoId: id, status: "failed-corrupt-source", startedAt: "2026-06-01T00:00:00.000Z", finishedAt: "2026-06-01T00:01:00.000Z", attempts: [], }), ); if (alreadyFailed) { await writeFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), `${id}\n`, ); } } test("transcribes every audio file with no transcript", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", { timeout: 30_000 }, ); for (const id of ["vidA", "vidB", "vidC"]) { expect( await pathExists( `test-transcripts/channels/test-transcribe/data/${id}/transcript.json`, ), ).toBe(true); } }); test("Transcribe missing excludes VTT-only videos instead of failing them", async ({ page, }) => { await resetData("youtube-with-playlist"); await generateReport(page, "test-youtube"); await page.goto(channelStage("test-youtube", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); const log = page.getByLabel("Transcribe missing output"); // A video that already has a yt-dlp VTT transcript is not "remaining work", // so it's excluded from the batch up front — never attempted, never failed. await expect(log).toContainText( "Whisper batch: 0 succeeded, 0 failed, 0 skipped, 0 attempted.", { timeout: 30_000 }, ); const failPath = "test-transcripts/channels/test-youtube/failed-transcriptions"; if (await pathExists(failPath)) { const raw = await readFile(resolvePath(failPath), "utf8"); expect(raw).not.toContain("fake00000001"); } expect( await pathExists( "test-transcripts/channels/test-youtube/data/fake00000001/transcript.json", ), ).toBe(false); }); test("Transcribe missing skips corrupt-source videos and auto-prunes them from the failed list", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // vidBad has no real audio (only a .part) and is stale-listed as failed. await writeCorruptSourceVideo("vidBad", true); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The three genuinely-downloaded videos transcribe; vidBad is never attempted. await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", { timeout: 30_000 }, ); // vidBad was a download/source problem, not a transcription failure: it must // be pruned from failed-transcriptions (so a re-download retries it), and it // must not have been transcribed or re-recorded as failed. const raw = await readFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "utf8", ); expect(raw).not.toContain("vidBad"); expect( await pathExists( "test-transcripts/channels/test-transcribe/data/vidBad/transcript.json", ), ).toBe(false); }); test("a corrupt-source video surfaces as corrupt_source, not a failed transcription", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await writeCorruptSourceVideo("vidBad", false); await generateReport(page, "test-transcribe"); // The video row is labeled corrupt_source (download/source problem) rather // than failed… await page.goto(channelVideos("test-transcribe")); await expect(page.getByLabel("status corrupt_source")).toBeVisible({ timeout: 15_000, }); // …and the Download stage calls it out for re-download. await page.goto(channelStage("test-transcribe", "download")); await expect( page.getByText(/corrupt source \(needs re-download\)/), ).toBeVisible(); }); test("verify reports nothing missing once transcribed", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", { timeout: 30_000 }, ); // Verify lives on the Diagnostics stage, one panel over. await page.goto(channelStage("test-transcribe", "diagnostics")); await page.getByRole("button", { name: "Verify" }).click(); const result = page.getByLabel("verify result"); await expect(result).toContainText("Duplicates (0)", { timeout: 10_000 }); await expect(result).toContainText("Missing (0)"); }); test("verify flags missing transcripts before whisper runs", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "diagnostics")); await page.getByRole("button", { name: "Verify" }).click(); await expect(page.getByLabel("verify result")).toContainText("Missing (3)", { timeout: 10_000, }); }); test("Retry failures button is gone; Clear failure list takes its place", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await writeFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "vidA\nvidB\n", ); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "transcribe")); await expect(page.getByRole("button", { name: "Retry failures" })).toHaveCount( 0, ); await expect( page.getByRole("button", { name: /Clear failure list \(2\)/ }), ).toBeVisible(); }); test("clearing the failure list lets the next Transcribe missing retry them", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await writeFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "vidA\nvidB\n", ); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "transcribe")); // Confirm the button is gated behind typing "clear". const clearButton = page.getByRole("button", { name: /Clear failure list \(2\)/, }); await expect(clearButton).toBeDisabled(); await page .getByLabel("confirm clear failed transcriptions") .fill("clear"); await expect(clearButton).toBeEnabled(); await clearButton.click(); await expect( page.getByLabel("Clear failed transcriptions output"), ).toContainText("Cleared 2 entries", { timeout: 15_000 }); expect( await readFile( resolvePath( "test-transcripts/channels/test-transcribe/failed-transcriptions", ), "utf8", ), ).toBe(""); // Run Transcribe missing — previously-failed vidA/vidB should be attempted. await page.reload(); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", { timeout: 30_000 }, ); }); test("Clean audio removes audio files only from transcribed videos", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // vidA gets a transcript so its audio should be cleaned. await writeFile( resolvePath( "test-transcripts/channels/test-transcribe/data/vidA/transcript.json", ), JSON.stringify({ result: { language: "en" }, transcription: [] }), ); await generateReport(page, "test-transcribe"); await page.goto(channelStage("test-transcribe", "cleanup")); await page.getByRole("button", { name: "Clean audio" }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( "Cleaned 1 audio file(s) from 1 of 3 video dir(s).", { timeout: 30_000 }, ); // vidA's audio is gone, transcript stays. expect( await pathExists( "test-transcripts/channels/test-transcribe/data/vidA/audio.m4a", ), ).toBe(false); expect( await pathExists( "test-transcripts/channels/test-transcribe/data/vidA/transcript.json", ), ).toBe(true); // vidB has no transcript, its audio is left alone. expect( await pathExists( "test-transcripts/channels/test-transcribe/data/vidB/audio.m4a", ), ).toBe(true); }); test("whisper panel is shown on youtube channels for hybrid use", async ({ page }) => { await resetData("one-youtube-channel"); await generateReport(page, "test-youtube"); await page.goto(channelStage("test-youtube", "transcribe")); // Channels can carry whisper transcripts alongside auto-subs; the // transcription panel actions should always be reachable. await expect( page.getByRole("button", { name: "Transcribe missing" }), ).toBeVisible(); // Verify is the Diagnostics stage's, and it is reachable on a youtube channel // for the same reason. await page.goto(channelStage("test-youtube", "diagnostics")); await expect(page.getByRole("button", { name: "Verify" })).toBeVisible(); });