commit 3aac3716b16db8a0f62a2932826589fa8febd357 parent 92baa36653e06e563194774cfefb8ea02767ca5b Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Fri, 28 Aug 2026 02:58:46 -0400 operations: each operation page lists the channels with its work, and the dashboard says "digest to do" `OperationDetail` gains a `channelWork` slot beside the two settings slots, and renders it after the three-way console branch — inside the outer div, outside every `section[data-lane]` and outside the runner's `<section>`. Both of those are hazards with a history: a nested `<section>` breaks RunnerOperationView's contract, and a labelled section inside the sweep's puts its headings, prose and slugs into every `data-lane`-scoped selector the suite has. Below the console rather than above it, because the console is the operation and the table is what is waiting for it. `/operations/[id]` fills the slot from `sectionsFor(op.id)`: three sections on download, two on transcription, one on digest, none on diarization, attribution-* or transcode — and for those the summary is never loaded. It rides the request-cached `getChannelBriefs` that `buildAutoQueueStatusPayload` read a few lines above, so it is no extra disk walk. The dashboard's digest number does NOT move. `actionableNoDigestCount` already returned `digestWorkOf(row.snapshot).reachable` — the same call `buildBands` folds into the digest band's `reachable` — so this is a rename to an honest label, not a re-sourcing: `actionableDigestReachableCount`, wire field `digestReachable`, column header "Digest to do", badge title "N video(s) the digest lane can work on now". The figure on screen is unchanged. The widget payload field renames with it; `MonitorWidget` never read it. "Needs work" links to /operations. Revalidation now names the pages that actually draw these counts: `/operations/[id]` as a page pattern (the shape `operations/actions.ts` documents — a literal path cannot reach `/operations/digest` without naming it) plus `/cleanup`, from `refreshAllChannelSnapshotsAction`, the two global incomplete actions, `pipelineActions` and the snapshot scheduler. The `"/actionable"` lines stay until the page is gone. Specs: `actionable.spec.ts` is `channel-work.spec.ts`. The download and transcription cases go to their operation pages; the dashboard link assertion follows the href. The first case is rewritten — the page-wide "Everything is up to date." died with the gate, so it asserts the section's own empty label instead. `incomplete-transcript`, `download-format-guard`, `backfill` (10) and the attribution tail follow their sections. Two properties are dropped with reasons in the comments: backfill (10)'s "not hidden behind nothing pending" (there is no such gate on an operation page) and attribution's "3", which was 1 diarized + 2 text summed — the per-operation pages split it by construction, and the sum still lives on the channel page's speakers stage, asserted twenty lines above. Nothing under transcripts/ was read or written for this commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Diffstat:
17 files changed, 394 insertions(+), 322 deletions(-)
diff --git a/common/jobs/snapshotScheduler.ts b/common/jobs/snapshotScheduler.ts @@ -201,6 +201,8 @@ async function fire(): Promise<void> { for (const slug of regenerated) revalidatePath(`/channels/${slug}`); revalidatePath("/channels"); revalidatePath("/actionable"); + revalidatePath("/operations/[id]", "page"); + revalidatePath("/cleanup"); revalidatePath("/"); } catch { // revalidatePath outside a request/Next runtime — the snapshots are diff --git a/editor/app/api/widget/actionable/route.ts b/editor/app/api/widget/actionable/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { - actionableNoDigestCount, + actionableDigestReachableCount, actionableUndownloadedCount, actionableUntranscribedCount, loadActionableSummary, @@ -13,15 +13,15 @@ export type WidgetActionableChannel = { slug: string; undownloaded: number; untranscribed: number; - // Videos with no current digest — missing, stale or part-done — read from the - // operation registry. Reported but STILL NOT used to decide whether a channel + // What the digest lane could act on today — the digest band's `reachable`, + // per channel. Reported but STILL NOT used to decide whether a channel // "needs work", and the reasoning is unchanged: during the backfill this is // ~99.87% of the corpus, so // counting it would put every channel in the list forever and drown the two // buckets a human can actually act on today. The registry's classification // removes untranscribed and cues-stale videos from the number, which makes it // smaller and more honest — nowhere near small enough to filter on. - noDigest: number; + digestReachable: number; }; export type WidgetActionablePayload = { @@ -39,7 +39,7 @@ export async function GET() { slug: row.channel.slug, undownloaded: actionableUndownloadedCount(row), untranscribed: actionableUntranscribedCount(row), - noDigest: actionableNoDigestCount(row), + digestReachable: actionableDigestReachableCount(row), })) .filter((c) => c.undownloaded > 0 || c.untranscribed > 0) .sort( diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -211,6 +211,8 @@ async function runPipelineAction( revalidatePath(`/channels/${slug}`); revalidatePath("/channels"); revalidatePath("/actionable"); + revalidatePath("/operations/[id]", "page"); + revalidatePath("/cleanup"); revalidatePath("/"); }, }); diff --git a/editor/app/components/dashboard/ChannelsTable.tsx b/editor/app/components/dashboard/ChannelsTable.tsx @@ -34,9 +34,9 @@ export function ChannelsTable({ <th className="text-right font-medium px-3 py-2">Videos</th> <th className="text-right font-medium px-3 py-2 whitespace-nowrap" - title="Videos with no AI digest at the current prompt/model identity" + title="Videos the digest lane can work on now — the same reachable figure the /operations rail draws; blocked and deferred are not in it" > - No digest + Digest to do </th> <th className="text-left font-medium px-3 py-2 whitespace-nowrap"> Last sync @@ -66,7 +66,7 @@ export function ChannelsTable({ {/* Muted: during the backfill this is nearly every video, so it is a coverage readout rather than a call to action. */} <td className="px-3 py-2 text-right tabular-nums text-xs text-muted-foreground"> - {c.noDigest} + {c.digestReachable} </td> <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap tabular-nums"> {c.lastSyncedAt == null diff --git a/editor/app/components/dashboard/NeedsWorkPanel.tsx b/editor/app/components/dashboard/NeedsWorkPanel.tsx @@ -8,7 +8,8 @@ const LIMIT = 10; // Live "needs work" list: the channels with videos to download or transcribe, // echoing the ↓ (download) / ✎ (transcribe) verbs used across the app, each row -// carrying the same per-channel Download/Transcribe actions as /actionable. +// carrying the same per-channel Download/Transcribe actions as the operation +// pages. export function NeedsWorkPanel({ data, }: { @@ -28,7 +29,7 @@ export function NeedsWorkPanel({ Needs work </h2> <Link - href="/actionable" + href="/operations" className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground" > {channels.length} {channels.length === 1 ? "channel" : "channels"} @@ -71,12 +72,12 @@ export function NeedsWorkPanel({ {/* Muted, not coloured: during the backfill this is nearly every video in the channel, so it is context for the row rather than a call to action like the two above. */} - {c.noDigest > 0 && ( + {c.digestReachable > 0 && ( <span - title={`${c.noDigest} video(s) with no digest at the current identity`} + title={`${c.digestReachable} video(s) the digest lane can work on now`} className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-muted text-muted-foreground" > - ◆ {c.noDigest} + ◆ {c.digestReachable} </span> )} </span> @@ -98,7 +99,7 @@ export function NeedsWorkPanel({ {overflow > 0 && ( <li className="text-xs text-muted-foreground"> <Link - href="/actionable" + href="/operations" className="underline underline-offset-2 hover:text-foreground" > +{overflow} more {overflow === 1 ? "channel" : "channels"} diff --git a/editor/app/components/dashboard/types.ts b/editor/app/components/dashboard/types.ts @@ -11,12 +11,12 @@ export type DashboardChannel = { hasUrl: boolean; undownloaded: number; untranscribed: number; - // Videos needing digest work at the CURRENT identity — missing, stale or - // part-done — from the operation registry. The only per-channel number that - // makes corpus coverage legible while a multi-week sweep is running. + // The digest band's reachable — what the lane could act on today; not + // blocked, not deferred. Missing, stale or part-done at the CURRENT identity, + // from the operation registry. The only per-channel number that makes corpus + // coverage legible while a multi-week sweep is running. // // Videos with no transcript are NOT in it: they are classified as waiting on - // transcription and counted separately, so this number is what the digest - // lane could actually act on today. - noDigest: number; + // transcription and counted separately. + digestReachable: number; }; diff --git a/editor/app/lib/actionable/actions.ts b/editor/app/lib/actionable/actions.ts @@ -85,9 +85,9 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu `${awaitingTranscription} awaiting transcription.`, ); // Deliberately no revalidatePath here — calling it from a - // background fn races with the in-flight re-render of /actionable - // that the action's own revalidatePath triggers. The action's - // single revalidate at the end picks up every fresh snapshot. + // background fn races with the in-flight re-render that the action's + // own revalidatePath triggers. The action's single revalidate at the + // end picks up every fresh snapshot. }, }); if (!result.ok) { @@ -98,11 +98,14 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu streams.push(result.stream); } // Wait for all snapshots to finish writing before revalidating so the - // re-rendered /actionable reads fresh counts. With queueKey === "" the + // pages that read the snapshots read fresh counts. With queueKey === "" the // jobs all run in parallel, so this waits roughly the time of the // slowest snapshot, not the sum. await Promise.all(streams.map(drainStream)); revalidatePath("/actionable"); + revalidatePath("/channels"); + revalidatePath("/operations/[id]", "page"); + revalidatePath("/cleanup"); revalidatePath("/"); return { queued, skipped }; } @@ -230,6 +233,7 @@ export async function clearAllIncompleteTranscriptsAction(): Promise<GlobalIncom } await enableAutoRunners(); revalidatePath("/actionable"); + revalidatePath("/operations/[id]", "page"); return { ok: true, channels: slugs.length, affected: cleared }; } @@ -250,6 +254,7 @@ export async function redownloadAllIncompleteTranscriptsAction(): Promise<Global } } revalidatePath("/actionable"); + revalidatePath("/operations/[id]", "page"); return { ok: true, channels: slugs.length, affected: queued }; } diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts @@ -108,7 +108,11 @@ export function actionableCleanExtraFormatsCount(row: ActionableRow): number { // uses, and it excludes videos with no transcript (blocked) and videos whose // cues.json is stale (deferred) — work the old `noDigest` bucket offered here // and the runner then declined. -export function actionableNoDigestCount(row: ActionableRow): number { +// +// This IS the digest band's `reachable`, per channel: buildBands.ts:63 folds +// the same call. The name says so now, so nobody re-sources a number that was +// already the right one. +export function actionableDigestReachableCount(row: ActionableRow): number { return digestWorkOf(row.snapshot).reachable; } diff --git a/editor/app/operations/[id]/page.tsx b/editor/app/operations/[id]/page.tsx @@ -23,6 +23,12 @@ import { DigestSettingsForm } from "../components/settings/DigestSettingsForm"; import { DiarizationSettingsForm } from "../components/settings/DiarizationSettingsForm"; import { AttributionSettingsForm } from "../components/settings/AttributionSettingsForm"; import { LaneSettingsForm } from "../components/settings/LaneSettingsForm"; +import { ChannelWorkTable } from "../../components/channelWork/ChannelWorkTable"; +import { + sectionsFor, + type SectionConfig, +} from "../../components/channelWork/sections"; +import { getActionableSummary } from "../../lib/actionable/loadActionable"; export const dynamic = "force-dynamic"; @@ -187,6 +193,26 @@ export default async function OperationPage({ videoId: j.videoId, })); + // THE CHANNELS WITH THIS OPERATION'S WORK. The cast is safe only because an + // id the module does not know returns [] — diarization, both attribution + // operations and transcode get no table at all, and the summary is not even + // loaded for them. + const sections = sectionsFor(op.id as SectionConfig["operation"]); + const channelWork = + sections.length > 0 ? ( + // No extra disk walk: the census is built from the request-cached + // `getChannelBriefs` that `buildAutoQueueStatusPayload` already read + // above (operations/lanes.ts:198). Keyed for the same reason + // settingsFormFor's elements are — a server element handed to a client + // component lands in its children array with React's dev-only key check + // still to run over it. + <ChannelWorkTable + key="channel-work" + sections={sections} + summary={await getActionableSummary(getPaths())} + /> + ) : null; + return ( <div className="flex flex-col gap-4"> <div className="flex flex-wrap items-baseline justify-between gap-2"> @@ -213,6 +239,7 @@ export default async function OperationPage({ }))} operationSettings={operationSettings} laneSettings={laneSettings} + channelWork={channelWork} /> </div> ); diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx @@ -36,6 +36,7 @@ export function OperationDetail({ dependsOn, operationSettings, laneSettings, + channelWork, }: { id: string; initial: AutoQueueStatusPayload; @@ -68,6 +69,16 @@ export function OperationDetail({ // and is therefore the same form on every member page of that lane. operationSettings?: ReactNode; laneSettings?: ReactNode; + // THE CHANNELS WITH THIS OPERATION'S WORK, built on the server (it needs the + // actionable census and `primaryAction` is a function, so it cannot be a + // client component). Rendered BELOW the console, and OUTSIDE it on purpose: + // - inside the runner's <section> it would be a nested <section>, which + // RunnerOperationView's contract forbids; + // - inside the sweep's <section> its headings, prose and slugs would land + // inside every `section[data-lane]`-scoped getByText in the e2e suite. + // Below rather than above because the console IS the operation; this table is + // what is waiting for it. + channelWork?: ReactNode; }) { const { data, refresh } = useOperationsStatus(initial); @@ -105,6 +116,8 @@ export function OperationDetail({ ) : ( <NoConsoleView id={id} data={data} dependsOn={dependsOn} /> )} + + {channelWork} </div> ); } diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -8,7 +8,7 @@ import { siteChannelSlugs, } from "yt-dlp-transcript-common/lib/site"; import { - actionableNoDigestCount, + actionableDigestReachableCount, actionableUndownloadedCount, actionableUntranscribedCount, getActionableSummary, @@ -77,7 +77,7 @@ export default async function Dashboard({ hasUrl: Boolean(r.channel.config.url), undownloaded: actionableUndownloadedCount(r), untranscribed: actionableUntranscribedCount(r), - noDigest: actionableNoDigestCount(r), + digestReachable: actionableDigestReachableCount(r), })); // "Needs work" payload, same shape the widget endpoint the cockpit polls @@ -89,7 +89,7 @@ export default async function Dashboard({ slug: c.slug, undownloaded: c.undownloaded, untranscribed: c.untranscribed, - noDigest: c.noDigest, + digestReachable: c.digestReachable, })) .sort( (a, b) => diff --git a/editor/e2e/actionable.spec.ts b/editor/e2e/actionable.spec.ts @@ -1,259 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; -import { test, expect } from "@playwright/test"; -import { resetData, resolvePath, readJson, jobRowByKind, generateReport} from "./helpers"; - -const YT_SNAPSHOT_REL = - "test-transcripts/channels/test-youtube/snapshot.json"; -const YT_CONFIG_REL = "test-transcripts/channels/test-youtube/config.json"; -const TR_SNAPSHOT_REL = - "test-transcripts/channels/test-transcribe/snapshot.json"; - -async function writeJson(relPath: string, value: unknown): Promise<void> { - const full = resolvePath(relPath); - await mkdir(dirname(full), { recursive: true }); - await writeFile(full, JSON.stringify(value, null, 2) + "\n"); -} - -test("empty state when nothing is pending and no channels exist", async ({ - page, -}) => { - await resetData("empty"); - await page.goto("/actionable"); - await expect( - page.getByRole("heading", { level: 1, name: "Actionable items" }), - ).toBeVisible(); - await expect(page.getByLabel("actionable items empty")).toContainText( - "Everything is up to date.", - ); -}); - -test("Actionable nav link is wired", async ({ page }) => { - await resetData("empty"); - await page.goto("/"); - await expect( - page.locator("aside").getByRole("link", { name: /^Actionable$/ }), - ).toHaveAttribute("href", "/actionable"); -}); - -test("populates the undownloaded section after a snapshot exists", async ({ - page, -}) => { - await resetData("youtube-with-playlist"); - // Visiting the channel detail page auto-generates the snapshot. - await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); - await page.goto("/actionable"); - - const section = page.getByRole("region", { - name: "undownloaded", - exact: true, - }); - await expect(section).toBeVisible(); - await expect(section.getByRole("heading", { name: /undownloaded videos/i })).toBeVisible(); - const row = section.getByLabel("undownloaded row test-youtube"); - await expect(row).toBeVisible(); - await expect(row).toContainText("4"); - await expect(row.getByRole("link", { name: "test-youtube" })).toHaveAttribute( - "href", - "/channels/test-youtube", - ); -}); - -test("populates the awaiting-transcription section", async ({ page }) => { - await resetData("one-transcribe-channel-with-audio"); - await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); - await page.goto("/actionable"); - - const section = page.getByRole("region", { - name: "untranscribed", - exact: true, - }); - const row = section.getByLabel("untranscribed row test-transcribe"); - await expect(row).toBeVisible(); - // vidA, vidB, vidC all have audio but no transcript. - await expect(row).toContainText("3"); -}); - -test("flags channels whose report is older than the last sync", async ({ - page, -}) => { - await resetData("one-youtube-channel"); - // Backdate the snapshot relative to lastSyncedAt to force the stale check. - const cfg = await readJson<Record<string, unknown>>(YT_CONFIG_REL); - cfg.lastSyncedAt = "2026-05-21T12:00:00.000Z"; - await writeJson(YT_CONFIG_REL, cfg); - await writeJson(YT_SNAPSHOT_REL, { - generatedAt: "2026-05-20T12:00:00.000Z", - totals: { videos: 0, transcribed: 0, downloaded: 0 }, - buckets: { - noTranscript: [], - downloadedNoTranscript: [], - untranscoded: [], - multipleAudioFormats: [], - untranscribable: [], - noMetadata: [], - failedListed: [], - missingFromArchive: [], - duplicateDirs: [], - partialDownloads: [], - }, - undownloadedIds: [], - }); - - await page.goto("/actionable"); - const section = page.getByRole("region", { - name: "stale-reports", - exact: true, - }); - await expect(section).toBeVisible(); - await expect( - section.getByLabel("stale-reports row test-youtube"), - ).toContainText("stale"); -}); - -test("'Update all reports' queues a refresh-report job per channel", async ({ - page, -}) => { - await resetData("one-youtube-channel"); - await page.goto("/actionable"); - await page.getByRole("button", { name: "update all reports" }).click(); - await expect(page.getByLabel("update all reports result")).toContainText( - /Queued 1/, - ); - - await page.goto("/jobs"); - // refresh-report is the ONE kind /jobs hides by default - // (DEFAULT_HIDDEN_KINDS in app/jobs/jobsFilterStorage.ts), so un-hide it - // before asserting. Without this the test passes only by RACING ITS OWN - // HYDRATION: JobsTable renders every job while `filters` is still null - // (`if (!filters) return jobs`) and drops refresh-report as soon as - // localStorage loads. That race is won under `next dev` and lost under - // `next start`, which is why this failed only in the sharded route. - // The chip is always present: `kinds` is derived from ALL jobs, not the - // visible ones. Retried because a click before hydration fires nothing. - const kindChip = page.getByRole("button", { - name: "refresh-report", - exact: true, - }); - await expect(async () => { - await kindChip.click(); - await expect(jobRowByKind(page, "refresh-report").first()).toBeVisible({ - timeout: 2_000, - }); - }).toPass({ timeout: 20_000 }); -}); - -test("syncing a channel auto-regenerates its report", async ({ page }) => { - // one-transcribe-channel-with-audio uses an odysee URL — sync will fail - // (the URL is fake), but the report regen happens after runYtdlp regardless - // because we only swallow regen errors, not sync errors. To avoid relying - // on a working sync, drive the auto-regen by visiting the channel page - // which generates the snapshot if missing — and assert the snapshot file - // appears with a fresh timestamp. - // - // Then trigger an actual pipeline action that completes locally — - // Refresh report — and verify the snapshot's generatedAt advances. - await resetData("one-transcribe-channel-with-audio"); - await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); - - const first = await readJson<{ generatedAt: string }>(TR_SNAPSHOT_REL); - await new Promise((r) => setTimeout(r, 50)); // ensure a distinct ISO timestamp - await page.goto("/actionable"); - const refresh = page - .getByRole("button", { name: "refresh report test-transcribe" }) - .first(); - await refresh.waitFor({ state: "visible" }); - // Retried, and the timeout is generous: a click landing before React hydrates - // fires NOTHING — no request, no job, no error — which is the long-standing - // flake pattern in this suite. Regenerating a report is idempotent, so an - // extra click costs nothing and a missed one costs the whole test. - await expect - .poll( - async () => { - await refresh.click({ timeout: 5_000 }).catch(() => {}); - for (let i = 0; i < 20; i++) { - const cur = await readJson<{ generatedAt: string }>(TR_SNAPSHOT_REL); - if (cur.generatedAt > first.generatedAt) return true; - await new Promise((r) => setTimeout(r, 250)); - } - return false; - }, - { timeout: 60_000, intervals: [0, 0, 0] }, - ) - .toBe(true); -}); - -test("inline 'Download missing' queues a download-missing job", async ({ - page, -}) => { - await resetData("youtube-with-playlist"); - await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); // auto-generate snapshot - await page.goto("/actionable"); - await page - .getByRole("button", { name: "download missing test-youtube" }) - .click(); - await expect( - page.getByLabel("download missing test-youtube job"), - ).toBeVisible({ timeout: 10_000 }); - - await page.goto("/jobs"); - await expect(jobRowByKind(page, "download-missing").first()).toBeVisible({ - timeout: 10_000, - }); -}); - -test("inline 'Transcribe pending' queues a whisper-all job", async ({ - page, -}) => { - await resetData("one-transcribe-channel-with-audio"); - await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); // auto-generate snapshot - await page.goto("/actionable"); - await page - .getByRole("button", { name: "transcribe pending test-transcribe" }) - .click(); - await expect( - page.getByLabel("transcribe pending test-transcribe job"), - ).toBeVisible({ timeout: 10_000 }); - - await page.goto("/jobs"); - await expect(jobRowByKind(page, "whisper-all").first()).toBeVisible({ - timeout: 10_000, - }); -}); - -// The cockpit redesign (c63f5cb) renamed this card from "Needs attention" to -// "Needs work" and changed its shape: it is now a <section> that always renders -// — listing channels when there is work and "Everything's handled." when there -// isn't — rather than a single link that disappeared when idle. The old -// "hides the card" assertion passed VACUOUSLY once the label changed (a count -// of an element nobody labels is always 0), so it is rewritten to assert the -// empty state it actually means. -test("dashboard surfaces the 'Needs work' card when work is pending", async ({ - page, -}) => { - await resetData("youtube-with-playlist"); - await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); // auto-generate snapshot - await page.goto("/"); - // getByRole("region"), not getByLabel: the label match is a case-insensitive - // substring, so getByLabel("Needs work") also picks up each - // <li aria-label="needs work <slug>"> inside the card. - const card = page.getByRole("region", { name: "Needs work" }); - await expect(card).toBeVisible(); - await expect(card.getByLabel("needs work test-youtube")).toBeVisible(); - // The card's channel-count link is what navigates to the full list. - await expect(card.locator('a[href="/actionable"]')).toBeVisible(); -}); - -test("dashboard's 'Needs work' card reads as empty when nothing is pending", async ({ - page, -}) => { - await resetData("empty"); - await page.goto("/"); - await expect(page.getByLabel("needs work empty")).toBeVisible(); -}); diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts @@ -353,14 +353,18 @@ test("the stage card and /actionable show attribution beside diarization, with t await expect(section.getByLabel("speakers needs re-acquiring")).toHaveCount(0); await expect(section.getByLabel("speakers blocked")).toContainText("1"); - // /actionable, which sums across kinds but keeps the populations in separate - // columns. - await page.goto("/actionable"); - const row = page.getByLabel(`speakers row ${CHANNEL}`); - await expect(row).toBeVisible(); - // 3 reachable, and still never added to anything else. - await expect(row).toContainText("3"); - await expect(page.getByLabel("speakers", { exact: true })).toContainText( - "Needs media", - ); + // The operation pages, which split the same corpus by construction: one page + // per attribution kind, each with its own band. The cross-kind sum the old + // /actionable row carried ("3" = 1 diarized + 2 text) has no home here and is + // not re-asserted — it still lives on the channel page's speakers stage, + // asserted a few lines above. + await page.goto("/operations/attribution-diarized"); + const diarized = page.locator('section[data-lane="backfill"]'); + await expect(diarized).toContainText(CHANNEL); + await expect(diarized.getByText("1 reachable now")).toBeVisible(); + await expect(diarized.getByText("1 blocked upstream")).toBeVisible(); + + await page.goto("/operations/attribution-text"); + const text = page.locator('section[data-lane="backfill"]'); + await expect(text.getByText("2 reachable now")).toBeVisible(); }); diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts @@ -744,10 +744,15 @@ test("a digest runs concurrently with a backfill, not behind it", async ({ return seen; }); -// (10) /actionable lists the channel, keeps the two numbers in separate columns, -// and — the part that is hand-exhaustive and so easy to miss — does not hide the -// whole page behind "nothing pending". -test("actionable shows the backfill section with both numbers", async ({ +// (10) The operation's own page lists the channel in the sweep plan and keeps +// the two populations apart — reachable now, and the ones whose media has to +// come back first. +// +// This used to assert /actionable's speakers section, which also carried the +// property that the page was not hidden behind a page-wide "nothing pending" +// gate. That gate went with the page: an operation page has no such gate to +// hide behind, so there is nothing left to assert there. +test("the diarization page shows the channel and both populations", async ({ page, }) => { test.setTimeout(SLOW); @@ -763,13 +768,17 @@ test("actionable shows the backfill section with both numbers", async ({ }); await generateReport(page, SLUG); - await page.goto("/actionable"); - const section = page.getByLabel("speakers", { exact: true }); - await expect(section).toBeVisible(); - await expect(section).toContainText(SLUG); - // The re-acquire count rides in its own column, exactly as "Est. reclaim" - // does — never added to the count beside it. - await expect(section).toContainText("Needs media"); + await page.goto("/operations/diarization"); + const lane = page.locator('section[data-lane="backfill"]'); + await expect(lane).toBeVisible(); + // The channel is a row in the plan (the fixture has a snapshot, so it lands + // in `working` rather than in the unknown-channel count). + await expect(lane).toContainText(SLUG); + // vidB's audio was removed above, so the re-acquire population is non-zero — + // and it is stated separately, never added to the count beside it. + await expect( + lane.getByText(/need their media back first/), + ).toBeVisible(); }); // PAUSE IS A HOLD, NOT A STOP, and it is reachable from where the work is diff --git a/editor/e2e/channel-work.spec.ts b/editor/e2e/channel-work.spec.ts @@ -0,0 +1,263 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { test, expect } from "@playwright/test"; +import { resetData, resolvePath, readJson, jobRowByKind, generateReport} from "./helpers"; + +// The channel-work tables on the operation pages, and the report controls on +// /channels. This file was actionable.spec.ts: /actionable rendered all of it +// on one page, and slice 4 sent each section to the page that owns the work. + +const YT_SNAPSHOT_REL = + "test-transcripts/channels/test-youtube/snapshot.json"; +const YT_CONFIG_REL = "test-transcripts/channels/test-youtube/config.json"; +const TR_SNAPSHOT_REL = + "test-transcripts/channels/test-transcribe/snapshot.json"; + +async function writeJson(relPath: string, value: unknown): Promise<void> { + const full = resolvePath(relPath); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, JSON.stringify(value, null, 2) + "\n"); +} + +// The page-wide "Everything is up to date." state went with the page. Each +// section now says whether IT is empty, which is the thing an operator on one +// operation's page is asking. +test("a section with no work says so on its operation's page", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/operations/download"); + await expect(page.getByLabel("undownloaded empty")).toContainText( + "Nothing pending.", + ); +}); + +test("Actionable nav link is wired", async ({ page }) => { + await resetData("empty"); + await page.goto("/"); + await expect( + page.locator("aside").getByRole("link", { name: /^Actionable$/ }), + ).toHaveAttribute("href", "/actionable"); +}); + +test("populates the undownloaded section after a snapshot exists", async ({ + page, +}) => { + await resetData("youtube-with-playlist"); + // Visiting the channel detail page auto-generates the snapshot. + await generateReport(page, "test-youtube"); + await page.goto("/channels/test-youtube"); + await page.goto("/operations/download"); + + const section = page.getByRole("region", { + name: "undownloaded", + exact: true, + }); + await expect(section).toBeVisible(); + await expect(section.getByRole("heading", { name: /undownloaded videos/i })).toBeVisible(); + const row = section.getByLabel("undownloaded row test-youtube"); + await expect(row).toBeVisible(); + await expect(row).toContainText("4"); + await expect(row.getByRole("link", { name: "test-youtube" })).toHaveAttribute( + "href", + "/channels/test-youtube", + ); +}); + +test("populates the awaiting-transcription section", async ({ page }) => { + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); + await page.goto("/channels/test-transcribe"); + await page.goto("/operations/transcription"); + + const section = page.getByRole("region", { + name: "untranscribed", + exact: true, + }); + const row = section.getByLabel("untranscribed row test-transcribe"); + await expect(row).toBeVisible(); + // vidA, vidB, vidC all have audio but no transcript. + await expect(row).toContainText("3"); +}); + +test("flags channels whose report is older than the last sync", async ({ + page, +}) => { + await resetData("one-youtube-channel"); + // Backdate the snapshot relative to lastSyncedAt to force the stale check. + const cfg = await readJson<Record<string, unknown>>(YT_CONFIG_REL); + cfg.lastSyncedAt = "2026-05-21T12:00:00.000Z"; + await writeJson(YT_CONFIG_REL, cfg); + await writeJson(YT_SNAPSHOT_REL, { + generatedAt: "2026-05-20T12:00:00.000Z", + totals: { videos: 0, transcribed: 0, downloaded: 0 }, + buckets: { + noTranscript: [], + downloadedNoTranscript: [], + untranscoded: [], + multipleAudioFormats: [], + untranscribable: [], + noMetadata: [], + failedListed: [], + missingFromArchive: [], + duplicateDirs: [], + partialDownloads: [], + }, + undownloadedIds: [], + }); + + await page.goto("/actionable"); + const section = page.getByRole("region", { + name: "stale-reports", + exact: true, + }); + await expect(section).toBeVisible(); + await expect( + section.getByLabel("stale-reports row test-youtube"), + ).toContainText("stale"); +}); + +test("'Update all reports' queues a refresh-report job per channel", async ({ + page, +}) => { + await resetData("one-youtube-channel"); + await page.goto("/actionable"); + await page.getByRole("button", { name: "update all reports" }).click(); + await expect(page.getByLabel("update all reports result")).toContainText( + /Queued 1/, + ); + + await page.goto("/jobs"); + // refresh-report is the ONE kind /jobs hides by default + // (DEFAULT_HIDDEN_KINDS in app/jobs/jobsFilterStorage.ts), so un-hide it + // before asserting. Without this the test passes only by RACING ITS OWN + // HYDRATION: JobsTable renders every job while `filters` is still null + // (`if (!filters) return jobs`) and drops refresh-report as soon as + // localStorage loads. That race is won under `next dev` and lost under + // `next start`, which is why this failed only in the sharded route. + // The chip is always present: `kinds` is derived from ALL jobs, not the + // visible ones. Retried because a click before hydration fires nothing. + const kindChip = page.getByRole("button", { + name: "refresh-report", + exact: true, + }); + await expect(async () => { + await kindChip.click(); + await expect(jobRowByKind(page, "refresh-report").first()).toBeVisible({ + timeout: 2_000, + }); + }).toPass({ timeout: 20_000 }); +}); + +test("syncing a channel auto-regenerates its report", async ({ page }) => { + // one-transcribe-channel-with-audio uses an odysee URL — sync will fail + // (the URL is fake), but the report regen happens after runYtdlp regardless + // because we only swallow regen errors, not sync errors. To avoid relying + // on a working sync, drive the auto-regen by visiting the channel page + // which generates the snapshot if missing — and assert the snapshot file + // appears with a fresh timestamp. + // + // Then trigger an actual pipeline action that completes locally — + // Refresh report — and verify the snapshot's generatedAt advances. + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); + await page.goto("/channels/test-transcribe"); + + const first = await readJson<{ generatedAt: string }>(TR_SNAPSHOT_REL); + await new Promise((r) => setTimeout(r, 50)); // ensure a distinct ISO timestamp + await page.goto("/actionable"); + const refresh = page + .getByRole("button", { name: "refresh report test-transcribe" }) + .first(); + await refresh.waitFor({ state: "visible" }); + // Retried, and the timeout is generous: a click landing before React hydrates + // fires NOTHING — no request, no job, no error — which is the long-standing + // flake pattern in this suite. Regenerating a report is idempotent, so an + // extra click costs nothing and a missed one costs the whole test. + await expect + .poll( + async () => { + await refresh.click({ timeout: 5_000 }).catch(() => {}); + for (let i = 0; i < 20; i++) { + const cur = await readJson<{ generatedAt: string }>(TR_SNAPSHOT_REL); + if (cur.generatedAt > first.generatedAt) return true; + await new Promise((r) => setTimeout(r, 250)); + } + return false; + }, + { timeout: 60_000, intervals: [0, 0, 0] }, + ) + .toBe(true); +}); + +test("inline 'Download missing' queues a download-missing job", async ({ + page, +}) => { + await resetData("youtube-with-playlist"); + await generateReport(page, "test-youtube"); + await page.goto("/channels/test-youtube"); // auto-generate snapshot + await page.goto("/operations/download"); + await page + .getByRole("button", { name: "download missing test-youtube" }) + .click(); + await expect( + page.getByLabel("download missing test-youtube job"), + ).toBeVisible({ timeout: 10_000 }); + + await page.goto("/jobs"); + await expect(jobRowByKind(page, "download-missing").first()).toBeVisible({ + timeout: 10_000, + }); +}); + +test("inline 'Transcribe pending' queues a whisper-all job", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, "test-transcribe"); + await page.goto("/channels/test-transcribe"); // auto-generate snapshot + await page.goto("/operations/transcription"); + await page + .getByRole("button", { name: "transcribe pending test-transcribe" }) + .click(); + await expect( + page.getByLabel("transcribe pending test-transcribe job"), + ).toBeVisible({ timeout: 10_000 }); + + await page.goto("/jobs"); + await expect(jobRowByKind(page, "whisper-all").first()).toBeVisible({ + timeout: 10_000, + }); +}); + +// The cockpit redesign (c63f5cb) renamed this card from "Needs attention" to +// "Needs work" and changed its shape: it is now a <section> that always renders +// — listing channels when there is work and "Everything's handled." when there +// isn't — rather than a single link that disappeared when idle. The old +// "hides the card" assertion passed VACUOUSLY once the label changed (a count +// of an element nobody labels is always 0), so it is rewritten to assert the +// empty state it actually means. +test("dashboard surfaces the 'Needs work' card when work is pending", async ({ + page, +}) => { + await resetData("youtube-with-playlist"); + await generateReport(page, "test-youtube"); + await page.goto("/channels/test-youtube"); // auto-generate snapshot + await page.goto("/"); + // getByRole("region"), not getByLabel: the label match is a case-insensitive + // substring, so getByLabel("Needs work") also picks up each + // <li aria-label="needs work <slug>"> inside the card. + const card = page.getByRole("region", { name: "Needs work" }); + await expect(card).toBeVisible(); + await expect(card.getByLabel("needs work test-youtube")).toBeVisible(); + // The card's channel-count link is what navigates to the full list. + await expect(card.locator('a[href="/operations"]')).toBeVisible(); +}); + +test("dashboard's 'Needs work' card reads as empty when nothing is pending", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/"); + await expect(page.getByLabel("needs work empty")).toBeVisible(); +}); diff --git a/editor/e2e/download-format-guard.spec.ts b/editor/e2e/download-format-guard.spec.ts @@ -153,8 +153,8 @@ test("duration guard flags a source-truncated download and keeps the stub", asyn banner.getByRole("button", { name: /Re-download as Original/ }), ).toBeVisible(); - // Actionable: the channel is listed under truncated downloads. - await page.goto(`/actionable`); + // The download page: the channel is listed under truncated downloads. + await page.goto(`/operations/download`); const section = page.getByRole("region", { name: "short-audio", exact: true }); await expect(section).toBeVisible(); await expect(section.getByLabel(`short-audio row ${SLUG}`)).toBeVisible(); diff --git a/editor/e2e/incomplete-transcript.spec.ts b/editor/e2e/incomplete-transcript.spec.ts @@ -3,7 +3,8 @@ // minutes landed, so whisper transcribed only those). The editor flags any // non-livestream video >=10min whose transcript covers <50% of its runtime: // an "Incomplete transcript" list filter + amber glyph, a warning banner on the -// video page with a re-download button, and an /actionable section. Detection +// video page with a re-download button, and a transcription-page section. +// Detection // reads each video's transcript.cues.json (duration + cues). See // common/lib/transcriptCoverage.ts. @@ -77,7 +78,7 @@ async function seed() { }); } -test("incomplete-transcript filter, glyph, panel banner, and actionable", async ({ +test("incomplete-transcript filter, glyph, panel banner, and the transcription page", async ({ page, }) => { await seed(); @@ -124,7 +125,7 @@ test("incomplete-transcript filter, glyph, panel banner, and actionable", async await expect(page.getByLabel("incomplete transcript")).toBeHidden(); // --- Actionable view lists the channel under incomplete transcripts --- - await page.goto(`/actionable`); + await page.goto(`/operations/transcription`); const section = page.getByRole("region", { name: "incomplete-transcripts", exact: true, @@ -212,14 +213,14 @@ test("channel bulk bar: re-download & re-transcribe queues a batch fix", async ( await expect(page.getByLabel("bulk action error")).toBeHidden(); }); -test("actionable: section exposes per-channel + global fix buttons; per-channel re-download queues a job", async ({ +test("transcription page: section exposes per-channel + global fix buttons; per-channel re-download queues a job", async ({ page, }) => { await seed(); - // Visiting the channel materializes its snapshot so /actionable lists it. + // Visiting the channel materializes its snapshot so the section lists it. await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); - await page.goto(`/actionable`); + await page.goto(`/operations/transcription`); const section = page.getByRole("region", { name: "incomplete-transcripts", exact: true, @@ -247,14 +248,14 @@ test("actionable: section exposes per-channel + global fix buttons; per-channel ).toBeVisible(); }); -test("actionable: global clear-all clears every flagged video and empties the section", async ({ +test("transcription page: global clear-all clears every flagged video and empties the section", async ({ page, }) => { await seed(); - // Visiting the channel materializes its snapshot so /actionable lists it. + // Visiting the channel materializes its snapshot so the section lists it. await generateReport(page, CHANNEL); await page.goto(`/channels/${CHANNEL}`); - await page.goto(`/actionable`); + await page.goto(`/operations/transcription`); const section = page.getByRole("region", { name: "incomplete-transcripts", exact: true, @@ -272,12 +273,12 @@ test("actionable: global clear-all clears every flagged video and empties the se .poll(() => pathExists(`${DATA}/vidTrunc/transcript.cues.json`)) .toBe(false); - // The snapshot regenerates on a ~1s debounce after the clear; /actionable - // reads the persisted snapshot, so re-navigate until the section is empty. + // The snapshot regenerates on a ~1s debounce after the clear; the section + // reads the persisted snapshot, so re-navigate until it is empty. await expect .poll( async () => { - await page.goto(`/actionable`); + await page.goto(`/operations/transcription`); return page.getByLabel("incomplete-transcripts empty").count(); }, { timeout: 15000 },