Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit eea11c4af0014e37375265bcff44ce0254833696
parent 245a9908102f9c3afc70d916ceb8af403782465e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 26 Apr 2026 21:42:14 -0400

feat(editor): whisper transcription queue with fake-binary e2e tests

Adds the Transcribe / Retry / Verify panel for transcribe-handling channels.

- editor/app/channels/[slug]/whisperActions.ts:
  - transcribeMissingAction wraps runWhisperBatch with mode "all" through
    runManagedFunction; shares the same channel:<slug> mutex as the
    yt-dlp pipeline so the two can't fight over the data dir.
  - retryFailuresAction wraps mode "retry-failures".
  - verifyAction returns a non-streaming { duplicates, missing } result
    (or an error object) so the UI can render it directly.
- editor/app/channels/[slug]/_components/WhisperPanel.tsx: two
  StreamActionLog instances + a VerifyButton that renders Duplicates /
  Missing buckets inline. Hidden for non-transcribe channels.
- Wired into the channel detail page.

Cypress:
- editor/cypress/fixtures/bin/fake-whisper.mjs: stand-in that consumes
  the runWhisperBatch arg shape (-of <tmpBase>) and writes <tmpBase>.json
  in the format common/lib/whisper.ts parses.
- dev:test now sets WHISPER_BIN + WHISPER_MODEL=/dev/null.
- New fixture one-transcribe-channel-with-audio/ with three video dirs
  (vidA/B/C) carrying placeholder audio.m4a + an archive that lists
  them.
- editor/cypress/e2e/whisper.cy.ts: transcribe-all writes 3
  transcript.json files, post-transcribe verify reports 0/0, pre-
  transcribe verify reports 3 missing, retry-failures with no failures
  reports 0 attempted, panel hidden on youtube channels.

Verified: 23/23 cypress specs pass; pnpm --filter export run build still
produces the existing static output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Diffstat:
Aeditor/app/channels/[slug]/_components/WhisperPanel.tsx | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 5++---
Aeditor/app/channels/[slug]/whisperActions.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/cypress/e2e/whisper.cy.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/cypress/fixtures/bin/fake-whisper.mjs | 39+++++++++++++++++++++++++++++++++++++++
Aeditor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/archive | 3+++
Aeditor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/config.json | 6++++++
Aeditor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidA/audio.m4a | 1+
Aeditor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidB/audio.m4a | 1+
Aeditor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidC/audio.m4a | 1+
Meditor/package.json | 2+-
11 files changed, 311 insertions(+), 4 deletions(-)

diff --git a/editor/app/channels/[slug]/_components/WhisperPanel.tsx b/editor/app/channels/[slug]/_components/WhisperPanel.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { + retryFailuresAction, + transcribeMissingAction, + verifyAction, + type VerifyResult, +} from "../whisperActions"; + +type Props = { + slug: string; +}; + +export function WhisperPanel({ slug }: Props) { + return ( + <div className="flex flex-col gap-6"> + <div className="flex flex-col gap-2"> + <Heading + title="Transcribe missing" + desc="Run whisper-cli over every video that has audio but no transcript.json. Failures append to channels/<slug>/failed-transcriptions." + /> + <StreamActionLog + trigger={() => transcribeMissingAction(slug)} + buttonLabel="Transcribe missing" + runningLabel="Transcribing…" + testId="whisper-all" + /> + </div> + <div className="flex flex-col gap-2"> + <Heading + title="Retry failures" + desc="Re-run whisper for any video listed in failed-transcriptions." + /> + <StreamActionLog + trigger={() => retryFailuresAction(slug)} + buttonLabel="Retry failures" + runningLabel="Retrying…" + testId="whisper-retry" + /> + </div> + <div className="flex flex-col gap-2"> + <Heading + title="Verify" + desc="Cross-check the archive against transcripts on disk." + /> + <VerifyButton slug={slug} /> + </div> + </div> + ); +} + +function VerifyButton({ slug }: { slug: string }) { + const [running, setRunning] = useState(false); + const [result, setResult] = useState<VerifyResult | null>(null); + + async function handleClick() { + setRunning(true); + setResult(null); + try { + const r = await verifyAction(slug); + setResult(r); + } finally { + setRunning(false); + } + } + + return ( + <div className="flex flex-col gap-2" data-testid="whisper-verify"> + <button + type="button" + onClick={handleClick} + disabled={running} + className="self-start px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {running ? "Verifying…" : "Verify"} + </button> + {result?.ok === false && ( + <div className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300"> + {result.error} + </div> + )} + {result?.ok === true && ( + <div + data-testid="whisper-verify-result" + className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm" + > + <Bucket label="Duplicates" items={result.duplicates} /> + <Bucket label="Missing" items={result.missing} /> + </div> + )} + </div> + ); +} + +function Bucket({ label, items }: { label: string; items: string[] }) { + return ( + <div className="rounded border border-zinc-200 dark:border-zinc-800 p-3"> + <div className="text-xs uppercase tracking-wide text-zinc-500"> + {label} ({items.length}) + </div> + {items.length === 0 ? ( + <p className="text-zinc-500 mt-1">None</p> + ) : ( + <ul className="mt-1 font-mono text-xs space-y-0.5"> + {items.map((id) => ( + <li key={id}>{id}</li> + ))} + </ul> + )} + </div> + ); +} + +function Heading({ title, desc }: { title: string; desc: string }) { + return ( + <div> + <h3 className="text-base font-semibold">{title}</h3> + <p className="text-sm text-zinc-500">{desc}</p> + </div> + ); +} diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -5,6 +5,7 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { ChannelFormClient } from "../_components/ChannelFormClient"; import { DeleteChannelForm } from "../_components/DeleteChannelForm"; import { PipelinePanel } from "./_components/PipelinePanel"; +import { WhisperPanel } from "./_components/WhisperPanel"; import { deleteChannelAction, updateChannelAction, @@ -58,9 +59,7 @@ export default async function ChannelDetailPage({ {config.handling === "transcribe" && ( <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> <h2 className="text-lg font-semibold">Transcription</h2> - <p className="text-sm text-zinc-500"> - Whisper queue UI lands in Phase 7. - </p> + <WhisperPanel slug={slug} /> </section> )} diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts @@ -0,0 +1,76 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { runWhisperBatch } from "yt-dlp-transcript-common/controller/whisperBatch"; +import { verifyTranscripts } from "yt-dlp-transcript-common/controller/verifyTranscripts"; +import { + runManagedFunction, + type StreamActionResult, +} from "yt-dlp-transcript-common/jobs/streamCommand"; + +export async function transcribeMissingAction( + slug: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "whisper-all", + mutexKey: `channel:${slug}`, + paths, + busyMessage: `Channel "${slug}" already has an operation running`, + fn: async (onLog, signal) => { + const result = await runWhisperBatch({ + channelSlug: slug, + paths, + mode: "all", + onLog, + signal, + }); + onLog( + `Whisper batch: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`, + ); + revalidatePath(`/channels/${slug}`); + }, + }); +} + +export async function retryFailuresAction( + slug: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "whisper-retry", + mutexKey: `channel:${slug}`, + paths, + busyMessage: `Channel "${slug}" already has an operation running`, + fn: async (onLog, signal) => { + const result = await runWhisperBatch({ + channelSlug: slug, + paths, + mode: "retry-failures", + onLog, + signal, + }); + onLog( + `Retry batch: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`, + ); + revalidatePath(`/channels/${slug}`); + }, + }); +} + +export type VerifyResult = + | { ok: true; duplicates: string[]; missing: string[] } + | { ok: false; error: string }; + +export async function verifyAction(slug: string): Promise<VerifyResult> { + try { + const result = await verifyTranscripts({ + channelSlug: slug, + paths: getPaths(), + }); + return { ok: true, ...result }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } +} diff --git a/editor/cypress/e2e/whisper.cy.ts b/editor/cypress/e2e/whisper.cy.ts @@ -0,0 +1,58 @@ +describe("Whisper", () => { + it("transcribes every audio file with no transcript", () => { + cy.resetData("one-transcribe-channel-with-audio"); + cy.visit("/channels/test-transcribe"); + cy.findByTestId("whisper-all").find("button").click(); + cy.findByTestId("whisper-all-log", { timeout: 30_000 }).should( + "contain.text", + "3 succeeded", + ); + for (const id of ["vidA", "vidB", "vidC"]) { + cy.task( + "pathExists", + `test-transcripts/channels/test-transcribe/data/${id}/transcript.json`, + ).then((exists) => expect(exists).to.equal(true)); + } + }); + + it("verify reports nothing missing once transcribed", () => { + cy.resetData("one-transcribe-channel-with-audio"); + cy.visit("/channels/test-transcribe"); + cy.findByTestId("whisper-all").find("button").click(); + cy.findByTestId("whisper-all-log", { timeout: 30_000 }).should( + "contain.text", + "3 succeeded", + ); + cy.findByTestId("whisper-verify").find("button").click(); + cy.findByTestId("whisper-verify-result", { timeout: 10_000 }) + .should("contain.text", "Duplicates (0)") + .and("contain.text", "Missing (0)"); + }); + + it("verify flags missing transcripts before whisper runs", () => { + cy.resetData("one-transcribe-channel-with-audio"); + cy.visit("/channels/test-transcribe"); + cy.findByTestId("whisper-verify").find("button").click(); + cy.findByTestId("whisper-verify-result", { timeout: 10_000 }).should( + "contain.text", + "Missing (3)", + ); + }); + + it("retry-failures runs cleanly with an empty failure list", () => { + cy.resetData("one-transcribe-channel-with-audio"); + cy.visit("/channels/test-transcribe"); + cy.findByTestId("whisper-retry").find("button").click(); + cy.findByTestId("whisper-retry-log", { timeout: 30_000 }).should( + "contain.text", + "0 succeeded", + ); + }); + + it("whisper panel hidden for youtube channels", () => { + cy.resetData("one-youtube-channel"); + cy.visit("/channels/test-youtube"); + cy.findByTestId("whisper-all").should("not.exist"); + cy.findByTestId("whisper-verify").should("not.exist"); + }); +}); diff --git a/editor/cypress/fixtures/bin/fake-whisper.mjs b/editor/cypress/fixtures/bin/fake-whisper.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Cypress fake whisper-cli. Mimics the args runWhisperBatch passes: +// whisper-cli -ojf -l en -m <model> -of <tmpBase> <audioFilename> +// Writes <tmpBase>.json next to cwd (which runWhisperBatch sets to the +// video dir). Format matches what common/lib/whisper.ts parses. +import { writeFile } from "node:fs/promises"; + +const argv = process.argv.slice(2); +function arg(flag) { + const i = argv.indexOf(flag); + return i < 0 ? undefined : argv[i + 1]; +} + +const tmpBase = arg("-of"); +const audio = argv[argv.length - 1]; +if (!tmpBase) { + process.stderr.write(`[fake-whisper] missing -of\n`); + process.exit(2); +} + +const doc = { + systeminfo: "fake", + model: { type: "fake" }, + result: { language: "en" }, + transcription: [ + { + offsets: { from: 0, to: 5000 }, + text: `Synthetic whisper output for ${audio} #1`, + }, + { + offsets: { from: 5000, to: 10000 }, + text: `Synthetic whisper output for ${audio} #2`, + }, + ], +}; + +const outPath = `${tmpBase}.json`; +await writeFile(outPath, JSON.stringify(doc)); +process.stdout.write(`[fake-whisper] wrote ${outPath}\n`); diff --git a/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/archive b/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/archive @@ -0,0 +1,3 @@ +lbry vidA +lbry vidB +lbry vidC diff --git a/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/config.json b/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/config.json @@ -0,0 +1,6 @@ +{ + "handling": "transcribe", + "name": "Test Transcribe Channel", + "url": "https://odysee.com/@example", + "audioFormat": "m4a" +} diff --git a/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidA/audio.m4a b/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidA/audio.m4a @@ -0,0 +1 @@ +fake audio bytes A diff --git a/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidB/audio.m4a b/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidB/audio.m4a @@ -0,0 +1 @@ +fake audio bytes B diff --git a/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidC/audio.m4a b/editor/cypress/fixtures/test-transcripts/one-transcribe-channel-with-audio/channels/test-transcribe/data/vidC/audio.m4a @@ -0,0 +1 @@ +fake audio bytes C diff --git a/editor/package.json b/editor/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "next dev --port 3001", - "dev:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public YTDLP_BIN=$(pwd)/cypress/fixtures/bin/fake-ytdlp.mjs next dev --port 3001", + "dev:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public YTDLP_BIN=$(pwd)/cypress/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/cypress/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null next dev --port 3001", "build": "next build", "start": "next start --port 3001", "lint": "eslint",