import { readFile, rm, writeFile } from "node:fs/promises"; import { test, expect, type Page } from "@playwright/test"; import { buildIndex, channelStage, generateReport, pathExists, readJson, resetData, resolvePath, writeSite, } from "./helpers"; import { baseUrl } from "./baseUrl"; // THE THIRD ANSWER FOR A FILTERED-OUT LIVESTREAM. // // A multi-hour stream whose title says nothing about the subject is rarely // worth its audio, but its live chat is text, it is small, and it is the only // record of what the room said. `downloadFilter.rejectedLivestreams: // "chat-only"` is that answer: the media is never fetched, the chat is, and the // video joins the corpus as a chat track with no captions. // // What every test here is really guarding is one risk. A chat-only directory // makes "metadata, no transcript" a LEGITIMATE shape — and that is exactly what // buildIndex.ts:309-315 and deriveChannelSets read as "this video was fetched". // Get the bucket rules wrong and filtered livestreams silently leave // missingNeverFetched and enter the published site as blank pages. const CHANNEL = "test-filter"; const ROOT = `test-transcripts/channels/${CHANNEL}`; const GUEST = ["guestvid0001", "guestvid0002"]; const PLAIN = ["plainvid0001", "plainvid0002"]; // The fixture's cookie-gated video. Its METADATA SCAN is refused without // cookies, but a download-time prefetch reads it fine — so on this path (no // scan first) the filter decides it like any other non-match. const NEEDS_AUTH = "needsauthvid01"; const TOKEN = "test-worker-token"; const AUTH = { authorization: `Bearer ${TOKEN}` }; // The fixture's FINISHED livestream VOD: `livevid` makes the fake report // live_status "was_live", and its title contains neither "guest" nor "plain", // so the include pattern rejects it and only the livestream rules decide it. const LIVE = "livevid000001"; type Snapshot = { generatedAt: string; totals: { videos: number; transcribed: number; downloaded: number }; buckets: { skippedByTitleFilter?: string[]; chatOnly?: string[]; chatOnlyPending?: string[]; noTranscript?: string[]; downloadedNoTranscript?: string[]; }; undownloadedIds: string[]; }; async function writeFilterConfig(filter: Record | null) { await writeFile( resolvePath(`${ROOT}/config.json`), JSON.stringify( { handling: "youtube", name: "Test Title Filter", url: "https://www.youtube.com/@example/videos", ...(filter ? { downloadFilter: filter } : {}), }, null, 2, ), ); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); } async function refreshReport( page: Page, after: string, until?: (snapshot: Snapshot) => boolean, ): Promise { await page.goto("/channels"); const refresh = page.getByRole("button", { name: `refresh report ${CHANNEL}`, }); await refresh.waitFor({ state: "visible" }); await expect .poll( async () => { await refresh.click({ timeout: 5_000 }).catch(() => {}); for (let i = 0; i < 20; i++) { const cur = await readJson(`${ROOT}/snapshot.json`).catch( () => null, ); if (cur && cur.generatedAt > after && (!until || until(cur))) { return true; } await new Promise((r) => setTimeout(r, 250)); } return false; }, { timeout: 60_000, intervals: [1000] }, ) .toBe(true); return readJson(`${ROOT}/snapshot.json`); } async function download(page: Page): Promise { await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); await expect(page.getByLabel("Download videos output")).toContainText( "Managed download complete", { timeout: 60_000 }, ); } test("a chat-only livestream gets its chat and stays undownloaded", async ({ page, }) => { test.setTimeout(180_000); await resetData("title-filter-channel"); await writeFilterConfig({ include: "guest", rejectedLivestreams: "chat-only" }); await generateReport(page, CHANNEL); await download(page); // The chat, and the metadata that makes it publishable. Nothing else. expect(await pathExists(`${ROOT}/data/${LIVE}/transcript.live_chat.json`)).toBe( true, ); // metadata.info.json HAS to survive: buildIndex stats it per video dir and // skips the directory when it throws, so without it the chat would be fetched // and then published nowhere. expect(await pathExists(`${ROOT}/data/${LIVE}/metadata.info.json`)).toBe(true); // Normalized on the spot, so the video is index-ready when it lands rather // than waiting for the corpus-wide normalize pass. expect(await pathExists(`${ROOT}/data/${LIVE}/live_chat.cues.json`)).toBe(true); // NOT a download, by every definition the app has. expect(await pathExists(`${ROOT}/data/${LIVE}/transcript.en.vtt`)).toBe(false); expect(await pathExists(`${ROOT}/data/${LIVE}/audio.mp3`)).toBe(false); const outcome = await readJson<{ status: string; attempts: unknown[] }>( `${ROOT}/data/${LIVE}/download-outcome.json`, ); expect(outcome.status).toBe("chat-only"); // No archive line: an archive id means "downloaded" to the sync walk and to // verifyTranscripts, and this video is not. const archive = await readFile(resolvePath(`${ROOT}/archive`), "utf8").catch( () => "", ); expect(archive).not.toContain(LIVE); // The ordinary rejections still leave nothing behind, and the matches still // download — the mode changes what happens to LIVESTREAMS and nothing else. for (const id of PLAIN) { expect(await pathExists(`${ROOT}/data/${id}`)).toBe(false); } for (const id of GUEST) { expect(await pathExists(`${ROOT}/data/${id}/transcript.en.vtt`)).toBe(true); } }); test("a chat-only video is in chatOnly, and in no bucket that means work", async ({ page, }) => { test.setTimeout(180_000); await resetData("title-filter-channel"); await writeFilterConfig({ include: "guest", rejectedLivestreams: "chat-only" }); await generateReport(page, CHANNEL); await download(page); const before = await readJson(`${ROOT}/snapshot.json`); const snap = await refreshReport( page, before.generatedAt, (s) => (s.buckets.chatOnly ?? []).length > 0, ); expect(snap.buckets.chatOnly ?? []).toEqual([LIVE]); // NOT "we decided not to have it" — we decided to have its chat. expect(snap.buckets.skippedByTitleFilter ?? []).toEqual( [...PLAIN, NEEDS_AUTH].sort(), ); // Nothing is going to transcribe a video whose audio we chose not to fetch, // so it must be out of both transcription-facing buckets AND the download // queue, or the lanes would fight the filter forever. expect(snap.buckets.noTranscript ?? []).not.toContain(LIVE); expect(snap.buckets.downloadedNoTranscript ?? []).not.toContain(LIVE); expect(snap.undownloadedIds).not.toContain(LIVE); // Its chat is on disk, so there is no outstanding chat work either. expect(snap.buckets.chatOnlyPending ?? []).toEqual([]); // A MEMBER OF THIS CORPUS, not a stub: it has a directory and it is counted. // Exactly three — the two matches plus the chat-only livestream. Every other // rejection took its prefetch directory with it. expect(snap.totals.videos).toBe(GUEST.length + 1); // And it is not a DOWNLOAD: only the two matches are. expect(snap.totals.downloaded).toBe(GUEST.length); }); test("before the chat lands it is chatOnlyPending, which is download-lane work", async ({ page, }) => { test.setTimeout(180_000); await resetData("title-filter-channel"); await writeFilterConfig({ include: "guest", rejectedLivestreams: "chat-only" }); await generateReport(page, CHANNEL); // Seed the scan store directly: the scan is what turns a listed id into a // chat-only verdict without fetching anything, and this is the state the // download lane is meant to find. await writeFile( resolvePath(`${ROOT}/metadata-scan.json`), JSON.stringify({ version: 1, entries: { [LIVE]: { title: `Synthetic ${LIVE}`, description: "", uploadDate: "20240101", liveStatus: "was_live", scannedAt: "2026-01-01T00:00:00.000Z", }, }, errors: {}, lastRun: null, }), ); await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); const before = await readJson(`${ROOT}/snapshot.json`); const snap = await refreshReport( page, before.generatedAt, (s) => (s.buckets.chatOnlyPending ?? []).length > 0, ); expect(snap.buckets.chatOnlyPending ?? []).toEqual([LIVE]); // It is NOT in undownloadedIds — fetching the media is the one thing this // video must not have done to it — and not in skippedByTitleFilter either. expect(snap.undownloadedIds).not.toContain(LIVE); expect(snap.buckets.skippedByTitleFilter ?? []).not.toContain(LIVE); }); // The index half, on the cheapest fixture that has one. The claim: a directory // holding metadata.info.json and transcript.live_chat.json and NO transcript is // admitted, published with its chat track, and carries no cues. const INDEX_CHANNEL = "test-youtube"; const INDEX_DIR = "20240101_test1234567"; const INDEX_DATA = `test-transcripts/channels/${INDEX_CHANNEL}/data/${INDEX_DIR}`; test("the export publishes a chat-only video as a chat track with no captions", async ({ page, }) => { test.setTimeout(180_000); await resetData("one-youtube-channel-with-data"); // Make it chat-only-shaped: the chat, and no transcript at all. await rm(resolvePath(`${INDEX_DATA}/transcript.en.vtt`), { force: true }); // yt-dlp's live_chat shape: one `replayChatItemAction` envelope per line, // carrying the offset and the renderer parseLiveChat reads. await writeFile( resolvePath(`${INDEX_DATA}/transcript.live_chat.json`), JSON.stringify({ replayChatItemAction: { videoOffsetTimeMsec: "11000", actions: [ { addChatItemAction: { item: { liveChatTextMessageRenderer: { authorName: { simpleText: "viewer" }, message: { runs: [{ text: "platypus in chat" }] }, }, }, }, }, ], }, }) + "\n", ); await writeSite("testsite", { channels: [{ slug: INDEX_CHANNEL, groupId: "default" }], }); await buildIndex(page); const subsPage = await readJson< Array<{ id: string; tracks?: Record }> >(`test-transcripts/.export-index/shared/subs/${INDEX_CHANNEL}/page-0000.json`); const detail = subsPage.find((d) => d.id === INDEX_DIR); expect(detail).toBeDefined(); expect(Object.keys(detail?.tracks ?? {})).toEqual(["live_chat"]); expect((detail?.tracks?.live_chat ?? []).length).toBeGreaterThan(0); // And no captions: the transcripts shard carries the video (it has metadata) // with no cues at all. const transcriptsPage = await readJson>( `test-transcripts/.export-index/shared/transcripts/${INDEX_CHANNEL}/page-0000.json`, ); const t = transcriptsPage.find((d) => d.id === INDEX_DIR); expect(t).toBeDefined(); expect(t?.cues?.length ?? 0).toBe(0); }); test("the form round-trips the chat-only tier and the ops API sets it", async ({ page, request, }) => { test.setTimeout(120_000); await resetData("title-filter-channel"); await generateReport(page, CHANNEL); await page.goto(channelStage(CHANNEL, "configure")); await page.locator("summary").filter({ hasText: "Advanced" }).click(); const select = page.getByLabel("filtered-out livestreams"); // Absent on disk reads as "skip", which is what every channel written before // the field did. await expect(select).toHaveValue("skip"); await select.selectOption("chat-only"); await page.getByRole("button", { name: "Save changes" }).click(); await expect .poll( async () => ( await readJson<{ downloadFilter?: { rejectedLivestreams?: string }; }>(`${ROOT}/config.json`) ).downloadFilter?.rejectedLivestreams ?? null, { timeout: 30_000 }, ) .toBe("chat-only"); // Back to the default, and the key is REMOVED rather than stored as "skip": // a stored default is a key on disk that changes nothing. await page.goto(channelStage(CHANNEL, "configure")); await page.locator("summary").filter({ hasText: "Advanced" }).click(); await expect(page.getByLabel("filtered-out livestreams")).toHaveValue( "chat-only", ); await page.getByLabel("filtered-out livestreams").selectOption("skip"); await page.getByRole("button", { name: "Save changes" }).click(); await expect .poll( async () => "rejectedLivestreams" in (( await readJson<{ downloadFilter?: Record }>( `${ROOT}/config.json`, ) ).downloadFilter ?? {}), { timeout: 30_000 }, ) .toBe(false); // The ops API goes through the same parser, so it gets the same validation. const ok = await request.post(`${baseUrl}/api/ops/channel-config`, { headers: AUTH, data: { slug: CHANNEL, patch: { downloadFilterRejectedLivestreams: "chat-only" }, }, }); expect(ok.ok()).toBeTruthy(); expect( ( await readJson<{ downloadFilter?: { rejectedLivestreams?: string } }>( `${ROOT}/config.json`, ) ).downloadFilter?.rejectedLivestreams, ).toBe("chat-only"); const bad = await request.post(`${baseUrl}/api/ops/channel-config`, { headers: AUTH, data: { slug: CHANNEL, patch: { downloadFilterRejectedLivestreams: "maybe" } }, }); expect(bad.status()).toBeGreaterThanOrEqual(400); // Refused, and the stored value is untouched. expect( ( await readJson<{ downloadFilter?: { rejectedLivestreams?: string } }>( `${ROOT}/config.json`, ) ).downloadFilter?.rejectedLivestreams, ).toBe("chat-only"); });