import { readFile, rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, channelVideos, generateReport, pathExists, resetData, resolvePath, } from "./helpers"; // The channel video list's checkbox bulk bar must queue like every other batch // feature: ONE managed batch job (whisper-bucket / retry-bucket), not one // single-video job per selection. /jobs lists most-recent-first, so the newest // row after a bulk click tells us exactly what was queued. // // The bar is a single Action dropdown + Apply button: pick the action, fill in // its contextual controls (queue, etc.), then click Apply. async function selectAllAndOpenBar(page: import("@playwright/test").Page) { await page.getByLabel("select all visible").check(); await expect(page.getByLabel("bulk action bar")).toBeVisible(); } async function chooseAction( page: import("@playwright/test").Page, label: string, ) { await page.getByLabel("bulk action", { exact: true }).selectOption({ label }); } function applyButton(page: import("@playwright/test").Page) { return page.getByLabel("apply bulk action"); } async function clickApply(page: import("@playwright/test").Page) { await applyButton(page).click(); } // A streaming bulk action clears the selection (hiding the bar) once its server // action returns — i.e. once the batch job is registered. Wait for that before // reading /jobs so a fast-failing job can't lose the race against navigation. async function expectSubmitted(page: import("@playwright/test").Page) { await expect(page.getByLabel("bulk action bar")).toBeHidden(); } test("bulk transcribe submits a single batch job on the transcription queue", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto(channelVideos("test-transcribe")); await selectAllAndOpenBar(page); // Transcribe is the default action, so its queue control is already shown. // Default queue for bulk transcribe matches "Transcribe missing". await expect( page.getByLabel("queue for bulk transcribe", { exact: true }), ).toHaveValue("transcription"); await clickApply(page); await expectSubmitted(page); await page.goto("/jobs"); const newest = page.getByRole("row").nth(1); // row 0 is the header // One batch job, not three transcribe-one jobs. Matched on the machine kind: // the cell renders the label "Transcribe downloaded audio". await expect(newest).toHaveAttribute( "data-kind", "whisper-bucket-downloaded-no-transcript", ); await expect(newest).toContainText("test-transcribe"); await expect(newest).toContainText("transcription"); }); test("bulk transcribe honors a custom queue", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto(channelVideos("test-transcribe")); await selectAllAndOpenBar(page); await page .getByLabel("queue for bulk transcribe", { exact: true }) .selectOption({ label: "Custom…" }); await page .getByLabel("custom queue for bulk transcribe", { exact: true }) .fill("qBulk"); await page .getByRole("button", { name: "save custom queue for bulk transcribe" }) .click(); await clickApply(page); await expectSubmitted(page); await page.goto("/jobs"); const newest = page.getByRole("row").nth(1); await expect(newest).toHaveAttribute( "data-kind", "whisper-bucket-downloaded-no-transcript", ); await expect(newest).toContainText("qBulk"); }); test("bulk retry download submits a single retry-bucket job on the platform queue", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); await page.goto(channelVideos("test-transcribe")); await selectAllAndOpenBar(page); await chooseAction(page, "Retry download"); await clickApply(page); await expectSubmitted(page); await page.goto("/jobs"); const newest = page.getByRole("row").nth(1); // One retry-bucket job, not three download-one-pipeline jobs. The channel URL // is odysee.com, so the default queue is platform:odysee. await expect(newest).toHaveAttribute("data-kind", "retry-bucket"); await expect(newest).toContainText("test-transcribe"); await expect(newest).toContainText("platform:odysee"); }); const dataDir = "test-transcripts/channels/test-transcribe/data"; const failedTranscriptionsFile = "test-transcripts/channels/test-transcribe/failed-transcriptions"; // Delete directories and Clear failed markers are synchronous, summary-style // actions (no queued job) — the point of bulk delete is to clear failed // downloads WITHOUT triggering a transcode. test("Select failed + Delete directories removes the dirs and queues no job", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // Mark vidA as a failed download. await writeFile(resolvePath(failedTranscriptionsFile), "vidA\n"); await generateReport(page, "test-transcribe"); await page.goto(channelVideos("test-transcribe")); // The quick-select helper grabs exactly the failed video. await page.getByRole("button", { name: "Select failed", exact: true }).click(); await expect(page.getByLabel("bulk action bar")).toBeVisible(); await expect(page.getByText("1 selected", { exact: true })).toBeVisible(); await chooseAction(page, "Delete directories"); // Apply stays disabled until "delete" is typed into the confirm box. const apply = applyButton(page); await expect(apply).toBeDisabled(); await page.getByLabel("type delete to confirm").fill("delete"); await expect(apply).toBeEnabled(); await apply.click(); await expectSubmitted(page); // vidA's directory is gone; the others are untouched. expect(await pathExists(`${dataDir}/vidA`)).toBe(false); expect(await pathExists(`${dataDir}/vidB`)).toBe(true); expect(await pathExists(`${dataDir}/vidC`)).toBe(true); // It disappears from the list immediately (live-filesystem read). await expect(page.getByLabel("select vidA")).toHaveCount(0); await expect(page.getByLabel("select vidB")).toBeVisible(); // Crucially: NO managed job was queued (no transcode/download triggered). // The report generateReport asked for is a refresh-report job of its own // (release 17: every report walk runs on the refresh-report queue), and // /jobs may draw it before its default filter hides it — not this action's. await page.goto("/jobs"); await expect( page .getByRole("row") .filter({ hasText: "test-transcribe" }) .filter({ hasNotText: "refresh-report" }), ).toHaveCount(0); }); test("Clear failed markers prunes the failure file and clears the glyph", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // vidA failed transcription. await writeFile(resolvePath(failedTranscriptionsFile), "vidA\n"); await generateReport(page, "test-transcribe"); await page.goto(channelVideos("test-transcribe")); // One failure glyph is shown before clearing. await expect(page.getByTitle("failure recorded")).toHaveCount(1); await page.getByRole("button", { name: "Select failed", exact: true }).click(); await expect(page.getByText("1 selected", { exact: true })).toBeVisible(); await chooseAction(page, "Clear failed markers"); await clickApply(page); await expectSubmitted(page); // The failure file no longer lists the cleared id (fresh-loaded each render, // so the glyph clears immediately too). await expect(page.getByTitle("failure recorded")).toHaveCount(0); expect(await readFile(resolvePath(failedTranscriptionsFile), "utf8")).not.toContain( "vidA", ); }); // Remove audio files / Remove wrong-format audio are synchronous, summary-style // actions (no queued job) gated by a window.confirm — the point is to clear // audio WITHOUT triggering a transcode. Accept the confirm dialog before Apply. test("Select wrong-format + Remove wrong-format audio removes off-target files and queues no job", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // The channel's target format is m4a. Turn vidA's audio.m4a into audio.webm so // vidA is a wrong-format orphan (off-target audio, no audio.m4a at all). await rename( resolvePath(`${dataDir}/vidA/audio.m4a`), resolvePath(`${dataDir}/vidA/audio.webm`), ); await generateReport(page, "test-transcribe"); await page.goto(channelVideos("test-transcribe")); // The quick-select helper grabs exactly the wrong-format video. await page .getByRole("button", { name: "Select wrong-format", exact: true }) .click(); await expect(page.getByLabel("bulk action bar")).toBeVisible(); await expect(page.getByText("1 selected", { exact: true })).toBeVisible(); page.once("dialog", (dialog) => dialog.accept()); await chooseAction(page, "Remove wrong-format audio"); await clickApply(page); await expectSubmitted(page); // vidA's off-target file is gone; the on-target files elsewhere are untouched. expect(await pathExists(`${dataDir}/vidA/audio.webm`)).toBe(false); expect(await pathExists(`${dataDir}/vidB/audio.m4a`)).toBe(true); expect(await pathExists(`${dataDir}/vidC/audio.m4a`)).toBe(true); // Crucially: NO managed job was queued (no transcode/download triggered). await page.goto("/jobs"); await expect( page.getByRole("row").filter({ hasText: "test-transcribe" }), ).toHaveCount(0); }); test("Remove audio files deletes finalized audio but keeps .part partials, queues no job", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // vidB has a finalized audio.m4a plus an in-progress .part download. await writeFile(resolvePath(`${dataDir}/vidB/audio.m4a.part`), "partial\n"); await generateReport(page, "test-transcribe"); await page.goto(channelVideos("test-transcribe")); await page.getByLabel("select vidB").check(); await expect(page.getByText("1 selected", { exact: true })).toBeVisible(); page.once("dialog", (dialog) => dialog.accept()); await chooseAction(page, "Remove audio files"); await clickApply(page); await expectSubmitted(page); // Finalized audio removed; the resumable .part is kept. expect(await pathExists(`${dataDir}/vidB/audio.m4a`)).toBe(false); expect(await pathExists(`${dataDir}/vidB/audio.m4a.part`)).toBe(true); // Other videos' audio is untouched. expect(await pathExists(`${dataDir}/vidA/audio.m4a`)).toBe(true); // No managed job was queued. await page.goto("/jobs"); await expect( page.getByRole("row").filter({ hasText: "test-transcribe" }), ).toHaveCount(0); }); // The channel-wide sweep lives in the Cleanup stage and runs as a managed job. // It removes wrong-format orphans across every dir — including dirs with no // target file, which "Clean extra audio formats" skips — but respects the // per-video do-not-clean marker. test("Cleanup stage 'Remove wrong-format audio' sweeps orphans but skips do-not-clean dirs", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); // vidA: wrong-format orphan that should be swept. await rename( resolvePath(`${dataDir}/vidA/audio.m4a`), resolvePath(`${dataDir}/vidA/audio.webm`), ); // vidB: wrong-format orphan protected by do-not-clean — must be kept. await rename( resolvePath(`${dataDir}/vidB/audio.m4a`), resolvePath(`${dataDir}/vidB/audio.webm`), ); await writeFile( resolvePath(`${dataDir}/vidB/do-not-clean.json`), JSON.stringify({ setAt: "2026-06-18T00:00:00.000Z" }), ); // vidC keeps its on-target audio.m4a untouched. // ?stage=cleanup renders the Cleanup panel — server-side, so there is nothing // to expand once the page loads. await page.goto(channelStage("test-transcribe", "cleanup")); const section = page.getByLabel("wrong-format audio section"); await expect(section).toBeVisible(); await section.getByLabel("confirm remove wrong-format audio").fill("remove"); await section .getByRole("button", { name: "Remove wrong-format audio", exact: true }) .click(); // The managed sweep removes vidA's orphan; vidB (do-not-clean) and vidC's // on-target audio are kept. await expect .poll(() => pathExists(`${dataDir}/vidA/audio.webm`), { timeout: 15_000 }) .toBe(false); expect(await pathExists(`${dataDir}/vidB/audio.webm`)).toBe(true); expect(await pathExists(`${dataDir}/vidC/audio.m4a`)).toBe(true); });