Archilyzer · Source

archilyzer

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

commit 119ae2cde9ea19000406bf0fa5ffbe363d553800
parent e7f116699507834f88ed505ed71618a397400fd5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 28 Aug 2026 15:50:45 -0400

video page: a speaker operation runs for one video

The panels could say a record was missing or stale and offer nothing to do
about it. The only entry point to the speaker lane was the channel's Speakers
stage, which runs every video in the channel.

runOperationForVideoAction is that entry point at video scale: one operation,
one video, on the lane's own key and behind nothing, because a hand-clicked run
goes in front of the sweep's. It refuses by name for an operation that is not
switched on — the panel does not draw the button in that case, and this is the
belt behind that brace. Digest is deliberately not routed through it: it keeps
digestBucketAction, its own lane and its own guards.

The e2e is where the id scope earns its keep. attrvid0001 is reachable for the
same operation and sits in the same channel; the run touches attrvid0002 alone,
so only the scope kept the other untouched. The other new spec pins the panel
set itself: a diarization record shown while capture is off and marked
"switched off", both attribution panels reading the one shared file, and a
blocked lane naming what it waits for. video-page.spec.ts gains the deferred
digest's reason, which no per-video surface could show before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
Meditor/app/channels/[slug]/videos/[id]/components/OperationPanel.tsx | 18++++++++++++++++--
Meditor/app/channels/[slug]/videos/[id]/components/SpeakerBodies.tsx | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Aeditor/app/channels/[slug]/videos/[id]/operationActions.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/attribution.spec.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/video-page.spec.ts | 6++++++
5 files changed, 248 insertions(+), 10 deletions(-)

diff --git a/editor/app/channels/[slug]/videos/[id]/components/OperationPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/OperationPanel.tsx @@ -103,9 +103,23 @@ function Body({ case "digest": return <DigestBody slug={slug} videoId={videoId} data={body.data} />; case "diarization": - return <DiarizationBody data={body.data} />; + return ( + <DiarizationBody + slug={slug} + videoId={videoId} + view={view} + data={body.data} + /> + ); case "attribution": - return <AttributionBody data={body.data} />; + return ( + <AttributionBody + slug={slug} + videoId={videoId} + view={view} + data={body.data} + /> + ); case undefined: return ( <ul diff --git a/editor/app/channels/[slug]/videos/[id]/components/SpeakerBodies.tsx b/editor/app/channels/[slug]/videos/[id]/components/SpeakerBodies.tsx @@ -18,8 +18,12 @@ // and lib/attribution.ts have no imports at all and are safe for values, but // nothing in this file needs one. +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; import type { DiarizationEngine } from "yt-dlp-transcript-common/lib/diarization"; import type { AttributionProvenance } from "yt-dlp-transcript-common/lib/attribution"; +import { runOperationForVideoAction } from "../operationActions"; +import { cancelJobAction } from "../../../../../jobs/actions"; +import type { OperationPanelView } from "./operationState"; // Projected on the server from DiarizationRecord — `turns` as a COUNT, because // a long recording has thousands of them and none is worth a row here. @@ -57,13 +61,29 @@ export type AttributionBodyData = { } | null; }; -export function DiarizationBody({ data }: { data: DiarizationBodyData }) { +export function DiarizationBody({ + slug, + videoId, + view, + data, +}: { + slug: string; + videoId: string; + view: OperationPanelView; + data: DiarizationBodyData; +}) { const record = data.record; if (!record) { return ( - <p aria-label="diarization empty" className="text-sm text-muted-foreground"> - No speaker turns have been captured for this video. - </p> + <> + <p + aria-label="diarization empty" + className="text-sm text-muted-foreground" + > + No speaker turns have been captured for this video. + </p> + <RunOne slug={slug} videoId={videoId} view={view} /> + </> ); } const e = record.engine; @@ -106,17 +126,34 @@ export function DiarizationBody({ data }: { data: DiarizationBodyData }) { <p aria-label="diarization summary" className="text-sm"> {record.speakers} speakers · {record.turns} turns </p> + <RunOne slug={slug} videoId={videoId} view={view} /> </> ); } -export function AttributionBody({ data }: { data: AttributionBodyData }) { +export function AttributionBody({ + slug, + videoId, + view, + data, +}: { + slug: string; + videoId: string; + view: OperationPanelView; + data: AttributionBodyData; +}) { const record = data.record; if (!record) { return ( - <p aria-label="attribution empty" className="text-sm text-muted-foreground"> - No speaker names for this video yet. - </p> + <> + <p + aria-label="attribution empty" + className="text-sm text-muted-foreground" + > + No speaker names for this video yet. + </p> + <RunOne slug={slug} videoId={videoId} view={view} /> + </> ); } const p = record.provenance; @@ -186,10 +223,43 @@ export function AttributionBody({ data }: { data: AttributionBodyData }) { <p aria-label="attribution summary" className="text-sm"> {record.segments} segments · {record.warnings} warnings </p> + <RunOne slug={slug} videoId={videoId} view={view} /> </> ); } +// One video, on the speaker lane. NOT DRAWN for a switched-off operation: its +// panel is shown so the record stays visible, but there is nothing to run — the +// action refuses on the same condition, which is the belt behind this brace. +function RunOne({ + slug, + videoId, + view, +}: { + slug: string; + videoId: string; + view: OperationPanelView; +}) { + if (!view.enabled) return null; + return ( + <div className="flex flex-col gap-2 border-t border-border pt-3"> + <StreamActionLog + trigger={() => runOperationForVideoAction(slug, videoId, view.id)} + cancelAction={cancelJobAction} + buttonLabel={`Run ${view.label}`} + runningLabel="Running…" + // The accessible name the log, the cancel button and the error all + // derive from — see StreamActionLog. + label={`Run ${view.label}`} + /> + <p className="text-xs text-muted-foreground"> + Runs this one video on the speaker lane, behind nothing — the channel&apos;s + Speakers stage runs the whole channel. + </p> + </div> + ); +} + function Row({ label, value }: { label: string; value: string }) { return ( <> diff --git a/editor/app/channels/[slug]/videos/[id]/operationActions.ts b/editor/app/channels/[slug]/videos/[id]/operationActions.ts @@ -0,0 +1,55 @@ +"use server"; + +// Run ONE registry operation over ONE video, from its panel. +// +// THE SPEAKER LANE ONLY. Digest keeps digestBucketAction: it runs on its own +// queue with its own guards (the pause switch, the yield-to-transcription +// carve-out, the spend cap, the remoteEnabled fail-fast), and none of those is +// expressible through the backfill lane's single scalar. +// +// NOTHING BUT FUNCTIONS IS DECLARED HERE. A `"use server"` file must not +// re-export a type: Next's server-actions transform does not erase a +// re-export the way it erases a type declaration, and every route that reaches +// the export 500s at runtime while tsc stays quiet. digestActions.ts has the +// long version of this note. + +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { + backfillLaneOperations, + operationLabel, +} from "yt-dlp-transcript-common/lib/operations"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { runBackfillChannelJob } from "yt-dlp-transcript-common/controller/operationJobs"; + +export async function runOperationForVideoAction( + slug: string, + videoId: string, + operationId: string, +): Promise<StreamActionResult> { + // The live lane, not the declared one. A disabled operation's panel does not + // draw the button at all; this is the belt behind that brace, and it names + // where to switch the feature on rather than just refusing. + const lane = backfillLaneOperations(getSettings()); + if (!lane.some((op) => op.id === operationId)) { + return { + ok: false, + error: `${operationLabel(operationId)} is not switched on, so there is nothing to run — enable it on its Operations page.`, + }; + } + return runBackfillChannelJob({ + paths: getPaths(), + channelSlug: slug, + kindIds: [operationId], + // The whole of "one video": the batch intersects these with disk and + // countBackfillWork sizes the bar to them. + ids: [videoId], + // No queueKey — the lane's own key. No `background` either: a hand-clicked + // run goes in front of the sweep's, exactly as backfillChannelAction's does. + onDone: () => { + revalidatePath(`/channels/${slug}/videos/${videoId}`); + revalidatePath(`/channels/${slug}`); + }, + }); +} diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts @@ -287,6 +287,99 @@ test("the text lane will not overwrite a record made from the audio", async ({ }); // --------------------------------------------------------------------------- +// 3b. The video page — one panel per operation, and a Run for one video. +// +// This spec owns them because its seeding IS the interesting case: capture is +// switched OFF throughout while attrvid0001 carries a diarization.json. That is +// exactly "shown because its output is on disk", and it is not hypothetical — +// a sidecar produced from audio the cleanup sweep has since deleted is the only +// surviving record of what was heard. +// --------------------------------------------------------------------------- + +test("the video page draws one panel per operation, with the registry's own state", async ({ + page, +}) => { + await seedChannel(); + await writeSettings(attributionSettings()); + + await page.goto(`/channels/${CHANNEL}/videos/${DIARIZED}`); + + // Shown even though capture is off, and SAID to be off — otherwise its pill + // would offer a regeneration nothing can perform. + await expect(page.getByLabel("diarization panel")).toBeVisible(); + await expect(page.getByLabel("diarization off")).toBeVisible(); + // WHICH configuration produced it. After the audio is gone this is the only + // thing that can say. + await expect(page.getByLabel("diarization provenance")).toContainText( + "fake-diarize", + ); + await expect(page.getByLabel("diarization summary")).toContainText( + "2 speakers", + ); + + // Both naming lanes: the record each is responsible for was never made. + await expect(page.getByLabel("attribution-diarized freshness")).toHaveText( + "not generated", + ); + await expect(page.getByLabel("attribution-text freshness")).toHaveText( + "not generated", + ); + // ONE attribution.json, two panels reading it — the empty state appears + // twice, and that is the two-kinds-one-file rule made visible. + expect(await page.getByLabel("attribution empty").count()).toBe(2); + + // writeDigestVideo lays down a fresh transcript.cues.json, so the digest is + // `missing` rather than `deferred`. + await expect(page.getByLabel("digest freshness")).toHaveText("not generated"); + // The heading is the registry entry's label, not a string kept beside it. + await expect( + page.getByRole("heading", { name: "Speaker names (from the audio)" }), + ).toBeVisible(); + + await page.goto(`/channels/${CHANNEL}/videos/${TEXTONLY}`); + // Off AND nothing on disk: no panel at all. + expect(await page.getByLabel("diarization panel").count()).toBe(0); + // And the blocked lane names what it is waiting FOR rather than just saying + // stuck — the same sentence the stage card uses. + await expect(page.getByLabel("attribution-diarized freshness")).toHaveText( + "waiting on Speaker diarization", + ); +}); + +test("Run from the video page runs that video and no other", async ({ + page, +}) => { + test.setTimeout(SLOW); + await seedChannel(); + await writeSettings(attributionSettings()); + + await page.goto(`/channels/${CHANNEL}/videos/${TEXTONLY}`); + await page + .getByRole("button", { + name: "Run Speaker names (from the transcript)", + exact: true, + }) + .click(); + // The batch's closing summary line, which is emitted after the last write — + // the happens-before edge for the sidecar reads below. + await expect( + page.getByLabel("Run Speaker names (from the transcript) output"), + ).toContainText("1 done", { timeout: 90_000 }); + + expect((await readAttribution(TEXTONLY)).provenance.method).toBe("text-only"); + // THE POINT OF THE ids SCOPE. attrvid0001 is reachable for this same + // operation and sits in the same channel; only the id scope kept it + // untouched, and without it this run would have been a channel run. + expect(await pathExists(dataRel(DIARIZED, "attribution.json"))).toBe(false); + + // StreamActionLog calls router.refresh() when a run finishes, so the pill + // re-renders from the server without a manual reload. + await expect(page.getByLabel("attribution-text freshness")).toHaveText( + "current", + ); +}); + +// --------------------------------------------------------------------------- // 4. The indicators, which were supposed to need no UI work at all // --------------------------------------------------------------------------- diff --git a/editor/e2e/video-page.spec.ts b/editor/e2e/video-page.spec.ts @@ -24,6 +24,12 @@ test("per-video page renders metadata and file list", async ({ page }) => { await expect( fileList.getByLabel("file name transcript.en.vtt"), ).toBeVisible(); + // This fixture has a VTT and NO transcript.cues.json — the state 1,942 videos + // on the live corpus are in. The digest defers on it, and this is the first + // place the reason is legible per video rather than as a channel-wide count. + await expect(page.getByLabel("digest freshness")).toContainText( + "held — waiting on a normalized transcript", + ); }); test("per-video page 404s for missing channel/video", async ({ page }) => {