// Phase 7: parakeet.cpp workers can be stopped into a partial transcript, and // their compute device is configurable. The fake parakeet wrapper // (fixtures/bin/fake-parakeet-stitch.mjs) stays alive for "slowop" ids and writes // a partial (partial:true, one window) on SIGTERM. import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, generateReport, pathExists, readJson, resetData, resolvePath, writeSettings, } from "./helpers"; async function makeTranscribeChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(root, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "transcribe", name: slug, url: "https://odysee.com/@example", audioFormat: "mp3", }), ); for (const id of ids) { await mkdir(`${root}/data/${id}`, { recursive: true }); await writeFile(`${root}/data/${id}/audio.mp3`, `fake audio ${id}\n`); } } test.beforeEach(async () => { await resetData("empty"); }); test("Stop & keep progress pauses a parakeet run, caches the window, and resumes", async ({ page, }) => { test.setTimeout(60_000); await writeSettings({ workers: [ { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {} }, ], }); await makeTranscribeChannel("partial-chan", ["slowoppk1"]); const dir = "test-transcripts/channels/partial-chan/data/slowoppk1"; const cachedWindow = `${dir}/.audio.mp3.parakeet/win-0000.json`; const transcript = `${dir}/transcript.json`; await generateReport(page, "partial-chan"); await page.goto(channelStage("partial-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // While the parakeet worker runs, the Workers page offers "Stop & keep progress". await page.goto("/workers"); const gpu = page.getByRole("listitem").filter({ hasText: "GPU parakeet" }); const stop = gpu.getByRole("button", { name: /stop GPU parakeet keep progress/i }); await expect(stop).toBeVisible({ timeout: 15_000 }); await stop.click(); // Stop also takes the worker out of rotation (it drains → disabled) rather // than leaving it enabled to grab the next video — an Enable button appears. const enable = gpu.getByRole("button", { name: /enable GPU parakeet/i }); await expect(enable).toBeVisible({ timeout: 15_000 }); // Pause caches the completed window but writes no transcript yet. await expect.poll(() => pathExists(cachedWindow), { timeout: 30_000 }).toBe(true); expect(await pathExists(transcript)).toBe(false); // Re-enable the worker (Stop disabled it), then re-running resumes from the // cached window and completes the transcript. await gpu.getByRole("button", { name: /enable GPU parakeet/i }).click(); await generateReport(page, "partial-chan"); await page.goto(channelStage("partial-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); const doc = await readJson<{ chunk_data?: unknown[] }>(transcript); expect(doc.chunk_data?.length).toBe(2); // The work dir is cleaned once stitching succeeds. expect(await pathExists(cachedWindow)).toBe(false); }); test("Pause Transcriptions stops a parakeet run after the current segment, caches it, and resume completes it", async ({ page, }) => { test.setTimeout(60_000); await writeSettings({ workers: [ { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {} }, ], }); await makeTranscribeChannel("gpause-chan", ["slowoppk1"]); const dir = "test-transcripts/channels/gpause-chan/data/slowoppk1"; const cachedWindow = `${dir}/.audio.mp3.parakeet/win-0000.json`; const transcript = `${dir}/transcript.json`; await generateReport(page, "gpause-chan"); await page.goto(channelStage("gpause-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // Wait until the parakeet worker is actually running, then hit the global // "Pause Transcriptions" button — it should gracefully stop the in-flight // parakeet job after the current segment (not let it run to completion). await page.goto("/workers"); const gpu = page.getByRole("listitem").filter({ hasText: "GPU parakeet" }); await expect( gpu.getByRole("button", { name: /stop GPU parakeet keep progress/i }), ).toBeVisible({ timeout: 15_000 }); await page.getByRole("button", { name: "Pause Transcriptions" }).click(); await expect(page.getByRole("status")).toContainText(/paused/i); // The completed window is cached but no transcript is written yet. await expect.poll(() => pathExists(cachedWindow), { timeout: 30_000 }).toBe(true); expect(await pathExists(transcript)).toBe(false); // Resume restores the worker to enabled; re-running resumes from the cached // window and completes the transcript. await page.getByRole("button", { name: "Resume Transcriptions" }).click(); await generateReport(page, "gpause-chan"); await page.goto(channelStage("gpause-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); const doc = await readJson<{ chunk_data?: unknown[] }>(transcript); expect(doc.chunk_data?.length).toBe(2); expect(await pathExists(cachedWindow)).toBe(false); }); test("/jobs has a Pause Transcriptions toggle", async ({ page, }) => { await writeSettings({ workers: [ { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {} }, ], }); await page.goto("/jobs"); const pause = page.getByRole("button", { name: "Pause Transcriptions" }); await expect(pause).toBeVisible(); await pause.click(); await expect( page.getByRole("button", { name: "Resume Transcriptions" }), ).toBeVisible({ timeout: 10_000 }); await page.getByRole("button", { name: "Resume Transcriptions" }).click(); await expect( page.getByRole("button", { name: "Pause Transcriptions" }), ).toBeVisible({ timeout: 10_000 }); }); test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a failed transcription", async ({ page, }) => { test.setTimeout(60_000); await writeSettings({ workers: [ { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {} }, ], }); // "hangterm" id: the fake parakeet ignores SIGTERM, so the partial stop's // graceful kill outlives execa's 5s force-kill window and the engine is // force-SIGKILLed — `await child` REJECTS. The bug recorded that rejection in // failed-transcriptions (blacklisting the video forever); it must be a skip. await makeTranscribeChannel("hangterm-chan", ["hangtermpk1"]); const dir = "test-transcripts/channels/hangterm-chan/data/hangtermpk1"; const transcript = `${dir}/transcript.json`; const failedFile = "test-transcripts/channels/hangterm-chan/failed-transcriptions"; await generateReport(page, "hangterm-chan"); await page.goto(channelStage("hangterm-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/workers"); const gpu = page.getByRole("listitem").filter({ hasText: "GPU parakeet" }); const stop = gpu.getByRole("button", { name: /stop GPU parakeet keep progress/i }); await expect(stop).toBeVisible({ timeout: 15_000 }); await stop.click(); // The worker drains → disabled once the (force-killed) unit ends; Enable appears. const enable = gpu.getByRole("button", { name: /enable GPU parakeet/i }); await expect(enable).toBeVisible({ timeout: 20_000 }); // The force-killed pause must NOT have been recorded as a failure, and no // transcript was produced (it paused before finishing). if (await pathExists(failedFile)) { const raw = await readFile(resolvePath(failedFile), "utf8"); expect(raw).not.toContain("hangtermpk1"); } expect(await pathExists(transcript)).toBe(false); // Decisive end-to-end check: because the video was NOT blacklisted, re-enabling // and re-running resumes it to completion from the cached window. Under the bug // it would sit in failed-transcriptions and be skipped forever, so the // transcript would never appear. await enable.click(); await generateReport(page, "hangterm-chan"); await page.goto(channelStage("hangterm-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); const doc = await readJson<{ chunk_data?: unknown[] }>(transcript); expect(doc.chunk_data?.length).toBe(2); }); test("a parakeet worker's device is configurable and persists", async ({ page, }) => { await page.goto("/workers"); const form = page.locator('form[data-settings-block="workers"]'); // Migration makes worker 1 whisper-cpp; switch it to parakeet to reveal Device. await form.getByLabel("worker 1 engine").selectOption("parakeet"); await form.getByLabel(/^Device/).fill("CUDA0"); await form.getByRole("button", { name: "Save workers" }).click(); await expect(form.getByRole("status")).toHaveText("Saved."); const saved = await readJson<{ workers?: { appId?: string; config?: { device?: string } }[]; }>("test-settings.json"); expect(saved.workers?.[0].appId).toBe("parakeet"); expect(saved.workers?.[0].config?.device).toBe("CUDA0"); });