Archilyzer · Source

archilyzer

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

commit 8e7d1c401aa11edc0f8fcd1d75cd6789fb92d47d
parent bfb05bd869ef3b14ad314c4fd4858cb14d61c725
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 11 Aug 2026 02:23:59 -0400

Merge feat/channel-cockpit: the channel page is a transit line, and the video browser has its own page

Diffstat:
Acommon/components/ui/collapsible.tsx | 34++++++++++++++++++++++++++++++++++
Acommon/components/ui/hover-card.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/ui/progress.tsx | 32++++++++++++++++++++++++++++++++
Acommon/components/virtualizer.ts | 13+++++++++++++
Mcommon/controller/channels.ts | 6+++++-
Meditor/CHANGELOG.md | 2++
Meditor/app/actionable/components/InlineActionButton.tsx | 19++++++++++++++++++-
Aeditor/app/channels/[slug]/components/ChannelTabs.tsx | 48++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/channels/[slug]/components/StageBadge.tsx | 44--------------------------------------------
Deditor/app/channels/[slug]/components/StageRailItem.tsx | 75---------------------------------------------------------------------------
Deditor/app/channels/[slug]/components/StagesView.tsx | 216-------------------------------------------------------------------------------
Deditor/app/channels/[slug]/components/StatusHeader.tsx | 90-------------------------------------------------------------------------------
Meditor/app/channels/[slug]/components/VideoListPane.tsx | 214++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Deditor/app/channels/[slug]/components/VideoListPaneSection.tsx | 143-------------------------------------------------------------------------------
Aeditor/app/channels/[slug]/components/flow/AttentionStrip.tsx | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/flow/ChannelLine.tsx | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/flow/FlowGap.tsx | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/flow/FlowStation.tsx | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/flow/NextAction.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/flow/OverviewPanel.tsx | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/flow/SidingList.tsx | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/flow/StageSwitcher.tsx | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/flow/tone.ts | 41+++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/layout.tsx | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/lib/channelConfigCache.ts | 15+++++++++++++++
Aeditor/app/channels/[slug]/lib/channelFlow.test.ts | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/lib/channelFlow.ts | 503+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/lib/stageStatus.ts | 25++++++++++++++++++++++---
Meditor/app/channels/[slug]/page.tsx | 793++++++++++++++++++++++++++++++++++---------------------------------------------
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 5++++-
Aeditor/app/channels/[slug]/videos/components/VideoWorkspace.tsx | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/page.tsx | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/attribution.spec.ts | 14++++++++------
Meditor/e2e/audio-check-scenarios.spec.ts | 11+++++++++--
Meditor/e2e/auto-queue.spec.ts | 11+++++++----
Meditor/e2e/auto-subs-replace.spec.ts | 19+++++++++++--------
Meditor/e2e/availability-backfill.spec.ts | 13++++++++++---
Meditor/e2e/availability.spec.ts | 19+++++++++++--------
Meditor/e2e/backfill.spec.ts | 41++++++++++++++++-------------------------
Meditor/e2e/bulk-actions.spec.ts | 28++++++++++++++++++----------
Meditor/e2e/cadence-ui.spec.ts | 9+++++----
Meditor/e2e/cancel.spec.ts | 11++++++++---
Meditor/e2e/channel-embedded-video.spec.ts | 77+++++++++++++++++++++++++++++++++++------------------------------------------
Aeditor/e2e/channel-line.spec.ts | 285+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/channel-rename.spec.ts | 9+++++++--
Meditor/e2e/channel-site-membership.spec.ts | 14++++++++++----
Aeditor/e2e/channel-stage-selection.spec.ts | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/channels-actions.spec.ts | 8++++++--
Meditor/e2e/channels-counts.spec.ts | 10+++++++---
Meditor/e2e/channels.spec.ts | 14++++++++++----
Meditor/e2e/chough.spec.ts | 13++++++++++---
Meditor/e2e/cookies-mode.spec.ts | 15+++++++++------
Meditor/e2e/diarization.spec.ts | 22+++++++++-------------
Meditor/e2e/digest.spec.ts | 17++++++++++-------
Meditor/e2e/disk-space.spec.ts | 8+++++---
Meditor/e2e/do-not-clean.spec.ts | 16+++++++++++-----
Meditor/e2e/download-format-guard.spec.ts | 12++++++++----
Meditor/e2e/download-part-files.spec.ts | 9+++++++--
Meditor/e2e/exclude-from-counts.spec.ts | 11++++++++---
Meditor/e2e/helpers.ts | 41+++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/import-video.spec.ts | 12+++++++++---
Meditor/e2e/incomplete-transcript.spec.ts | 19+++++++++++++------
Meditor/e2e/job-stream-cancel.spec.ts | 8++++++--
Meditor/e2e/jobs-active-order.spec.ts | 10+++++++---
Meditor/e2e/jobs-batch-tasks-drain.spec.ts | 26++++++++++++++++----------
Meditor/e2e/jobs-channel.spec.ts | 10+++++++---
Meditor/e2e/jobs-reorder.spec.ts | 9+++++++--
Meditor/e2e/jobs-retry.spec.ts | 12++++++++++--
Meditor/e2e/maybe-missing.spec.ts | 14++++++++++----
Meditor/e2e/no-orphan-fixtures.spec.ts | 10++++++++--
Meditor/e2e/no-subs-fallback.spec.ts | 17++++++++++++-----
Meditor/e2e/parakeet-partial.spec.ts | 22+++++++++++++++-------
Meditor/e2e/parakeet.spec.ts | 13++++++++++---
Meditor/e2e/partial-downloads-bucket.spec.ts | 14++++++++++----
Meditor/e2e/pipeline.spec.ts | 27+++++++++++++++++++++------
Meditor/e2e/pre-clean-availability.spec.ts | 8+++++---
Meditor/e2e/queues.spec.ts | 30+++++++++++++++++++-----------
Meditor/e2e/retry-bucket.spec.ts | 14++++++++++----
Meditor/e2e/saved-videos.spec.ts | 11++++++++---
Meditor/e2e/shard.spec.ts | 27+++++++++++++++++----------
Meditor/e2e/skip-live.spec.ts | 11+++++++----
Meditor/e2e/sync-break-on-existing.spec.ts | 9+++++++--
Meditor/e2e/sync-deep.spec.ts | 7++++---
Meditor/e2e/transcript-source.spec.ts | 10++++++++--
Meditor/e2e/undownloaded.spec.ts | 23+++++++++++++++--------
Meditor/e2e/video-filter-combine.spec.ts | 9+++++++--
Meditor/e2e/video-page.spec.ts | 31++++++++++++++++++++-----------
Meditor/e2e/whisper.spec.ts | 40+++++++++++++++++++++++++++-------------
Meditor/e2e/worker-remote.spec.ts | 13++++++++++---
Meditor/e2e/workers.spec.ts | 14+++++++++++---
90 files changed, 3871 insertions(+), 1429 deletions(-)

diff --git a/common/components/ui/collapsible.tsx b/common/components/ui/collapsible.tsx @@ -0,0 +1,34 @@ +"use client" + +import * as React from "react" +import { Collapsible as CollapsiblePrimitive } from "radix-ui" + +function Collapsible({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { + return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.Trigger>) { + return ( + <CollapsiblePrimitive.Trigger + data-slot="collapsible-trigger" + {...props} + /> + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.Content>) { + return ( + <CollapsiblePrimitive.Content + data-slot="collapsible-content" + {...props} + /> + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/common/components/ui/hover-card.tsx b/common/components/ui/hover-card.tsx @@ -0,0 +1,44 @@ +"use client" + +import * as React from "react" +import { HoverCard as HoverCardPrimitive } from "radix-ui" + +import { cn } from "../../lib/utils" + +function HoverCard({ + ...props +}: React.ComponentProps<typeof HoverCardPrimitive.Root>) { + return <HoverCardPrimitive.Root data-slot="hover-card" {...props} /> +} + +function HoverCardTrigger({ + ...props +}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) { + return ( + <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} /> + ) +} + +function HoverCardContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps<typeof HoverCardPrimitive.Content>) { + return ( + <HoverCardPrimitive.Portal data-slot="hover-card-portal"> + <HoverCardPrimitive.Content + data-slot="hover-card-content" + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-64 origin-(--radix-hover-card-content-transform-origin) animate-in rounded-md border border-border bg-popover p-3 text-popover-foreground shadow-md outline-hidden fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", + className + )} + {...props} + /> + </HoverCardPrimitive.Portal> + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/common/components/ui/progress.tsx b/common/components/ui/progress.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "../../lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps<typeof ProgressPrimitive.Root>) { + return ( + <ProgressPrimitive.Root + data-slot="progress" + className={cn( + "relative h-1.5 w-full overflow-hidden rounded-full bg-muted", + className + )} + value={value} + {...props} + > + <ProgressPrimitive.Indicator + data-slot="progress-indicator" + className="h-full w-full flex-1 bg-primary transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + /> + </ProgressPrimitive.Root> + ) +} + +export { Progress } diff --git a/common/components/virtualizer.ts b/common/components/virtualizer.ts @@ -0,0 +1,13 @@ +// @tanstack/react-virtual, re-exported from the package that owns it. +// +// The dependency lives in `common` (beside VirtualRow, the row wrapper every +// virtualized list here shares) and pnpm's strict node_modules means `editor` +// cannot reach it directly. Re-exporting keeps ONE copy of the virtualizer in +// the tree — two would each hold their own scroll observers over the same +// elements — and keeps the dependency declared where the components that need +// it live, instead of duplicating it into every app that renders a long list. +export { + useVirtualizer, + useWindowVirtualizer, + type VirtualItem, +} from "@tanstack/react-virtual"; diff --git a/common/controller/channels.ts b/common/controller/channels.ts @@ -106,7 +106,11 @@ async function countDataFiles(dataDir: string): Promise<{ return { videos: videoDirs.length, transcripts, downloads, digests }; } -async function countPlaylist(p: string): Promise<number | null> { +// How many videos the channel's `playlist` file names, or null when there is no +// such file. ONE small readFile — cheap enough for a render path (the channel +// line's first denominator), which is why it is exported rather than folded into +// the corpus walk that noCorpusWalkInRenderPaths.test.ts bans. +export async function countPlaylist(p: string): Promise<number | null> { let raw: string; try { raw = await readFile(p, "utf8"); diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- **A channel page now opens on the channel's whole lifecycle instead of a viewport of video rows.** The first screen is a single transit line — Playlist → Download → Transcode → Transcribe → Digest → Backfill — where each station carries its count and its coverage, and the gap between two stations carries the shortfall, because the gap *is* the work. Only the largest shortfall is emphasised, so the bottleneck is the thing your eye lands on. Work the lane can actually do today sits on the line; everything that left it (needs cookies, deleted, untranscribable, waiting on a transcript, needs media re-acquired) hangs below it in muted type, on a different axis, so the two can never be read as one number. Below the line: one brand-coloured **next action** that disappears when there is nothing to do, a strip of anomaly chips that says "All clear" rather than vanishing, and the stage panels — **one at a time**, chosen by a tab strip and carried in the URL as `?stage=`, so a stage is a link you can share. With no stage selected you get an overview listing every stage's status at once. Previously the page stacked a full-height video browser above eight simultaneously-expanded stage panels, which put the channel's actual pipeline a full screen and a collapsed `<details>` out of reach. +- **The video browser has moved to its own page and no longer builds a DOM node per video.** `/channels/<slug>/videos` is a two-pane workspace: a virtualized list on the left, the selected video's detail on the right, both always visible. The largest channel has ~11,000 videos and the old list rendered every one of them into the page; it now renders the twenty or so you can see. Filters, search and `?video=` selection work exactly as before. The old collapse toggles are gone — they existed only because the pane was squeezed onto a page it shared with everything else, and a page of its own removes the reason for them. - **Command bookmarks are gone.** Saving a job under a label and re-launching it from the strip above **Jobs** / **Active jobs**, and the `/jobs/bookmarks` page that managed those labels, have all been removed. The feature was used for three days after it shipped and never again: every job it could re-launch is still one click away from its own channel-page control, and **Retry** — which re-runs a finished job straight from the descriptor stored with it — covers re-running something you have already run. Retry and **Retry all failed** are untouched and keep working exactly as before, including on jobs old enough to have been evicted from memory. Bucket jobs still re-derive their work from the channel's current state on every re-run rather than replaying a stale list. Your existing `transcripts/.bookmarks/bookmarks.json` is left on disk as a record; nothing reads it any more, and you can delete it whenever you like. - **The monitor widget lays out on a real grid now: rows, full-width sections, and a rail of totals.** The board could only ever split the widget into columns of stacked strips, so three things people kept asking for were impossible: a Controls row across the whole top, four one-line totals reading as one instrument cluster instead of four separate boxes, and a section that takes the leftover height. The floorplan now has rows as well as columns — drag the edge of a cell to make it span two tracks, and flip a cell to a **rail** to put its strips on one line with shortened labels (`synced 3m`, `412 GB free`, `1.2k backfill`). Every widget link written before this keeps working and still copies out in exactly the same short form; only an arrangement that genuinely needs rows writes the newer parameter. - **A row can now take the leftover height, and the sections in it scroll under their own headings.** The rail down the left of the board sets each row to *content height* (what every row was before) or *fill*. A widget with a fill row becomes exactly as tall as the window or iframe it is in, and the sections sharing that row each get half of it and scroll inside — with the section title pinned at the top so you always know what you are looking at. Those lists also drop the six-row cap while they are scrollable, because a list you can scroll that still says "+14 more channels" is not much of a list. diff --git a/editor/app/actionable/components/InlineActionButton.tsx b/editor/app/actionable/components/InlineActionButton.tsx @@ -15,6 +15,7 @@ import { redownloadShortAudioBucketAction, } from "../../channels/[slug]/incompleteTranscriptActions"; import { backfillChannelAction } from "../../channels/[slug]/backfillActions"; +import { digestChannelAction } from "../../channels/[slug]/digestActions"; import { refreshChannelSnapshotAction } from "../../channels/actions"; type Variant = @@ -26,6 +27,9 @@ type Variant = | { kind: "cleanTranscribedAudio"; slug: string } | { kind: "cleanExtraFormats"; slug: string } | { kind: "backfillChannel"; slug: string } + // The digest lane's LOCAL half only. The metered lane is behind a settings + // gate and a spend cap, so it is never what a one-click "next action" starts. + | { kind: "digestChannel"; slug: string } | { kind: "refreshReport"; slug: string }; type Status = @@ -44,6 +48,7 @@ const LABEL: Record<Variant["kind"], { idle: string; running: string }> = { cleanTranscribedAudio: { idle: "Clean audio", running: "Queuing…" }, cleanExtraFormats: { idle: "Clean extra formats", running: "Queuing…" }, backfillChannel: { idle: "Backfill", running: "Queuing…" }, + digestChannel: { idle: "Digest channel", running: "Queuing…" }, refreshReport: { idle: "Refresh report", running: "Refreshing…" }, }; @@ -86,6 +91,9 @@ async function runAction(variant: Variant): Promise<StreamActionResult> { if (variant.kind === "backfillChannel") { return backfillChannelAction(variant.slug); } + if (variant.kind === "digestChannel") { + return digestChannelAction(variant.slug, "local"); + } // refreshReport: returns an ActionResult, not a StreamActionResult — adapt. const result = await refreshChannelSnapshotAction(variant.slug); if (result && "error" in result) { @@ -102,8 +110,13 @@ async function runAction(variant: Variant): Promise<StreamActionResult> { export function InlineActionButton({ variant, + // The channel page's single "next action" control asks for `brand`. Brand + // appears EXACTLY ONCE per page — that restraint is what pays for the channel + // line's boldness — so this is a prop rather than a new default. + tone = "default", }: { variant: Variant; + tone?: "default" | "brand"; }) { const [status, setStatus] = useState<Status>({ kind: "idle" }); const [pending, startTransition] = useTransition(); @@ -155,7 +168,11 @@ export function InlineActionButton({ onClick={handleClick} disabled={running} aria-label={ariaLabel} - className="px-2 py-1 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 disabled:opacity-50 whitespace-nowrap" + className={`rounded-md font-medium hover:opacity-90 disabled:opacity-50 whitespace-nowrap ${ + tone === "brand" + ? "px-3 py-1.5 bg-brand text-brand-ink text-sm" + : "px-2 py-1 bg-primary text-primary-foreground text-xs" + }`} > {running ? labels.running : labels.idle} </button> diff --git a/editor/app/channels/[slug]/components/ChannelTabs.tsx b/editor/app/channels/[slug]/components/ChannelTabs.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Link from "next/link"; +import { useSelectedLayoutSegment } from "next/navigation"; + +// Overview | Videos. +// +// A CLIENT component on purpose. Next 16 layouts are cached during navigation +// and DO NOT RERENDER — they cannot read searchParams and would not re-evaluate +// an active-tab check on a client-side move between the two routes. +// useSelectedLayoutSegment is the supported way to ask "which child segment is +// rendered", and it re-renders because this component does. + +export function ChannelTabs({ slug }: { slug: string }) { + const segment = useSelectedLayoutSegment(); + const onVideos = segment === "videos"; + return ( + <nav aria-label="channel sections" className="flex items-center gap-1"> + <Tab href={`/channels/${slug}`} active={!onVideos} label="Overview" /> + <Tab href={`/channels/${slug}/videos`} active={onVideos} label="Videos" /> + </nav> + ); +} + +function Tab({ + href, + active, + label, +}: { + href: string; + active: boolean; + label: string; +}) { + return ( + <Link + href={href} + aria-current={active ? "page" : undefined} + aria-label={`${label} tab`} + className={`rounded-md px-3 py-1 text-sm ${ + active + ? "bg-muted text-foreground font-medium" + : "text-muted-foreground hover:bg-muted hover:text-foreground" + }`} + > + {label} + </Link> + ); +} diff --git a/editor/app/channels/[slug]/components/StageBadge.tsx b/editor/app/channels/[slug]/components/StageBadge.tsx @@ -1,44 +0,0 @@ -import type { StageStatus, StageTone } from "../lib/stageStatus"; - -// Semantic, token-driven tones so stage badges recolor with every theme family. -const TONE_CLASS: Record<StageTone, string> = { - neutral: - "border-border bg-card text-muted-foreground hover:border-muted-foreground/50", - attention: - "border-warning/30 bg-warning-soft text-warning hover:border-warning/60", - danger: - "border-destructive/30 bg-destructive-soft text-destructive hover:border-destructive/60", - running: "border-info/30 bg-info-soft text-info hover:border-info/60", - ok: "border-success/30 bg-success-soft text-success hover:border-success/60", -}; - -export function StageBadge({ status }: { status: StageStatus }) { - const countLabel = formatCount(status); - return ( - <a - href={`#stage-${status.id}`} - aria-label={`Jump to ${status.title} stage`} - className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${TONE_CLASS[status.tone]}`} - > - {status.running && ( - <span - aria-hidden="true" - className="inline-block w-2 h-2 rounded-full bg-info animate-pulse" - /> - )} - <span>{status.title}</span> - {countLabel && ( - <span className="font-mono opacity-80">{countLabel}</span> - )} - </a> - ); -} - -function formatCount(s: StageStatus): string | null { - if (s.running) return "running"; - const parts: string[] = []; - if (s.pending > 0) parts.push(String(s.pending)); - if (s.failed > 0) parts.push(`${s.failed} failed`); - if (parts.length === 0) return null; - return parts.join(" · "); -} diff --git a/editor/app/channels/[slug]/components/StageRailItem.tsx b/editor/app/channels/[slug]/components/StageRailItem.tsx @@ -1,75 +0,0 @@ -import type { StageStatus, StageTone } from "../lib/stageStatus"; - -const TONE_ACCENT: Record<StageTone, string> = { - neutral: "text-muted-foreground", - attention: "text-warning", - danger: "text-destructive", - running: "text-info", - ok: "text-success", -}; - -const TONE_ACTIVE_BG: Record<StageTone, string> = { - neutral: "bg-muted", - attention: "bg-warning-soft", - danger: "bg-destructive-soft", - running: "bg-info-soft", - ok: "bg-success-soft", -}; - -const TONE_ACTIVE_BORDER: Record<StageTone, string> = { - neutral: "border-border", - attention: "border-warning", - danger: "border-destructive", - running: "border-info", - ok: "border-success", -}; - -type Props = { - status: StageStatus; - active: boolean; - href: string; - onSelect?: () => void; -}; - -export function StageRailItem({ status, active, href, onSelect }: Props) { - const bg = active ? TONE_ACTIVE_BG[status.tone] : "hover:bg-muted"; - const border = active - ? `border-l-2 ${TONE_ACTIVE_BORDER[status.tone]}` - : "border-l-2 border-transparent"; - return ( - <a - href={href} - aria-current={active ? "page" : undefined} - onClick={onSelect} - className={`flex items-center gap-2 pl-3 pr-2 py-2 rounded-r-md text-sm ${bg} ${border}`} - > - {status.running && ( - <span - aria-hidden="true" - className="inline-block w-2 h-2 rounded-full bg-info animate-pulse shrink-0" - /> - )} - <span className={`truncate ${active ? "font-semibold" : ""} ${TONE_ACCENT[status.tone]}`}> - {status.title} - </span> - <span className="ml-auto flex items-center gap-1"> - {status.pending > 0 && ( - <span - aria-label={`${status.title} pending count`} - className="font-mono text-xs px-1.5 rounded bg-warning-soft text-warning" - > - {status.pending} - </span> - )} - {status.failed > 0 && ( - <span - aria-label={`${status.title} failed count`} - className="font-mono text-xs px-1.5 rounded bg-destructive-soft text-destructive" - > - {status.failed} - </span> - )} - </span> - </a> - ); -} diff --git a/editor/app/channels/[slug]/components/StagesView.tsx b/editor/app/channels/[slug]/components/StagesView.tsx @@ -1,216 +0,0 @@ -"use client"; - -import { - useCallback, - useEffect, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; -import type { StageId, StageStatus, StageTone } from "../lib/stageStatus"; -import { StageRailItem } from "./StageRailItem"; - -const TONE_BORDER: Record<StageTone, string> = { - neutral: "border-border", - attention: "border-warning/30", - danger: "border-destructive/30", - running: "border-info/30", - ok: "border-success/30", -}; - -const TONE_BADGE: Record<StageTone, string> = { - neutral: - "bg-muted text-muted-foreground border-border", - attention: - "bg-warning-soft text-warning border-warning/30", - danger: - "bg-destructive-soft text-destructive border-destructive/30", - running: - "bg-info-soft text-info border-info/30", - ok: "bg-success-soft text-success border-success/30", -}; - -const LG_QUERY = "(min-width: 1024px)"; - -function subscribeHash(callback: () => void) { - window.addEventListener("hashchange", callback); - return () => window.removeEventListener("hashchange", callback); -} - -function getHash(): string { - if (typeof window === "undefined") return ""; - return window.location.hash; -} - -function getServerHash(): string { - return ""; -} - -type Props = { - stages: Record<StageId, StageStatus>; - stageOrder: StageId[]; - panels: Partial<Record<StageId, ReactNode>>; -}; - -export function StagesView({ stages, stageOrder, panels }: Props) { - const hash = useSyncExternalStore(subscribeHash, getHash, getServerHash); - const defaultStage = - stageOrder.find((id) => stages[id].defaultOpen) ?? stageOrder[0]; - const hashMatch = /^#stage-(.+)$/.exec(hash); - const fromHash = hashMatch - ? (stageOrder.find((id) => id === hashMatch[1]) ?? null) - : null; - const selected = fromHash ?? defaultStage; - - const [openIds, setOpenIds] = useState<Set<StageId>>( - () => new Set(stageOrder.filter((id) => stages[id].defaultOpen)), - ); - - // Mobile: when a hash anchor (e.g. a StatusHeader badge) navigates to a - // stage, also expand it so the section is not just scrolled into view as a - // collapsed header. - useEffect(() => { - if (!fromHash) return; - setOpenIds((prev) => { - if (prev.has(fromHash)) return prev; - const next = new Set(prev); - next.add(fromHash); - return next; - }); - if (typeof document === "undefined") return; - const section = document.getElementById(`stage-${fromHash}`); - if (!section) return; - // Open the ancestor "Channel pipeline & settings" details so a badge - // click doesn't silently land on a collapsed section. - const parent = section.closest("details"); - if (parent && !parent.open) parent.open = true; - // The parent details (and on mobile, the section's own collapsible body) - // may have just expanded — re-scroll after the next paint so the anchor - // lands at the corrected position. - requestAnimationFrame(() => { - section.scrollIntoView({ block: "start", behavior: "auto" }); - }); - }, [fromHash]); - - const toggle = useCallback((id: StageId) => { - // On lg+ the section header isn't a toggle (content is always shown when - // the section is the selected one). Bail so a click doesn't desync the - // mobile open-state behind the user's back. - if ( - typeof window !== "undefined" && - window.matchMedia(LG_QUERY).matches - ) { - return; - } - setOpenIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }, []); - - return ( - <div className="lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-6"> - <nav - aria-label="Pipeline stages" - className="hidden lg:flex lg:flex-col lg:gap-0.5 lg:sticky lg:self-start lg:top-28" - > - {stageOrder.map((id) => ( - <StageRailItem - key={id} - status={stages[id]} - active={id === selected} - href={`#stage-${id}`} - /> - ))} - </nav> - <div className="flex flex-col gap-4 min-w-0"> - {stageOrder.map((id) => { - const status = stages[id]; - const isOpen = openIds.has(id); - const isDanger = id === "danger"; - const contentId = `stage-${id}-content`; - return ( - <section - key={id} - id={`stage-${id}`} - aria-label={`${status.title} stage`} - className={`rounded-md border ${TONE_BORDER[status.tone]} bg-card scroll-mt-44 lg:scroll-mt-28`} - > - <button - type="button" - onClick={() => toggle(id)} - aria-expanded={isOpen} - aria-controls={contentId} - aria-label={`${status.title} stage summary`} - className="w-full flex items-center justify-between gap-3 px-4 py-3 text-left rounded-t-md hover:bg-muted lg:hover:bg-transparent lg:dark:hover:bg-transparent lg:cursor-default" - > - <div className="flex items-baseline gap-3 min-w-0"> - <h2 - className={`text-lg font-semibold ${isDanger ? "text-destructive" : ""}`} - > - {status.title} - </h2> - <span className="text-sm text-muted-foreground truncate"> - {status.summary} - </span> - </div> - <div className="flex items-center gap-2 flex-shrink-0"> - {status.running && ( - <span - className={`text-xs uppercase tracking-wide rounded-full border px-2 py-0.5 ${TONE_BADGE.running}`} - aria-label={`${status.title} running`} - > - running - </span> - )} - {status.pending > 0 && ( - <span - className={`text-xs rounded-full border px-2 py-0.5 ${TONE_BADGE.attention}`} - aria-label={`${status.title} pending count`} - > - {status.pending} - </span> - )} - {status.failed > 0 && ( - <span - className={`text-xs rounded-full border px-2 py-0.5 ${TONE_BADGE.danger}`} - aria-label={`${status.title} failed count`} - > - {status.failed} failed - </span> - )} - <Chevron isOpen={isOpen} /> - </div> - </button> - <div - id={contentId} - className={`px-4 pb-4 pt-1 lg:py-4 flex-col gap-4 border-t border-border ${isOpen ? "flex" : "hidden"} lg:flex`} - > - {panels[id]} - </div> - </section> - ); - })} - </div> - </div> - ); -} - -function Chevron({ isOpen }: { isOpen: boolean }) { - return ( - <svg - aria-hidden="true" - className={`w-4 h-4 text-muted-foreground transition-transform lg:hidden ${isOpen ? "rotate-90" : ""}`} - viewBox="0 0 20 20" - fill="currentColor" - > - <path - fillRule="evenodd" - d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 0 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02z" - clipRule="evenodd" - /> - </svg> - ); -} diff --git a/editor/app/channels/[slug]/components/StatusHeader.tsx b/editor/app/channels/[slug]/components/StatusHeader.tsx @@ -1,90 +0,0 @@ -import Link from "next/link"; -import type { StageId, StageStatus } from "../lib/stageStatus"; -import { RefreshSnapshotButton } from "./RefreshSnapshotButton"; -import { StageBadge } from "./StageBadge"; - -const BADGE_ORDER: StageId[] = [ - "playlist", - "download", - "transcode", - "transcribe", - "digest", - "cleanup", - "diagnostics", -]; - -type Props = { - slug: string; - channelName: string; - generatedAt: string; - fresh: boolean; - stages: Record<StageId, StageStatus>; - transcodeApplies: boolean; -}; - -export function StatusHeader({ - slug, - channelName, - generatedAt, - fresh, - stages, - transcodeApplies, -}: Props) { - const badgeIds = BADGE_ORDER.filter( - (id) => transcodeApplies || id !== "transcode", - ); - return ( - <header - aria-label="channel status header" - className="sticky top-0 z-10 -mx-4 md:-mx-6 px-4 md:px-6 py-3 bg-card/95 backdrop-blur border-b border-border" - > - <div className="flex flex-col gap-3"> - <div className="flex items-start justify-between gap-3 flex-wrap"> - <div className="flex flex-col gap-1 min-w-0"> - <nav className="flex items-center gap-2 text-sm text-muted-foreground"> - <Link - href="/channels" - className="underline hover:text-foreground" - > - Channels - </Link> - <span aria-hidden="true">/</span> - <span className="font-mono">{slug}</span> - </nav> - <h1 className="text-2xl font-semibold truncate">{channelName}</h1> - <p className="text-xs text-muted-foreground"> - {fresh ? ( - "Report generated just now." - ) : ( - <> - Report generated{" "} - <time dateTime={generatedAt}> - {formatTimestamp(generatedAt)} - </time> - . - </> - )} - </p> - </div> - <RefreshSnapshotButton slug={slug} /> - </div> - <div - aria-label="pipeline stage badges" - className="flex flex-wrap gap-2 lg:hidden" - > - {badgeIds.map((id) => ( - <StageBadge key={id} status={stages[id]} /> - ))} - </div> - </div> - </header> - ); -} - -function formatTimestamp(iso: string): string { - try { - return new Date(iso).toLocaleString(); - } catch { - return iso; - } -} diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx @@ -1,8 +1,10 @@ "use client"; import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useMemo, useRef, useState, useTransition } from "react"; +import { useVirtualizer } from "yt-dlp-transcript-common/components/virtualizer"; +import { VirtualRow } from "yt-dlp-transcript-common/components/VirtualRow"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { VideoRow, VideoFilter } from "../lib/videoRows"; import { filterRows, serializeFilters } from "../lib/videoRows"; @@ -75,19 +77,17 @@ const FILTER_OPTIONS: { value: VideoFilter; label: string }[] = [ { value: "transcribed", label: "Transcribed" }, ]; -function buildHref( - slug: string, - base: URLSearchParams, - updates: Record<string, string | null>, -): string { - const params = new URLSearchParams(base); - for (const [k, v] of Object.entries(updates)) { - if (v === null) params.delete(k); - else params.set(k, v); - } - const q = params.toString(); - return q ? `/channels/${slug}?${q}` : `/channels/${slug}`; -} +// One row is a checkbox, three status dots and a monospace id on one line. The +// virtualizer still measures each mounted row (ResizeObserver), so this is only +// the first-paint guess and a wrapped id costs nothing but a reflow. +const ROW_ESTIMATE_PX = 30; + +// The viewport the virtualizer assumes for its FIRST render, before the scroll +// element has been measured. Roughly the pane's real height on a laptop — the +// exact number only decides how many rows the first paint contains, and the +// ResizeObserver corrects it immediately afterwards. +const INITIAL_LIST_HEIGHT_PX = 900; +const INITIAL_LIST_WIDTH_PX = 384; export function VideoListPane({ slug, @@ -100,7 +100,6 @@ export function VideoListPane({ existingQueues, }: Props) { const router = useRouter(); - const searchParams = useSearchParams(); const [filters, setFilters] = useState<Set<VideoFilter>>( () => new Set(initialFilters), ); @@ -125,6 +124,29 @@ export function VideoListPane({ return filtered.filter((r) => r.id.toLowerCase().includes(q)); }, [rows, filters, query]); + // VIRTUALIZED. The largest channel has ~11,000 videos, and one <li> + <Link> + // each is ~11,000 DOM nodes for a pane that shows twenty of them. The scroll + // container is this list, not the window, so this is the element virtualizer + // (the corpus search results use the window one — same VirtualRow helper, + // different scroller). + const listRef = useRef<HTMLDivElement>(null); + const rowVirtualizer = useVirtualizer({ + count: visibleRows.length, + getScrollElement: () => listRef.current, + estimateSize: () => ROW_ESTIMATE_PX, + overscan: 12, + // WITHOUT THIS THE FIRST PAINT IS EMPTY. The virtualizer derives its window + // from the scroll element's measured rect, and before the ResizeObserver + // fires that rect is 0×0 — so getVirtualItems() returns nothing and the list + // renders no rows at all until a frame later. That is invisible to a human + // and lethal to anything that reads the DOM immediately after navigation: + // it turns "assert this row is present" into a silent zero. Seeding a + // plausible viewport makes the first render produce a full window, which + // measurement then corrects. + initialRect: { width: INITIAL_LIST_WIDTH_PX, height: INITIAL_LIST_HEIGHT_PX }, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + const allSelected = visibleRows.length > 0 && visibleRows.every((r) => selected.has(r.id)); const someSelected = selected.size > 0; @@ -151,16 +173,18 @@ export function VideoListPane({ }); } - // Filtering is entirely client-side, so we only need the URL to mirror the - // active filters for reload/share — not a navigation. Use history.replaceState - // (Next's useSearchParams still reflects it) instead of router.replace: it - // avoids an RSC refetch per chip toggle and, crucially, won't be dropped when - // it races the global AutoRefresh router.refresh() transition. - function syncFiltersToUrl(next: Set<VideoFilter>) { + // Filtering and search are entirely client-side, so the URL only has to + // MIRROR them for reload/share — it is not a navigation. history.replaceState + // (which Next's useSearchParams still reflects) rather than router.replace: + // that avoids an RSC refetch per keystroke and, crucially, won't be dropped + // when it races the global AutoRefresh router.refresh() transition. + function syncUrl(nextFilters: Set<VideoFilter>, nextQuery: string) { const url = new URL(window.location.href); - const serialized = serializeFilters(next); + const serialized = serializeFilters(nextFilters); if (serialized) url.searchParams.set("filter", serialized); else url.searchParams.delete("filter"); + if (nextQuery.trim()) url.searchParams.set("q", nextQuery); + else url.searchParams.delete("q"); window.history.replaceState(null, "", url.toString()); } @@ -170,28 +194,31 @@ export function VideoListPane({ if (next.has(value)) next.delete(value); else next.add(value); setFilters(next); - syncFiltersToUrl(next); + syncUrl(next, query); } // The "All" chip clears every active filter. function clearFilters() { const next = new Set<VideoFilter>(); setFilters(next); - syncFiltersToUrl(next); + syncUrl(next, query); } function changeQuery(value: string) { setQuery(value); - const params = new URLSearchParams(searchParams.toString()); - if (value.trim()) params.set("q", value); - else params.delete("q"); - const q = params.toString(); - router.replace(q ? `/channels/${slug}?${q}` : `/channels/${slug}`, { - scroll: false, - }); + syncUrl(filters, value); } - const baseParams = new URLSearchParams(searchParams.toString()); + // Built from local state rather than useSearchParams: the state is what the + // list is actually showing, and after a replaceState the two agree anyway. + function buildHref(videoId: string): string { + const params = new URLSearchParams(); + const serialized = serializeFilters(filters); + if (serialized) params.set("filter", serialized); + if (query.trim()) params.set("q", query); + params.set("video", videoId); + return `/channels/${slug}/videos?${params.toString()}`; + } // Transcribe/retry submit ONE managed batch job; we don't consume its stream // (the job runs to completion regardless) and just refresh so it appears in @@ -379,7 +406,7 @@ export function VideoListPane({ } return ( - <div className="flex flex-col gap-2 min-h-0"> + <div className="flex flex-col gap-2 min-h-0 flex-1"> <div className="flex flex-col gap-2"> <input type="search" @@ -484,58 +511,77 @@ export function VideoListPane({ </div> </div> - <ul - aria-label="videos" - className="flex-1 min-h-0 overflow-y-auto flex flex-col divide-y divide-border border border-border rounded" + {/* The scroll container is unlabelled and the <ul> carries the name. + That split is not cosmetic: an accessible name only attaches to an + element whose role permits one, and a bare <div> is role=generic, + which prohibits it — so `getByLabel("videos")` on the div matches + NOTHING and every assertion scoped to it passes vacuously. A <ul> is + role=list, which is nameable. It is also always rendered, empty state + included, so the locator resolves whether or not the filter matched. */} + <div + ref={listRef} + className="flex-1 min-h-0 overflow-y-auto border border-border rounded" > - {visibleRows.length === 0 ? ( - <li className="px-3 py-4 text-sm text-muted-foreground"> - No videos match this filter. - </li> - ) : ( - visibleRows.map((row) => { - const isActive = row.id === selectedVideoId; - const isSelected = selected.has(row.id); - const href = buildHref(slug, baseParams, { video: row.id }); - return ( - <li - key={row.id} - className={`flex items-center gap-2 px-2 py-1.5 text-sm ${ - isActive - ? "bg-info-soft" - : "hover:bg-muted" - }`} - > - <input - type="checkbox" - checked={isSelected} - onChange={() => toggleOne(row.id)} - aria-label={`select ${row.id}`} - className="flex-shrink-0" - /> - <Link - href={href} - scroll={false} - className="flex-1 min-w-0 flex items-center gap-2" - aria-label={`open ${row.id}`} - aria-current={isActive ? "page" : undefined} + <ul + aria-label="videos" + className="relative" + style={ + visibleRows.length === 0 + ? undefined + : { height: rowVirtualizer.getTotalSize() } + } + > + {visibleRows.length === 0 ? ( + <li className="px-3 py-4 text-sm text-muted-foreground"> + No videos match this filter. + </li> + ) : ( + virtualItems.map((vi) => { + const row = visibleRows[vi.index]; + const isActive = row.id === selectedVideoId; + const isSelected = selected.has(row.id); + return ( + <VirtualRow + as="li" + key={row.id} + measureRef={rowVirtualizer.measureElement} + index={vi.index} + offset={vi.start} + className={`flex items-center gap-2 px-2 py-1.5 text-sm border-b border-border ${ + isActive ? "bg-info-soft" : "hover:bg-muted" + }`} > - <StatusGlyphs row={row} /> - <span className="font-mono text-xs truncate">{row.id}</span> - {row.running && ( - <span - aria-label="job running" - className="text-[10px] uppercase tracking-wide rounded-full border border-info/30 bg-info-soft text-info px-1.5 py-0.5 leading-none" - > - running - </span> - )} - </Link> - </li> - ); - }) - )} - </ul> + <input + type="checkbox" + checked={isSelected} + onChange={() => toggleOne(row.id)} + aria-label={`select ${row.id}`} + className="flex-shrink-0" + /> + <Link + href={buildHref(row.id)} + scroll={false} + className="flex-1 min-w-0 flex items-center gap-2" + aria-label={`open ${row.id}`} + aria-current={isActive ? "page" : undefined} + > + <StatusGlyphs row={row} /> + <span className="font-mono text-xs truncate">{row.id}</span> + {row.running && ( + <span + aria-label="job running" + className="text-[10px] uppercase tracking-wide rounded-full border border-info/30 bg-info-soft text-info px-1.5 py-0.5 leading-none" + > + running + </span> + )} + </Link> + </VirtualRow> + ); + }) + )} + </ul> + </div> {someSelected && ( <div diff --git a/editor/app/channels/[slug]/components/VideoListPaneSection.tsx b/editor/app/channels/[slug]/components/VideoListPaneSection.tsx @@ -1,143 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { useSearchParams } from "next/navigation"; - -const STORAGE_KEY = "vlp:collapsed"; - -// Resolve the lg+ grid columns for every left/right collapse combination. When -// the right (embedded single-video) pane is expanded it is the primary content -// area; when collapsed it shrinks to a thin strip and the list takes the slack. -function gridTemplate(leftCollapsed: boolean, rightCollapsed: boolean): string { - const left = leftCollapsed ? "2.25rem" : "24rem"; - if (!rightCollapsed) return `${left} minmax(0,1fr)`; - return "minmax(0,1fr) 2.25rem"; -} - -export function VideoListPaneSection({ - left, - right, -}: { - left: ReactNode; - right: ReactNode; -}) { - const searchParams = useSearchParams(); - const videoParam = searchParams.get("video"); - const [collapsed, setCollapsed] = useState(false); - // The embedded single-video pane is collapsed by default and its open state - // is derived from the selection (see effect below), not persisted. - const [rightCollapsed, setRightCollapsed] = useState(true); - const [hydrated, setHydrated] = useState(false); - const rightRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - try { - setCollapsed(window.localStorage.getItem(STORAGE_KEY) === "1"); - } catch { - // localStorage unavailable — keep default. - } - setHydrated(true); - }, []); - - useEffect(() => { - if (!hydrated) return; - try { - window.localStorage.setItem(STORAGE_KEY, collapsed ? "1" : "0"); - } catch { - // ignore - } - }, [collapsed, hydrated]); - - // Selection-driven embedded pane: collapse when no video is selected, expand - // and jump to it when one is (this overrides any manual collapse). Keyed on - // videoParam so prev/next navigation inside the panel re-scrolls too. - useEffect(() => { - if (!hydrated) return; - if (!videoParam) { - setRightCollapsed(true); - return; - } - setRightCollapsed(false); - const el = rightRef.current; - if (!el) return; - // Defer two frames so the grid column resizes and the panel mounts before - // we scroll, otherwise the target hasn't reached its final position yet. - requestAnimationFrame(() => - requestAnimationFrame(() => - el.scrollIntoView({ block: "start", behavior: "smooth" }), - ), - ); - }, [videoParam, hydrated]); - - return ( - <section - aria-label="videos" - style={ - { "--vlp-cols": gridTemplate(collapsed, rightCollapsed) } as React.CSSProperties - } - className="grid grid-cols-1 lg:grid-cols-[var(--vlp-cols)] gap-4 lg:gap-6 min-h-[calc(100vh-22rem)]" - > - <div className="flex flex-col min-h-0 lg:sticky lg:self-start lg:top-28 lg:max-h-[80vh]"> - {collapsed ? ( - <button - type="button" - onClick={() => setCollapsed(false)} - aria-expanded={false} - aria-label="expand video list" - className="hidden lg:flex items-center justify-center w-full h-12 rounded border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted" - title="Expand video list" - > - <span aria-hidden="true" className="text-sm">›</span> - </button> - ) : ( - <> - <button - type="button" - onClick={() => setCollapsed(true)} - aria-expanded={true} - aria-label="collapse video list" - className="hidden lg:flex self-end items-center gap-1 text-xs text-muted-foreground hover:text-foreground mb-1" - title="Collapse video list" - > - <span aria-hidden="true">‹</span> - <span>Collapse</span> - </button> - {left} - </> - )} - </div> - <div ref={rightRef} className="min-w-0 scroll-mt-28"> - {rightCollapsed ? ( - <> - <button - type="button" - onClick={() => setRightCollapsed(false)} - aria-expanded={false} - aria-label="expand video" - className="hidden lg:flex items-center justify-center w-full h-12 rounded border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted" - title="Expand video" - > - <span aria-hidden="true" className="text-sm">‹</span> - </button> - <div className="lg:hidden">{right}</div> - </> - ) : ( - <> - <button - type="button" - onClick={() => setRightCollapsed(true)} - aria-expanded={true} - aria-label="collapse video" - className="hidden lg:flex self-start items-center gap-1 text-xs text-muted-foreground hover:text-foreground mb-1" - title="Collapse video" - > - <span aria-hidden="true">›</span> - <span>Collapse</span> - </button> - {right} - </> - )} - </div> - </section> - ); -} diff --git a/editor/app/channels/[slug]/components/flow/AttentionStrip.tsx b/editor/app/channels/[slug]/components/flow/AttentionStrip.tsx @@ -0,0 +1,159 @@ +import Link from "next/link"; +import { Badge } from "yt-dlp-transcript-common/components/ui/badge"; +import type { SnapshotBuckets, StageId } from "../../lib/stageStatus"; + +// ANOMALIES, one chip each, every one a link to the stage that owns it. +// +// Not on the line: these are not a conserved quantity moving through stages, +// they are things that went wrong at one of them. The line answers "where is the +// work"; this answers "what is broken". +// +// It renders a quiet "All clear" rather than disappearing when empty. A strip +// that vanishes is indistinguishable from a strip that failed to render, and the +// whole point of putting it on the first screen is that you can trust its +// silence. + +type Chip = { + label: string; + count?: number; + stage: StageId; + tone: "warning" | "destructive"; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +// A snapshot is regenerated on a ~1s debounce after any report-changing action, +// so a week-old one means nothing has touched this channel in a week — worth +// saying before someone reads its counts as current. +const STALE_REPORT_DAYS = 7; +// The sync cadence gate already spaces full sweeps out; a month is well past any +// of its intervals. +const STALE_SYNC_DAYS = 30; + +export type AttentionInput = { + slug: string; + buckets: SnapshotBuckets; + digestWarnings: string[]; + failedVideoIds: string[]; + failedTranscodingIds: string[]; + transcodeApplies: boolean; + generatedAt: string; + lastSyncedAt?: string; +}; + +function ageInDays(iso: string | undefined): number | null { + if (!iso) return null; + const t = Date.parse(iso); + if (Number.isNaN(t)) return null; + return (Date.now() - t) / DAY_MS; +} + +export function collectAttention(input: AttentionInput): Chip[] { + const { buckets } = input; + const chips: Chip[] = []; + const add = ( + label: string, + count: number, + stage: StageId, + tone: Chip["tone"] = "warning", + ) => { + if (count > 0) chips.push({ label, count, stage, tone }); + }; + + add("failed transcriptions", input.failedVideoIds.length, "transcribe", "destructive"); + if (input.transcodeApplies) { + add( + "failed transcodings", + input.failedTranscodingIds.length, + "transcode", + "destructive", + ); + } + add("need cookies", buckets.needsCookies.length, "download"); + add("partial downloads", buckets.partialDownloads.length, "download"); + add("corrupt sources", buckets.corruptSource.length, "download", "destructive"); + add( + "corrupt full sources", + buckets.corruptFullSource.length, + "download", + "destructive", + ); + add( + "incomplete transcripts", + buckets.incompleteTranscript.length, + "transcribe", + ); + add("short audio", buckets.shortAudio.length, "transcribe"); + add("duplicate dirs", buckets.duplicateDirs.length, "diagnostics"); + add("no metadata", buckets.noMetadata.length, "diagnostics"); + add("missing from archive", buckets.missingFromArchive.length, "diagnostics"); + add("digest warnings", input.digestWarnings.length, "digest"); + + // Freshness. Both of these are about whether the numbers above can be trusted, + // which is why they sit in the same strip rather than in a footnote. + if (!input.generatedAt) { + chips.push({ label: "no report yet", stage: "playlist", tone: "warning" }); + } else { + const days = ageInDays(input.generatedAt); + if (days != null && days > STALE_REPORT_DAYS) { + chips.push({ + label: `report is ${Math.floor(days)} days old`, + stage: "playlist", + tone: "warning", + }); + } + } + if (!input.lastSyncedAt) { + chips.push({ label: "never synced", stage: "playlist", tone: "warning" }); + } else { + const days = ageInDays(input.lastSyncedAt); + if (days != null && days > STALE_SYNC_DAYS) { + chips.push({ + label: `last synced ${Math.floor(days)} days ago`, + stage: "playlist", + tone: "warning", + }); + } + } + + return chips; +} + +export function AttentionStrip(props: AttentionInput) { + const chips = collectAttention(props); + if (chips.length === 0) { + return ( + <p + aria-label="attention strip" + data-attention="clear" + className="text-xs text-muted-foreground" + > + All clear — nothing is flagged on this channel. + </p> + ); + } + return ( + <div + aria-label="attention strip" + data-attention="flagged" + className="flex flex-wrap items-center gap-1.5" + > + {chips.map((c) => ( + <Badge + key={`${c.stage}:${c.label}`} + asChild + variant={c.tone === "destructive" ? "destructive" : "warning"} + > + <Link + href={`/channels/${props.slug}?stage=${c.stage}`} + aria-label={`${c.label} anomaly`} + > + {c.count != null && ( + <span className="tabular-nums">{c.count.toLocaleString()}</span> + )} + {c.label} + </Link> + </Badge> + ))} + </div> + ); +} diff --git a/editor/app/channels/[slug]/components/flow/ChannelLine.tsx b/editor/app/channels/[slug]/components/flow/ChannelLine.tsx @@ -0,0 +1,83 @@ +import type { ChannelFlow } from "../../lib/channelFlow"; +import { GapFoot, GapHead, GapRail } from "./FlowGap"; +import { StationFoot, StationHead, StationRail } from "./FlowStation"; + +// THE CHANNEL LINE. +// +// A transit diagram, not a row of cards. Stations are stages; the gap between +// two stations carries the shortfall. The one structural trick is the LAYOUT +// CONTRACT: every cell emits exactly three children — +// +// row 1 the station numeral (gaps contribute nothing) +// row 2 the rail: a dot for a station, a track for a gap +// row 3 the coverage note / the shortfall + its sidings +// +// — and the parent is a 3-row column-flow grid on md+. That is what keeps every +// dot on one horizontal rule no matter how tall the labels above them run, with +// no absolute positioning and no measured heights. +// +// On a narrow screen the same DOM stacks vertically in reading order, which is +// the natural form for a transit diagram in a column: label, count, dot, +// coverage, then the track and the shortfall leading into the next station. + +export function ChannelLine({ + flow, + slug, +}: { + flow: ChannelFlow; + slug: string; +}) { + const cells: ( + | { kind: "station"; index: number } + | { kind: "gap"; index: number } + )[] = []; + flow.stations.forEach((_, i) => { + cells.push({ kind: "station", index: i }); + if (i < flow.gaps.length) cells.push({ kind: "gap", index: i }); + }); + + // Stations size to their content; gaps take the slack, so the line stretches + // to the card and the negative space is genuinely negative space. + const gridTemplateColumns = cells + .map((c) => (c.kind === "station" ? "max-content" : "minmax(3rem,1fr)")) + .join(" "); + + return ( + <section + aria-label="channel line" + className="rounded-xl border border-border bg-card px-4 py-5 md:px-6 md:py-6 overflow-x-auto" + > + <div + className="flex flex-col gap-1 md:grid md:grid-flow-col md:gap-x-1 md:gap-y-2 md:items-start min-w-fit" + style={{ + gridTemplateColumns, + gridTemplateRows: "auto auto auto", + }} + > + {cells.map((cell) => { + if (cell.kind === "station") { + const station = flow.stations[cell.index]; + return [ + <StationHead key={`${station.id}-h`} station={station} slug={slug} />, + <StationRail key={`${station.id}-r`} station={station} />, + <StationFoot key={`${station.id}-f`} station={station} />, + ]; + } + const gap = flow.gaps[cell.index]; + return [ + <GapHead key={`${gap.to}-h`} />, + <GapRail key={`${gap.to}-r`} gap={gap} />, + <GapFoot + key={`${gap.to}-f`} + gap={gap} + slug={slug} + // Exactly one gap is promoted. Two emphasised things are no + // emphasis at all. + promoted={flow.bottleneck === gap.from && gap.reachable > 0} + />, + ]; + })} + </div> + </section> + ); +} diff --git a/editor/app/channels/[slug]/components/flow/FlowGap.tsx b/editor/app/channels/[slug]/components/flow/FlowGap.tsx @@ -0,0 +1,83 @@ +import Link from "next/link"; +import type { FlowGap as Gap } from "../../lib/channelFlow"; +import { SidingList } from "./SidingList"; + +// THE GAP IS THE WORK. +// +// The shortfall between two stations lives in the negative space between them, +// because that is literally what it is: `videos − downloaded`-shaped. Only the +// LARGEST reachable gap is promoted typographically; the rest recede. The eye +// lands on the bottleneck without the widths having to lie about proportion — +// a 1,675 gap and a 72 gap are drawn the same size, and the type says which is +// which. + +export function GapHead() { + // Row 1 is the station numerals; a gap contributes nothing to it. + return <div aria-hidden="true" className="hidden md:block" />; +} + +export function GapRail({ gap }: { gap: Gap }) { + const dashed = gap.reachable > 0; + return ( + <div className="flex items-center h-3 pl-[4px] md:pl-0 md:px-2"> + <span + aria-hidden="true" + className={ + // Vertical on mobile (the natural form for a transit diagram in a + // narrow column), horizontal on md+. + `w-px h-4 md:w-full md:h-0 md:border-t-2 ${ + dashed + ? "bg-warning/50 md:bg-transparent md:border-dashed md:border-warning/50" + : "bg-border-strong md:bg-transparent md:border-solid md:border-border-strong" + }` + } + /> + </div> + ); +} + +export function GapFoot({ + gap, + slug, + promoted, +}: { + gap: Gap; + slug: string; + promoted: boolean; +}) { + return ( + <div + aria-label={`${gap.label} gap`} + data-gap={gap.to} + data-bottleneck={promoted ? "true" : undefined} + className="min-w-0 md:px-2" + > + {gap.reachable > 0 ? ( + <Link + href={`/channels/${slug}?stage=${gap.stage}`} + aria-label={`${gap.label} shortfall`} + className={ + promoted + ? "inline-flex items-baseline gap-1 text-sm font-medium text-warning hover:underline tabular-nums" + : "inline-flex items-baseline gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline tabular-nums" + } + > + <span aria-hidden="true">▼</span> + <span>{gap.reachable.toLocaleString()}</span> + <span className="font-normal">{gap.label}</span> + </Link> + ) : ( + <span + aria-label={`${gap.label} shortfall`} + className="text-xs text-muted-foreground" + > + {/* An explicit zero is the right answer here: this IS a work list the + lane can enumerate, and it is empty. The "—" rule is for coverage, + where the snapshot cannot say. */} + 0 {gap.label} + </span> + )} + <SidingList slug={slug} sidings={gap.sidings} /> + </div> + ); +} diff --git a/editor/app/channels/[slug]/components/flow/FlowStation.tsx b/editor/app/channels/[slug]/components/flow/FlowStation.tsx @@ -0,0 +1,82 @@ +import Link from "next/link"; +import { Progress } from "yt-dlp-transcript-common/components/ui/progress"; +import type { FlowStation as Station } from "../../lib/channelFlow"; +import { formatCount, formatCoverage, STATION_DOT } from "./tone"; + +// One station on the line, emitted as THREE siblings so the parent grid can put +// every dot on the same horizontal rule regardless of how tall the labels above +// them are. See ChannelLine for the row contract. + +export function StationHead({ + station, + slug, +}: { + station: Station; + slug: string; +}) { + return ( + <Link + href={`/channels/${slug}?stage=${station.stage}`} + aria-label={`${station.label} station`} + data-station={station.id} + className="group flex flex-col gap-0.5 rounded-md px-1 -mx-1 hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring" + > + {/* The house eyebrow, tightened one step. */} + <span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground"> + {station.label} + </span> + {/* The only large numeral the page is allowed. ~3:1 against the 10px + eyebrow is what makes the line scannable from across the room. */} + <span + aria-label={`${station.label} through`} + className="font-mono text-3xl font-semibold tabular-nums leading-none group-hover:underline" + > + {formatCount(station.through)} + </span> + </Link> + ); +} + +export function StationRail({ station }: { station: Station }) { + return ( + <div className="flex items-center h-3 md:justify-start"> + <span + aria-hidden="true" + className={`inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${STATION_DOT[station.tone]}`} + /> + {station.running && <span className="sr-only">running</span>} + </div> + ); +} + +export function StationFoot({ station }: { station: Station }) { + return ( + <div className="flex flex-col gap-1 min-w-[5rem]"> + <p + aria-label={`${station.label} coverage`} + className="text-[11px] text-muted-foreground tabular-nums" + > + {station.denominator == null && station.coverage == null + ? // Not "0%". A third of the snapshots on disk predate the denominator + // this would divide by, and inventing one is how a fully digested + // channel comes to read as empty. + "—" + : `${formatCoverage(station.coverage)} of ${formatCount(station.denominator)}`} + </p> + {station.coverage != null && ( + // Only drawn when there is a real ratio behind it. An empty meter for an + // unknown denominator would say "none of it is done". + // + // aria-hidden, and no label: the <p> above states the same ratio in + // words, so the bar is decorative. Naming it "<station> coverage meter" + // both announces the number twice AND makes getByLabel("<station> + // coverage") ambiguous, since that matching is by substring. + <Progress + value={station.coverage * 100} + aria-hidden="true" + className="h-1 max-w-[7rem]" + /> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/components/flow/NextAction.tsx b/editor/app/channels/[slug]/components/flow/NextAction.tsx @@ -0,0 +1,57 @@ +import type { ChannelFlow } from "../../lib/channelFlow"; +import { InlineActionButton } from "../../../../actionable/components/InlineActionButton"; +import type { StageId } from "../../lib/stageStatus"; + +// THE ONE PRIMARY ACTION, and the only brand-coloured thing on the page. +// +// It renders NOTHING when the channel is idle and clean. A control that is +// always there and usually does nothing teaches you to stop reading it; the +// absence is the signal that there is nothing to do. +// +// The variants are the ones InlineActionButton already binds to real server +// actions, so this is a router, not a new dispatch path. + +// Deliberately no `refreshReport` entry: NoReportYet already carries that +// button at the top of an unreported channel, and two controls with the same +// accessible name is one affordance too many (and one locator match too many). +const VARIANT_FOR: Partial< + Record< + StageId, + "downloadMissing" | "transcribeMissing" | "digestChannel" | "backfillChannel" + > +> = { + download: "downloadMissing", + transcribe: "transcribeMissing", + digest: "digestChannel", + backfill: "backfillChannel", +}; + +export function NextAction({ + flow, + slug, +}: { + flow: ChannelFlow; + slug: string; +}) { + const next = flow.next; + if (!next) return null; + const kind = VARIANT_FOR[next.stage]; + if (!kind) return null; + return ( + <div + aria-label="next action" + className="flex flex-wrap items-center gap-x-3 gap-y-1" + > + <InlineActionButton variant={{ kind, slug }} tone="brand" /> + {next.count > 0 && ( + <p className="text-sm text-muted-foreground"> + <span className="tabular-nums font-medium text-foreground"> + {next.count.toLocaleString()} + </span>{" "} + {/* Furthest upstream, not biggest — see pickNext. */} + waiting at the front of the line. + </p> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/components/flow/OverviewPanel.tsx b/editor/app/channels/[slug]/components/flow/OverviewPanel.tsx @@ -0,0 +1,193 @@ +import Link from "next/link"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "yt-dlp-transcript-common/components/ui/card"; +import { Separator } from "yt-dlp-transcript-common/components/ui/separator"; +import type { StageId, StageStatus } from "../../lib/stageStatus"; + +// THE LANDING PANEL. +// +// One panel is open at a time, and this is the one that is open when no stage is +// selected. It is not a summary written by hand: every row is a straight render +// of the `StageStatus` that computeStageStatuses already returns, so a stage's +// summary line is legible here without opening anything and there is no second +// copy of the wording to drift. + +const TONE_DOT: Record<StageStatus["tone"], string> = { + neutral: "bg-transparent border border-border-strong", + ok: "bg-success", + attention: "bg-warning", + danger: "bg-destructive", + running: "bg-info animate-pulse", +}; + +export type ChannelEssentials = { + handling: string; + platform?: string; + url?: string; + cookieMode?: string; + keepLatest?: number; +}; + +export function OverviewPanel({ + slug, + stageOrder, + stages, + essentials, + generatedAt, + lastSyncedAt, +}: { + slug: string; + stageOrder: StageId[]; + stages: Record<StageId, StageStatus>; + essentials: ChannelEssentials; + generatedAt: string; + lastSyncedAt?: string; +}) { + return ( + <Card aria-label="overview panel" className="gap-4 py-4"> + <CardHeader className="px-4"> + <CardTitle className="text-base">Every stage at a glance</CardTitle> + </CardHeader> + <CardContent className="px-4 flex flex-col gap-4"> + <ul className="flex flex-col divide-y divide-border"> + {stageOrder.map((id) => { + const s = stages[id]; + return ( + <li key={id}> + <Link + href={`/channels/${slug}?stage=${id}`} + aria-label={`${s.title} overview row`} + className="flex items-baseline gap-3 py-2 -mx-2 px-2 rounded hover:bg-muted" + > + <span + aria-hidden="true" + className={`inline-block w-2 h-2 rounded-full flex-shrink-0 translate-y-[-1px] ${TONE_DOT[s.tone]}`} + /> + <span + className={`text-sm font-medium w-28 flex-shrink-0 ${ + id === "danger" ? "text-destructive" : "" + }`} + > + {s.title} + </span> + <span className="text-sm text-muted-foreground min-w-0 flex-1"> + {s.summary} + </span> + <span className="flex items-center gap-1.5 flex-shrink-0"> + {s.running && ( + <span className="text-[10px] uppercase tracking-wide rounded-full border border-info/30 bg-info-soft text-info px-1.5 py-0.5 leading-none"> + running + </span> + )} + {s.pending > 0 && ( + <span + aria-label={`${s.title} pending count`} + className="text-xs tabular-nums rounded-full border border-warning/30 bg-warning-soft text-warning px-2 py-0.5" + > + {s.pending.toLocaleString()} + </span> + )} + {s.failed > 0 && ( + <span + aria-label={`${s.title} failed count`} + className="text-xs tabular-nums rounded-full border border-destructive/30 bg-destructive-soft text-destructive px-2 py-0.5" + > + {s.failed.toLocaleString()} failed + </span> + )} + </span> + </Link> + </li> + ); + })} + </ul> + + <Separator /> + + <div className="flex flex-col gap-2"> + <div className="flex items-baseline justify-between gap-3"> + <h3 className="text-[10px] font-mono uppercase tracking-[0.18em] text-muted-foreground"> + Channel + </h3> + <Link + href={`/channels/${slug}?stage=configure`} + aria-label="edit channel configuration" + className="text-xs underline text-muted-foreground hover:text-foreground" + > + Edit + </Link> + </div> + <dl + aria-label="channel essentials" + className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-sm" + > + <Field label="Handling" value={essentials.handling} /> + <Field label="Platform" value={essentials.platform ?? "—"} /> + <Field + label="URL" + value={essentials.url ?? "no URL configured"} + mono + /> + <Field + label="Cookies" + value={essentials.cookieMode ?? "inherit global"} + /> + <Field + label="Keep latest" + value={ + essentials.keepLatest && essentials.keepLatest > 0 + ? `${essentials.keepLatest}` + : "off" + } + /> + </dl> + <p className="text-xs text-muted-foreground"> + {generatedAt ? ( + <> + Report generated{" "} + <time dateTime={generatedAt}>{formatTimestamp(generatedAt)}</time> + . + </> + ) : ( + "No report has been generated for this channel yet." + )}{" "} + {lastSyncedAt + ? `Last synced ${formatTimestamp(lastSyncedAt)}.` + : "Never synced."} + </p> + </div> + </CardContent> + </Card> + ); +} + +function Field({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( + <> + <dt className="text-muted-foreground">{label}</dt> + <dd className={`min-w-0 truncate ${mono ? "font-mono text-xs" : ""}`}> + {value} + </dd> + </> + ); +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} diff --git a/editor/app/channels/[slug]/components/flow/SidingList.tsx b/editor/app/channels/[slug]/components/flow/SidingList.tsx @@ -0,0 +1,112 @@ +"use client"; + +import Link from "next/link"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "yt-dlp-transcript-common/components/ui/collapsible"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "yt-dlp-transcript-common/components/ui/hover-card"; +import type { Siding } from "../../lib/channelFlow"; + +// How many sidings stay visible before the rest collapse. The tail of the +// pipeline can legitimately carry six at once (digest blocked + deferred + +// warnings, backfill missing-input + deferred + blocked), and six lines of +// muted text under one gap stops being a footnote and starts being the page. +const VISIBLE_SIDINGS = 2; + +// SIDINGS — everything that has LEFT the line. +// +// Deliberately recessive: muted-foreground, 11px, hanging BELOW the rule while +// reachable work sits on it. That is not decoration. `missingInput`, `deferred`, +// `blocked`, `excludedFromDownload` and `untranscribable` must never be added to +// a gap's reachable count — on the measured corpus missingInput alone is 91× +// larger, and four surfaces once summed them into one "remaining" figure that +// put every channel permanently at the top of every list. Rendering them on a +// different axis is what makes the layout itself unable to sum them. +// +// The hover card carries the sentence nobody remembers: what "deferred" is +// waiting for, what a "blocked" video is blocked ON. Those come from the +// registry (kind.deferredHint, dependsOn → operationLabel), resolved on the +// server, because BACKFILL_KIND_BY_ID reads the filesystem. + +export function SidingList({ + slug, + sidings, +}: { + slug: string; + sidings: Siding[]; +}) { + if (sidings.length === 0) return null; + const head = sidings.slice(0, VISIBLE_SIDINGS); + const tail = sidings.slice(VISIBLE_SIDINGS); + return ( + <div + aria-label="off the line" + className="flex flex-col gap-0.5 mt-1.5 text-[11px] text-muted-foreground" + > + <ul className="flex flex-col gap-0.5"> + {head.map((s) => ( + <SidingItem key={`${s.stage}:${s.label}`} slug={slug} siding={s} /> + ))} + </ul> + {tail.length > 0 && ( + <Collapsible> + <CollapsibleTrigger + aria-label="show remaining sidings" + className="text-left underline decoration-dotted underline-offset-2 hover:text-foreground" + > + +{tail.length} more + </CollapsibleTrigger> + <CollapsibleContent> + <ul className="flex flex-col gap-0.5"> + {tail.map((s) => ( + <SidingItem key={`${s.stage}:${s.label}`} slug={slug} siding={s} /> + ))} + </ul> + </CollapsibleContent> + </Collapsible> + )} + </div> + ); +} + +function SidingItem({ slug, siding: s }: { slug: string; siding: Siding }) { + const link = ( + <Link + href={`/channels/${slug}?stage=${s.stage}`} + aria-label={`${s.label} siding`} + className={`tabular-nums hover:text-foreground ${ + s.hint + ? "underline decoration-dotted underline-offset-2" + : "hover:underline" + }`} + > + {s.count.toLocaleString()} {s.label} + </Link> + ); + return ( + <li className="flex items-start gap-1"> + <span aria-hidden="true" className="select-none leading-[1.4]"> + └ + </span> + {s.hint ? ( + <HoverCard> + <HoverCardTrigger asChild>{link}</HoverCardTrigger> + <HoverCardContent className="text-xs"> + <p className="font-medium text-foreground mb-1"> + {s.count.toLocaleString()} {s.label} + </p> + <p className="text-muted-foreground">{s.hint}</p> + </HoverCardContent> + </HoverCard> + ) : ( + link + )} + </li> + ); +} diff --git a/editor/app/channels/[slug]/components/flow/StageSwitcher.tsx b/editor/app/channels/[slug]/components/flow/StageSwitcher.tsx @@ -0,0 +1,111 @@ +import Link from "next/link"; +import type { StageId, StageStatus } from "../../lib/stageStatus"; + +// The stage picker. ONE PANEL IS OPEN AT A TIME and the selection lives in +// `?stage=`, not a hash — so it is server-rendered, shareable, and survives the +// global AutoRefresh's router.refresh(). The old rail rendered every panel +// expanded at lg+, which is how the page came to be eight tall panels deep. +// +// A plain <Link> row rather than shadcn's <Tabs>: Tabs is a client component +// with roving focus over panels it owns, and here each "tab" is a navigation +// that re-renders the server panel. Borrowing its markup would mean lying about +// which element controls which panel. + +const TONE_DOT: Record<StageStatus["tone"], string> = { + neutral: "bg-transparent border border-border-strong", + ok: "bg-success", + attention: "bg-warning", + danger: "bg-destructive", + running: "bg-info animate-pulse", +}; + +export function StageSwitcher({ + slug, + stageOrder, + stages, + selected, +}: { + slug: string; + stageOrder: StageId[]; + stages: Record<StageId, StageStatus>; + selected: StageId | null; +}) { + return ( + <nav + aria-label="pipeline stages" + className="flex flex-wrap items-center gap-1 border-b border-border pb-2" + > + <Tab href={`/channels/${slug}`} active={selected === null} label="Overview" /> + {stageOrder.map((id) => { + const status = stages[id]; + return ( + <Tab + key={id} + href={`/channels/${slug}?stage=${id}`} + active={selected === id} + label={status.title} + dot={TONE_DOT[status.tone]} + badge={ + status.failed > 0 + ? { text: `${status.failed}`, tone: "destructive" as const } + : status.pending > 0 + ? { text: `${status.pending}`, tone: "warning" as const } + : null + } + danger={id === "danger"} + /> + ); + })} + </nav> + ); +} + +function Tab({ + href, + active, + label, + dot, + badge, + danger, +}: { + href: string; + active: boolean; + label: string; + dot?: string; + badge?: { text: string; tone: "warning" | "destructive" } | null; + danger?: boolean; +}) { + return ( + <Link + href={href} + aria-current={active ? "page" : undefined} + aria-label={`${label} stage tab`} + className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm ${ + active + ? "bg-muted text-foreground font-medium" + : danger + ? "text-destructive hover:bg-muted" + : "text-muted-foreground hover:bg-muted hover:text-foreground" + }`} + > + {dot && ( + <span + aria-hidden="true" + className={`inline-block w-1.5 h-1.5 rounded-full ${dot}`} + /> + )} + <span>{label}</span> + {badge && ( + <span + className={`text-[10px] tabular-nums rounded-full px-1.5 leading-4 ${ + badge.tone === "destructive" + ? "bg-destructive-soft text-destructive" + : "bg-warning-soft text-warning" + }`} + > + {badge.text} + </span> + )} + </Link> + ); +} diff --git a/editor/app/channels/[slug]/components/flow/tone.ts b/editor/app/channels/[slug]/components/flow/tone.ts @@ -0,0 +1,41 @@ +import type { StageTone } from "../../lib/stageStatus"; + +// The line invents no colours. Every value below is one of the semantic tokens +// the repo already carries across four theme families × light/dark +// (common/styles/tokens.css); a bespoke hue here would be wrong in eight +// palettes at once. + +export const STATION_DOT: Record<StageTone, string> = { + // A station with nothing to say is an outline, not a filled dot: "○" in the + // sketch. Notably this is also where a DISABLED lane lands — see the tone rule + // in channelFlow.ts. + neutral: "border border-border-strong bg-transparent", + ok: "bg-success", + attention: "bg-warning", + danger: "bg-destructive", + // The travelling pulse. The only animated thing on the page. + running: "bg-info animate-pulse", +}; + +export const STATION_TEXT: Record<StageTone, string> = { + neutral: "text-muted-foreground", + ok: "text-foreground", + attention: "text-warning", + danger: "text-destructive", + running: "text-info", +}; + +export function formatCount(n: number | null): string { + // "—", never "0". A zero here would claim a measurement nobody took. + return n == null ? "—" : n.toLocaleString(); +} + +export function formatCoverage(coverage: number | null): string { + if (coverage == null) return "—"; + const pct = coverage * 100; + // Whole percents once you are past 10% — the third significant figure on + // "94.37%" is noise at this size. Below that, one decimal, because the + // difference between 6.8% and 6% is the difference between a sweep that is + // moving and one that is not. + return `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}%`; +} diff --git a/editor/app/channels/[slug]/layout.tsx b/editor/app/channels/[slug]/layout.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; +import { ChannelTabs } from "./components/ChannelTabs"; +import { RefreshSnapshotButton } from "./components/RefreshSnapshotButton"; +import { readChannelConfigCached } from "./lib/channelConfigCache"; + +// Shared chrome for every channel route: the breadcrumb, the name, the refresh +// control and the Overview | Videos switch. This is what StatusHeader used to +// be, moved up a level now that the channel has more than one page. +// +// Next 16 caveats this file is written around (see +// node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/layout.md): +// +// - LAYOUTS DO NOT RERENDER on navigation and cannot read searchParams. So the +// active-tab check lives in a client component (ChannelTabs), and anything +// that varies with `?stage=` — the report freshness, the stage panels — stays +// in page.tsx. +// - NO loading.tsx HERE. In this app it turns notFound() 404s into 200s and +// never shows on a client-side sibling navigation anyway. +// +// force-dynamic for the same reason page.tsx carries it: this reads the channel +// config off disk on every request and must never be statically prerendered. +export const dynamic = "force-dynamic"; + +export default async function ChannelLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + // Deduped with page.tsx's read of the same file via React cache(). + const config = await readChannelConfigCached(slug); + if (!config) notFound(); + + return ( + <div className="flex flex-col gap-6"> + <header + aria-label="channel status header" + className="sticky top-0 z-10 -mx-4 md:-mx-6 px-4 md:px-6 py-3 bg-card/95 backdrop-blur border-b border-border" + > + <div className="flex items-start justify-between gap-3 flex-wrap"> + <div className="flex flex-col gap-1 min-w-0"> + <nav className="flex items-center gap-2 text-sm text-muted-foreground"> + <Link href="/channels" className="underline hover:text-foreground"> + Channels + </Link> + <span aria-hidden="true">/</span> + <span className="font-mono">{slug}</span> + </nav> + <h1 className="text-2xl font-semibold truncate"> + {config.name ?? slug} + </h1> + </div> + {/* A social channel has no videos, no downloads and no transcripts: + the video workspace would be an empty room, and there is no video + report to refresh. Both controls stay off it — which is also what + the header did before this layout existed. */} + {!isSocialChannel(config) && ( + <div className="flex items-center gap-3"> + <ChannelTabs slug={slug} /> + <RefreshSnapshotButton slug={slug} /> + </div> + )} + </div> + </header> + {children} + </div> + ); +} diff --git a/editor/app/channels/[slug]/lib/channelConfigCache.ts b/editor/app/channels/[slug]/lib/channelConfigCache.ts @@ -0,0 +1,15 @@ +import { cache } from "react"; +import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; + +// The layout and the page both need the channel's config — the layout for the +// title and whether to offer the Videos tab, the page for everything else. React +// `cache()` dedupes them into ONE read per request. Without it, every channel +// route pays two reads of the same file for the same render. +// +// Request-scoped, not a process cache: the memo lives for the duration of one +// render pass, so a config edited by the form on the previous request is read +// fresh on the next one. +export const readChannelConfigCached = cache(async (slug: string) => + readChannelConfig(getPaths(), slug), +); diff --git a/editor/app/channels/[slug]/lib/channelFlow.test.ts b/editor/app/channels/[slug]/lib/channelFlow.test.ts @@ -0,0 +1,307 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; +import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; +import type { + BackfillKind, + BackfillSnapshotEntry, +} from "yt-dlp-transcript-common/lib/backfillKinds"; +import { computeChannelFlow, type FlowStationId } from "./channelFlow"; +import { computeStageStatuses, normalizeBuckets } from "./stageStatus"; + +// Run from this directory (the [slug] segment is a glob to node's test runner): +// cd "editor/app/channels/[slug]/lib" && ../../../../../node_modules/.bin/tsx --test channelFlow.test.ts + +const CONFIG: ChannelConfig = { handling: "transcribe", url: "https://x/y" }; + +function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot { + return { + generatedAt: "2026-08-01T00:00:00.000Z", + totals: { videos: 100, transcribed: 40, downloaded: 60 }, + buckets: normalizeBuckets(undefined), + undownloadedIds: [], + ...patch, + }; +} + +// A registry entry is a big object with three async methods on it; none of them +// are reachable from computeChannelFlow, which only ever reads id/label/hints. +function kind(patch: Partial<BackfillKind> & { id: string }): BackfillKind { + return { label: patch.id, hint: "", ...patch } as BackfillKind; +} + +// A snapshot entry AS WRITTEN TO DISK. The cast is the point of the helper: +// BackfillCounts declares deferred/blocked/partial required, but every snapshot +// currently on disk predates them, which is why every read site carries `?? 0`. +// Omitting them here is how these tests exercise the real files. +function entry(patch: Partial<BackfillSnapshotEntry>): BackfillSnapshotEntry { + return { ids: [], missing: 0, stale: 0, missingInput: 0, ...patch } as BackfillSnapshotEntry; +} + +function flowOf( + snapshot: ChannelSnapshot, + opts: { + backfillKinds?: BackfillKind[]; + playlistCount?: number | null; + transcodeApplies?: boolean; + failedVideoIds?: string[]; + failedTranscodingIds?: string[]; + } = {}, +) { + const failedVideoIds = opts.failedVideoIds ?? []; + const failedTranscodingIds = opts.failedTranscodingIds ?? []; + return computeChannelFlow({ + snapshot, + stages: computeStageStatuses({ + snapshot, + failedVideoIds, + failedTranscodingIds, + config: CONFIG, + runningJobs: [], + }), + config: CONFIG, + failedVideoIds, + failedTranscodingIds, + transcodeApplies: opts.transcodeApplies ?? false, + backfillKinds: opts.backfillKinds ?? [kind({ id: "diarization" })], + playlistCount: opts.playlistCount ?? null, + }); +} + +function station( + flow: ReturnType<typeof flowOf>, + id: FlowStationId, +) { + const s = flow.stations.find((st) => st.id === id); + assert.ok(s, `expected a ${id} station`); + return s; +} + +test("a pre-`eligible` snapshot reports unknown coverage, not zero", () => { + // The shape a third of the snapshots on disk are still in: per-kind counts + // written before `eligible` existed. A 0 here renders as "nothing digested" + // on a channel that may be fully digested. + const flow = flowOf( + snapshotOf({ + backfill: { + digest: entry({ missing: 5 }), + diarization: entry({ missing: 3 }), + }, + }), + ); + + assert.equal(station(flow, "digest").through, null); + assert.equal(station(flow, "digest").denominator, null); + assert.equal(station(flow, "digest").coverage, null); + assert.equal(station(flow, "backfill").through, null); + assert.equal(station(flow, "backfill").coverage, null); +}); + +test("one unknown term poisons the whole coverage sum rather than under-reporting", () => { + const flow = flowOf( + snapshotOf({ + backfill: { + diarization: entry({ eligible: 10 }), + // Same lane, no `eligible` — written by an older build. + "attribution-diarized": entry({}), + }, + }), + { + backfillKinds: [ + kind({ id: "diarization" }), + kind({ id: "attribution-diarized" }), + ], + }, + ); + + assert.equal(station(flow, "backfill").through, null); + assert.equal(station(flow, "backfill").denominator, null); +}); + +test("coverage is a real ratio once the snapshot can say", () => { + const flow = flowOf(snapshotOf(), { playlistCount: 125 }); + assert.equal(station(flow, "playlist").through, 100); + assert.equal(station(flow, "playlist").denominator, 125); + assert.equal(station(flow, "playlist").coverage, 0.8); + assert.equal(station(flow, "download").coverage, 0.6); + // transcribed / downloaded, not / videos: the denominator is the eligible + // population, and an undownloaded video is not eligible for transcription. + assert.equal( + station(flow, "transcribe").coverage, + 40 / 60, + ); +}); + +test("a lane that is switched off reads neutral, never ok and never amber", () => { + const snapshot = snapshotOf({ + backfill: { + diarization: entry({ missing: 7, eligible: 50, ids: ["a", "b"] }), + }, + }); + + const off = flowOf(snapshot, { backfillKinds: [] }); + assert.equal(station(off, "backfill").tone, "neutral"); + // …and the work it recorded is not offered as something to press, because + // nothing would run it. + assert.equal(off.gaps.find((g) => g.to === "backfill")?.reachable, 0); + assert.notEqual(off.next?.stage, "backfill"); + + const on = flowOf(snapshot, { backfillKinds: [kind({ id: "diarization" })] }); + assert.equal(on.gaps.find((g) => g.to === "backfill")?.reachable, 7); +}); + +test("deferred, blocked and missing-input never enter a gap's reachable count", () => { + const flow = flowOf( + snapshotOf({ + backfill: { + digest: entry({ + missing: 2, + stale: 1, + partial: 1, + missingInput: 900, + deferred: 40, + blocked: 1631, + eligible: 3000, + ids: ["a", "b", "c", "d"], + }), + diarization: entry({ + missing: 3, + missingInput: 500, + deferred: 11, + blocked: 70, + eligible: 1000, + ids: ["x", "y", "z"], + }), + }, + }), + ); + + const toDigest = flow.gaps.find((g) => g.to === "digest"); + const toBackfill = flow.gaps.find((g) => g.to === "backfill"); + // missing + stale + partial, and nothing else. + assert.equal(toDigest?.reachable, 4); + assert.equal(toBackfill?.reachable, 3); + + // The excluded populations are present — on the other axis. + const sidingCount = ( + gapTo: FlowStationId, + label: string, + ): number | undefined => + flow.gaps + .find((g) => g.to === gapTo) + ?.sidings.find((s) => s.label === label)?.count; + + assert.equal(sidingCount("digest", "waiting on a transcript"), 1631); + assert.equal(sidingCount("digest", "deferred"), 40); + assert.equal(sidingCount("backfill", "needs media re-acquired"), 500); + assert.equal(sidingCount("backfill", "deferred"), 11); + assert.equal(sidingCount("backfill", "waiting on an earlier backfill"), 70); + + // The invariant stated as the sum nobody should be able to write: a gap's + // reachable count is not the total of everything hanging under it. + for (const gap of flow.gaps) { + const sidingTotal = gap.sidings.reduce((n, s) => n + s.count, 0); + if (sidingTotal > 0) { + assert.notEqual(gap.reachable, gap.reachable + sidingTotal); + } + } +}); + +test("the digest station reads through the registry, not the legacy bucket alone", () => { + // A snapshot old enough to have only `noDigest`. digestWorkOf falls back to + // it, so the work is still reported rather than reading as fully digested. + const flow = flowOf( + snapshotOf({ + buckets: { ...normalizeBuckets(undefined), noDigest: ["a", "b", "c"] }, + }), + ); + assert.equal(flow.gaps.find((g) => g.to === "digest")?.reachable, 3); + // …but it still cannot say how many are done. + assert.equal(station(flow, "digest").through, null); +}); + +test("the transcode station appears only when the channel transcodes", () => { + const withOut = flowOf(snapshotOf()); + assert.equal( + withOut.stations.some((s) => s.id === "transcode"), + false, + ); + assert.deepEqual( + withOut.gaps.map((g) => g.to), + ["download", "transcribe", "digest", "backfill"], + ); + + const withIt = flowOf( + snapshotOf({ + buckets: { ...normalizeBuckets(undefined), untranscoded: ["a", "b"] }, + }), + { transcodeApplies: true }, + ); + assert.deepEqual( + withIt.gaps.map((g) => g.to), + ["download", "transcode", "transcribe", "digest", "backfill"], + ); + assert.equal(withIt.gaps.find((g) => g.to === "transcode")?.reachable, 2); + assert.equal(station(withIt, "transcode").through, 58); +}); + +test("the bottleneck is the biggest gap; the next action is the furthest upstream one", () => { + const flow = flowOf( + snapshotOf({ + undownloadedIds: ["a", "b"], + backfill: { + digest: entry({ missing: 1675, eligible: 1797 }), + }, + }), + ); + + // The eye goes to the 1,675-video digest shortfall… + assert.equal(flow.bottleneck, "transcribe"); + // …but the button offers the two downloads, because a line clears from the + // front and the digest gap shrinks on its own as the upstream one does. + assert.equal(flow.next?.stage, "download"); + assert.equal(flow.next?.count, 2); +}); + +test("an idle, clean, reported channel offers no action at all", () => { + const flow = flowOf( + snapshotOf({ totals: { videos: 10, transcribed: 10, downloaded: 10 } }), + ); + assert.equal(flow.next, null); + assert.equal(flow.bottleneck, null); +}); + +test("a channel with no report offers nothing here — NoReportYet owns that", () => { + // Every count is zero because nothing has looked yet, not because the work is + // done. The page says so in the NoReportYet banner, which carries the only + // "Refresh report" button; putting a second one here duplicates the affordance + // and makes the name ambiguous. + const flow = flowOf( + snapshotOf({ + generatedAt: "", + totals: { videos: 0, transcribed: 0, downloaded: 0 }, + }), + ); + assert.equal(flow.next, null); +}); + +test("every optional snapshot field survives being absent", () => { + // The render-path crash this guards: `.toLocaleString()` on an undefined + // count. Nothing here is defaulted defensively — it is defaulted because + // snapshots on disk genuinely predate each field. + const bare = { + generatedAt: "", + totals: { videos: 0, transcribed: 0, downloaded: 0 }, + buckets: {}, + undownloadedIds: [], + } as unknown as ChannelSnapshot; + const flow = flowOf(bare); + for (const s of flow.stations) { + assert.ok(s.through === null || Number.isFinite(s.through)); + assert.ok(s.coverage === null || Number.isFinite(s.coverage)); + } + for (const g of flow.gaps) { + assert.ok(Number.isFinite(g.reachable)); + for (const sd of g.sidings) assert.ok(Number.isFinite(sd.count)); + } +}); diff --git a/editor/app/channels/[slug]/lib/channelFlow.ts b/editor/app/channels/[slug]/lib/channelFlow.ts @@ -0,0 +1,503 @@ +import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; +import { + digestWorkOf, + excludedDownloadIdSet, + type ChannelSnapshot, +} from "yt-dlp-transcript-common/controller/channelSnapshot"; +import { digestCountOf } from "yt-dlp-transcript-common/controller/channels"; +import { + laneEntriesOf, + operationLabel, + presentBackfillWork, + reachableBackfillWork, + type BackfillKind, +} from "yt-dlp-transcript-common/lib/backfillKinds"; +import { + normalizeBuckets, + type StageId, + type StageStatus, + type StageTone, +} from "./stageStatus"; + +// THE CHANNEL LINE. +// +// A channel's lifecycle is not ten sibling cards — it is a conserved quantity +// moving through stations. Every video enters at the playlist and either +// advances or leaves the line. This module is the model behind that picture: +// stations are stages, and the GAP between two stations carries the shortfall, +// because the gap is the work. +// +// SNAPSHOT-ONLY, and deliberately pure. common/controller/noCorpusWalkInRender- +// Paths.test.ts bans the corpus walk from render paths, and page.tsx documents +// the multi-minute regression from generating a report inside a GET. The one +// value this cannot derive from the snapshot — how many videos the playlist +// file names — is passed IN by the caller (one small readFile), never read here. +// +// Two invariants this file exists to hold: +// +// 1. WORK ON THE LINE IS NEVER SUMMED WITH WORK OFF IT. `reachable` is what the +// lane can do today (missing + stale + partial). `missingInput`, `deferred`, +// `blocked`, `excludedFromDownload` and `untranscribable` have LEFT the line +// and live in `sidings`. Four surfaces once summed Object.values(snapshot +// .backfill) and put every channel permanently at the top of every list. +// Different fields on different axes is what makes the mistake unspellable. +// 2. UNKNOWN IS NOT ZERO. A third of the snapshots on disk predate `eligible`, +// so `present`/`coverage` are `number | null` and a reader must render "—". +// A 0 there reads as "nothing digested" on a fully digested channel. + +export type FlowStationId = + | "playlist" + | "download" + | "transcode" + | "transcribe" + | "digest" + | "backfill"; + +export type FlowStation = { + id: FlowStationId; + label: string; + // Videos that have cleared this station. NULL when the snapshot cannot say — + // see invariant 2 above. Only the digest and backfill stations can be null; + // the rest come from `totals`, which every snapshot carries. + through: number | null; + // The eligible population. null = unknown. + denominator: number | null; + // through / denominator, 0..1. Null whenever either side is unknown — render + // "—", never 0. + coverage: number | null; + running: boolean; + tone: StageTone; + // Which stage panel this station opens (?stage=). + stage: StageId; +}; + +// A population that has LEFT the line: it is not work the lane can pick up, and +// it must never be added to a gap's `reachable`. +export type Siding = { + label: string; + count: number; + stage: StageId; + hint?: string; +}; + +export type FlowGap = { + from: FlowStationId; + to: FlowStationId; + // Work the lane can do today. NEVER summed with `sidings`. + reachable: number; + // "to download", "to transcribe", … + label: string; + // Where ?stage= sends you — the stage that OWNS this gap's work, i.e. the + // destination station's stage. + stage: StageId; + // The destination station's off-line populations. Sidings hang below the gap + // in the rendered line, on a different axis from `reachable`, so the layout + // itself cannot sum them. + sidings: Siding[]; +}; + +export type ChannelFlow = { + stations: FlowStation[]; + gaps: FlowGap[]; + // The `from` id of the largest reachable gap — the one the renderer promotes + // typographically. Null when nothing is reachable anywhere. + bottleneck: FlowStationId | null; + // The single primary action offered on the page. See pickNext below for why + // this is the FURTHEST UPSTREAM gap rather than the biggest one. + next: { stage: StageId; label: string; count: number } | null; +}; + +export type ComputeChannelFlowInput = { + snapshot: ChannelSnapshot; + // Reused, never recomputed: tone and running are stageStatus's job and a + // second opinion about them is a second thing to keep in sync. + stages: Record<StageId, StageStatus>; + config: ChannelConfig; + failedVideoIds: string[]; + failedTranscodingIds: string[]; + transcodeApplies: boolean; + // Enabled lane kinds. EMPTY MEANS THE LANE IS OFF, which is not the same as + // finished — see the tone rule at the bottom of this file. + backfillKinds: BackfillKind[]; + // How many videos the channel's `playlist` file names, or null when there is + // no playlist file. Read by the caller (countPlaylist) so this stays pure. + playlistCount: number | null; +}; + +function ratio(through: number | null, denominator: number | null): number | null { + if (through == null || denominator == null || denominator <= 0) return null; + return Math.min(1, through / denominator); +} + +function siding( + label: string, + count: number, + stage: StageId, + hint?: string, +): Siding[] { + return count > 0 ? [{ label, count, stage, ...(hint ? { hint } : {}) }] : []; +} + +export function computeChannelFlow( + input: ComputeChannelFlowInput, +): ChannelFlow { + const { + snapshot, + stages, + failedVideoIds, + failedTranscodingIds, + transcodeApplies, + backfillKinds, + playlistCount, + } = input; + + const buckets = normalizeBuckets(snapshot.buckets); + const digestWarnings = snapshot.buckets?.digestWarnings ?? []; + const totals = snapshot.totals ?? { videos: 0, transcribed: 0, downloaded: 0 }; + const undownloadedIds = snapshot.undownloadedIds ?? []; + const excluded = snapshot.excludedFromDownload; + const excludedIds = excludedDownloadIdSet(snapshot); + const actionableDownloadedNoTranscript = buckets.downloadedNoTranscript.filter( + (id) => !excludedIds.has(id), + ); + + // Read the digest operation through digestWorkOf, never buckets.noDigest: the + // bucket has no cues-staleness gate and no transcript gate, so a channel that + // has not been re-reported since the registry landed would read as done. + const digestWork = digestWorkOf(snapshot); + + // laneEntriesOf, never Object.values: the per-kind map now carries EVERY + // catalog operation including digest (~75k videos on the live corpus), and + // digest has its own station one step upstream. + const laneEntries = laneEntriesOf(snapshot.backfill); + // An empty kind list means the operator switched the feature off. That is not + // an empty work list in the "finished" sense, and the tone rule below says so. + const laneOff = backfillKinds.length === 0; + + const laneReachable = laneOff + ? 0 + : laneEntries.reduce((n, e) => n + reachableBackfillWork(e), 0); + const laneMissingInput = laneEntries.reduce((n, e) => n + e.missingInput, 0); + // `?? 0` is load-bearing, not defensive: snapshots written before these fields + // existed have neither, and .toLocaleString() on undefined throws in a render. + const laneDeferred = laneEntries.reduce((n, e) => n + (e.deferred ?? 0), 0); + const laneBlocked = laneEntries.reduce((n, e) => n + (e.blocked ?? 0), 0); + + // presentBackfillWork returns null on a pre-`eligible` snapshot. One null + // poisons the whole sum — deliberately: "3 of the 4 kinds are done and the + // fourth is unknown" is not a number, and rendering the partial sum would + // understate coverage without saying so. + const lanePresent = sumOrNull(laneEntries.map((e) => presentBackfillWork(e))); + const laneEligible = sumOrNull( + laneEntries.map((e) => (e.eligible == null ? null : e.eligible)), + ); + + // digestCountOf sums `digestEngines`, which 11 of the 65 live snapshots lack + // entirely — it returns 0 for those, which would read as "nothing digested". + // So it is only consulted when the map is actually present. + const digestEnginesTotal = + snapshot.digestEngines != null ? digestCountOf(snapshot) : null; + + const stationById: Record<FlowStationId, FlowStation> = { + playlist: { + id: "playlist", + label: "Playlist", + through: totals.videos, + denominator: playlistCount, + coverage: ratio(totals.videos, playlistCount), + running: stages.playlist.running, + tone: stages.playlist.tone, + stage: "playlist", + }, + download: { + id: "download", + label: "Download", + through: totals.downloaded, + denominator: totals.videos, + coverage: ratio(totals.downloaded, totals.videos), + running: stages.download.running, + tone: stages.download.tone, + stage: "download", + }, + transcode: { + id: "transcode", + label: "Transcode", + // No `totals.transcoded` field exists, but the population is derivable + // exactly: everything downloaded that is not in the untranscoded bucket is + // already in the channel's target format. + through: Math.max(0, totals.downloaded - buckets.untranscoded.length), + denominator: totals.downloaded, + coverage: ratio( + Math.max(0, totals.downloaded - buckets.untranscoded.length), + totals.downloaded, + ), + running: stages.transcode.running, + tone: stages.transcode.tone, + stage: "transcode", + }, + transcribe: { + id: "transcribe", + label: "Transcribe", + through: totals.transcribed, + denominator: totals.downloaded, + coverage: ratio(totals.transcribed, totals.downloaded), + running: stages.transcribe.running, + tone: stages.transcribe.tone, + stage: "transcribe", + }, + digest: { + id: "digest", + label: "Digest", + through: digestWork.present ?? digestEnginesTotal, + denominator: digestWork.eligible, + coverage: ratio(digestWork.present ?? digestEnginesTotal, digestWork.eligible), + running: stages.digest.running, + tone: stages.digest.tone, + stage: "digest", + }, + backfill: { + id: "backfill", + label: "Backfill", + through: laneOff ? null : lanePresent, + denominator: laneOff ? null : laneEligible, + coverage: laneOff ? null : ratio(lanePresent, laneEligible), + running: stages.backfill.running, + // A station whose lane is DISABLED is neutral — never "ok" and never + // amber. An empty work list because a feature is off is not the same as + // being finished, and colouring it green claims a thing nobody checked. + tone: laneOff ? "neutral" : stages.backfill.tone, + stage: "backfill", + }, + }; + + const order: FlowStationId[] = [ + "playlist", + "download", + ...(transcodeApplies ? (["transcode"] as const) : []), + "transcribe", + "digest", + "backfill", + ]; + const stations = order.map((id) => stationById[id]); + + // Sidings belong to the DESTINATION station's stage: "4 need cookies" hangs + // under "to download", "1,631 blocked" under "to digest". Same rule for every + // gap, so nothing is homeless and nothing is counted twice. + const sidingsOf: Record<FlowStationId, Siding[]> = { + playlist: [], + download: [ + ...siding( + "never fetched", + snapshot.missingNeverFetched?.length ?? 0, + "diagnostics", + "Known to the roster, never downloaded, and gone from the current listing.", + ), + ...siding("members-only", excluded?.membersOnly?.length ?? 0, "diagnostics"), + ...siding("deleted", excluded?.deleted?.length ?? 0, "diagnostics"), + ...siding("private", excluded?.private?.length ?? 0, "diagnostics"), + ...siding( + "skipped by filter", + buckets.skippedByFilter.length, + "diagnostics", + "Declined as currently live or upcoming; retried on a later sync.", + ), + ...siding( + "need cookies", + buckets.needsCookies.length, + "download", + "Browser cookies could recover these.", + ), + ...siding("partial downloads", buckets.partialDownloads.length, "download"), + ...siding( + "corrupt source", + buckets.corruptSource.length, + "download", + "Malformed source; needs re-downloading.", + ), + ...siding( + "corrupt full source", + buckets.corruptFullSource.length, + "download", + "Download completed but the audio stayed malformed. File kept for inspection; re-downloading is futile.", + ), + ], + transcode: transcodeApplies + ? siding("failed transcodings", failedTranscodingIds.length, "transcode") + : [], + transcribe: [ + ...siding("failed", failedVideoIds.length, "transcribe"), + ...siding( + "untranscribable", + buckets.untranscribable.length, + "diagnostics", + "Marked untranscribable by hand — an intentional decision, not an anomaly.", + ), + ...siding( + "incomplete transcript", + buckets.incompleteTranscript.length, + "transcribe", + "The transcript covers a fraction of the runtime — the audio download truncated silently.", + ), + ...siding( + "short audio", + buckets.shortAudio.length, + "transcribe", + "The source served a truncated stream; the short file is kept so it is not re-downloaded into a loop.", + ), + ], + digest: [ + ...siding( + "waiting on a transcript", + digestWork.blocked, + "digest", + "Nothing the digest lane can do about these — the number falls on its own as transcription runs.", + ), + ...siding( + "deferred", + digestWork.deferred, + "digest", + deferredHintFor(backfillKinds, "digest"), + ), + ...siding( + "digest warnings", + digestWarnings.length, + "digest", + "The digest pass recorded something a human should look at.", + ), + ], + backfill: laneOff + ? [] + : [ + ...siding( + "needs media re-acquired", + laneMissingInput, + "backfill", + "The source audio is gone; re-acquiring it is an opt-in re-download.", + ), + ...siding( + "deferred", + laneDeferred, + "backfill", + deferredHintFor(backfillKinds), + ), + ...siding( + "waiting on an earlier backfill", + laneBlocked, + "backfill", + dependsOnHint(backfillKinds), + ), + ], + }; + + // Work owned by each DESTINATION station — the shortfall carried by the gap + // that leads into it. + const reachableInto: Record<FlowStationId, number> = { + playlist: 0, + download: undownloadedIds.length, + transcode: transcodeApplies ? buckets.untranscoded.length : 0, + transcribe: actionableDownloadedNoTranscript.length, + digest: digestWork.reachable, + backfill: laneReachable, + }; + + const GAP_LABEL: Record<FlowStationId, string> = { + playlist: "", + download: "to download", + transcode: "to transcode", + transcribe: "to transcribe", + digest: "to digest", + backfill: "to backfill", + }; + + const gaps: FlowGap[] = []; + for (let i = 0; i < order.length - 1; i++) { + const from = order[i]; + const to = order[i + 1]; + gaps.push({ + from, + to, + reachable: reachableInto[to], + label: GAP_LABEL[to], + stage: stationById[to].stage, + sidings: sidingsOf[to], + }); + } + + const biggest = gaps.reduce<FlowGap | null>( + (best, g) => (g.reachable > (best?.reachable ?? 0) ? g : best), + null, + ); + + return { + stations, + gaps, + bottleneck: biggest ? biggest.from : null, + next: pickNext(gaps), + }; +} + +// The ONE primary action. Deliberately the FURTHEST UPSTREAM gap with work +// rather than the biggest one: the pipeline is a line, so 1,600 videos waiting +// to be digested behind 113 that were never downloaded is not 1,600 jobs you can +// start — clearing the upstream gap is what makes the downstream one shrink. +// The bottleneck is still reported separately; it is the thing to LOOK at, not +// necessarily the thing to press. +function pickNext(gaps: FlowGap[]): ChannelFlow["next"] { + const ACTIONABLE: Partial<Record<StageId, string>> = { + download: "Download missing", + transcribe: "Transcribe pending", + digest: "Digest channel", + backfill: "Backfill", + }; + for (const gap of gaps) { + if (gap.reachable <= 0) continue; + const label = ACTIONABLE[gap.stage]; + if (!label) continue; + return { stage: gap.stage, label, count: gap.reachable }; + } + // Nothing reachable — including on a channel that has never been reported, + // where the counts are all zero because nothing has looked yet. That case is + // NOT offered here: NoReportYet already sits at the top of the page saying so + // and carrying the button, and a second control with the same accessible name + // is both a duplicate affordance and, as it turns out, a locator that matches + // two elements. + return null; +} + +// Null when ANY term is unknown. See invariant 2: a partial sum reported as a +// whole is worse than saying nothing. +function sumOrNull(values: (number | null)[]): number | null { + if (values.length === 0) return null; + let n = 0; + for (const v of values) { + if (v == null) return null; + n += v; + } + return n; +} + +// What a deferred video of this kind is waiting for, from the registry rather +// than hardcoded here. BackfillStage used to say "too long to diarize", which +// was true only while diarization was the sole kind that could defer. +function deferredHintFor( + kinds: BackfillKind[], + onlyId?: string, +): string | undefined { + const hints = kinds + .filter((k) => (onlyId ? k.id === onlyId : true)) + .map((k) => k.deferredHint) + .filter((h): h is string => Boolean(h)); + const unique = [...new Set(hints)]; + if (unique.length === 0) return undefined; + return unique.map((h) => `Waiting because they are ${h}`).join(" · "); +} + +// Which operations' output the lane's kinds are blocked on, by label — so a +// blocked count says what it is waiting FOR rather than merely that it is stuck. +function dependsOnHint(kinds: BackfillKind[]): string | undefined { + const labels = [ + ...new Set(kinds.flatMap((k) => (k.dependsOn ?? []).map(operationLabel))), + ]; + if (labels.length === 0) return undefined; + return `Waiting on ${labels.join(", ")}.`; +} diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts @@ -123,13 +123,28 @@ export type ComputeStageStatusesInput = { failedTranscodingIds: string[]; config: ChannelConfig; runningJobs: JobRecord[]; + // Whether ANY backfill kind is switched on. + // + // The snapshot's per-kind counts are a record of what was true when it was + // written, and they survive the operator turning the feature off. Without + // this flag the card reports "7 videos need derived data" — amber, with a + // count — directly above its own body copy saying "No backfill is enabled", + // and nothing would ever run the work it is advertising. Defaults to true so + // an omitted flag behaves as it always did. + backfillEnabled?: boolean; }; export function computeStageStatuses( input: ComputeStageStatusesInput, ): Record<StageId, StageStatus> { - const { snapshot, failedVideoIds, failedTranscodingIds, config, runningJobs } = - input; + const { + snapshot, + failedVideoIds, + failedTranscodingIds, + config, + runningJobs, + backfillEnabled = true, + } = input; const buckets = normalizeBuckets(snapshot.buckets); const undownloadedIds = snapshot.undownloadedIds ?? []; @@ -409,7 +424,11 @@ export function computeStageStatuses( // videos to this instrument on the measured corpus. The filter is by the kind's // declared lane rather than by its id, so the next operation registered on a // lane of its own does not re-arm the same trap. - const backfillEntries = laneEntriesOf(snapshot.backfill); + // A disabled lane has NO work, whatever the snapshot recorded before it was + // switched off — see `backfillEnabled` above. + const backfillEntries = backfillEnabled + ? laneEntriesOf(snapshot.backfill) + : []; const backfillPending = backfillEntries.reduce( (n, e) => n + reachableBackfillWork(e), 0, diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -1,10 +1,7 @@ import path from "node:path"; -import { readdir, readFile, stat } from "node:fs/promises"; -import type { Dirent } from "node:fs"; import type { ReactNode } from "react"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; import { countPosts, @@ -30,6 +27,7 @@ import { normalizeMaybeMissing, readChannelSnapshot, } from "yt-dlp-transcript-common/controller/channelSnapshot"; +import { countPlaylist } from "yt-dlp-transcript-common/controller/channels"; import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; import { loadFailedTranscodings } from "yt-dlp-transcript-common/controller/failedTranscodings"; import { savedVideoTotals } from "yt-dlp-transcript-common/controller/savedVideoInventory"; @@ -39,13 +37,7 @@ import { type ShardConfig, type ShardOp, } from "yt-dlp-transcript-common/controller/shard"; -import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server"; -import { loadAvailability } from "yt-dlp-transcript-common/lib/availability-server"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { resolvePrimaryVtt } from "yt-dlp-transcript-common/lib/videoStatus"; -import { readTranscriptCoverage } from "yt-dlp-transcript-common/controller/normalizeTranscript"; -import { isIncompleteTranscript } from "yt-dlp-transcript-common/lib/transcriptCoverage"; -import { isExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server"; import { platformQueueKey, queueKeyForUrl, @@ -67,8 +59,6 @@ import type { import { DeleteChannelForm } from "../components/DeleteChannelForm"; import { RenameChannelForm } from "../components/RenameChannelForm"; import { RunningJobsList } from "../../jobs/components/RunningJobsList"; -import { StagesView } from "./components/StagesView"; -import { StatusHeader } from "./components/StatusHeader"; import { CleanupStage } from "./components/stages/CleanupStage"; import { DiagnosticsStage } from "./components/stages/DiagnosticsStage"; import { DownloadStage } from "./components/stages/DownloadStage"; @@ -81,14 +71,13 @@ import { getBackfillKind, laneBackfillKinds, } from "yt-dlp-transcript-common/lib/backfillKinds"; -import { VideoListPane } from "./components/VideoListPane"; -import { VideoListPaneSection } from "./components/VideoListPaneSection"; -import { VideoPanel, type VideoFile } from "./videos/[id]/components/VideoPanel"; -import { parseFilters, filterRows, serializeFilters } from "./lib/videoRows"; -import { - computeVideoRows, - readDataDirVideoIds, -} from "./lib/videoRowsServer"; +import { ChannelLine } from "./components/flow/ChannelLine"; +import { NextAction } from "./components/flow/NextAction"; +import { AttentionStrip } from "./components/flow/AttentionStrip"; +import { StageSwitcher } from "./components/flow/StageSwitcher"; +import { OverviewPanel } from "./components/flow/OverviewPanel"; +import { computeChannelFlow } from "./lib/channelFlow"; +import { readChannelConfigCached } from "./lib/channelConfigCache"; import { computeStageStatuses, normalizeBuckets, @@ -103,49 +92,13 @@ import { export const dynamic = "force-dynamic"; -async function loadVideoDir( - channelDataDir: string, - videoId: string, -): Promise<{ files: VideoFile[] }> { - const dir = path.join(channelDataDir, videoId); - let entries: Dirent[]; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - return { files: [] }; - } - const files: VideoFile[] = []; - for (const e of entries) { - if (!e.isFile()) continue; - if (e.name.includes(".tmp-")) continue; - const s = await stat(path.join(dir, e.name)); - files.push({ name: e.name, size: s.size, mtime: s.mtimeMs }); - } - files.sort((a, b) => a.name.localeCompare(b.name)); - return { files }; -} - -async function loadVideoTitle( - channelDataDir: string, - videoId: string, -): Promise<string | null> { - const file = path.join(channelDataDir, videoId, "metadata.info.json"); - try { - const raw = await readFile(file, "utf8"); - const j = JSON.parse(raw); - return typeof j?.title === "string" ? j.title : null; - } catch { - return null; - } -} - export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }): Promise<Metadata> { const { slug } = await params; - const config = await readChannelConfig(getPaths(), slug); + const config = await readChannelConfigCached(slug); const subject = config?.name ?? slug; return { title: `${subject} — Channel` }; } @@ -159,20 +112,15 @@ export default async function ChannelDetailPage({ }) { const { slug } = await params; const sp = (await searchParams) ?? {}; - const rawVideo = sp.video; - const selectedVideoId = - typeof rawVideo === "string" && rawVideo.length > 0 ? rawVideo : null; - const rawFilter = typeof sp.filter === "string" ? sp.filter : undefined; - const filters = parseFilters(rawFilter); - const queryRaw = typeof sp.q === "string" ? sp.q : ""; const paths = getPaths(); - const config = await readChannelConfig(paths, slug); + const config = await readChannelConfigCached(slug); if (!config) notFound(); // A social (posts) channel short-circuits the whole video pipeline view: it // has no downloads, audio, transcripts or availability, so none of the six - // stages or their pathology buckets apply. It gets a minimal Fetch → Index - // rail instead (see SocialChannelPanel). + // stations or their pathology buckets apply. It gets a minimal Fetch → Index + // rail instead (see SocialChannelPanel). Giving it the same line shape is a + // good follow-up, not this change. if (isSocialChannel(config)) { const channelRoot = path.join(paths.channelsDir, slug); const [postCount, shards, fetchState, postAvailability] = await Promise.all([ @@ -192,7 +140,6 @@ export default async function ChannelDetailPage({ ); return ( <div className="flex flex-col gap-6"> - <h1 className="text-lg font-medium">{config.name ?? slug}</h1> <RunningJobsList jobs={socialRunningJobs.map((j) => ({ id: j.id, @@ -228,9 +175,6 @@ export default async function ChannelDetailPage({ ); } - const update = updateChannelAction.bind(null, slug); - const del = deleteChannelAction.bind(null, slug); - const rename = renameChannelAction.bind(null, slug); const registry = getRegistry(); const existingQueues = registry.activeQueueNames(); const runningJobs = registry @@ -250,8 +194,7 @@ export default async function ChannelDetailPage({ // generate; it reads what's there and OFFERS to generate what isn't. // // With no report, the page renders normally against an empty one rather than - // replacing itself with a placeholder: the video list comes from the data - // directory, not the snapshot, and the pipeline controls (Download videos, + // replacing itself with a placeholder: the pipeline controls (Download videos, // Transcribe, Sync) are exactly what you want available on a channel that has // never been analysed. Only the derived buckets and counts are empty, and // NoReportYet says so at the top. @@ -262,32 +205,17 @@ export default async function ChannelDetailPage({ buckets: normalizeBuckets(undefined), undownloadedIds: [], }; - // Saved-video store summary for this channel + whether backups are configured, - // for the Cleanup stage's Retention & persistence section (Phase 5). - const savedTotals = await savedVideoTotals({ paths, channelSlug: slug }); const settings = getSettings(); - const backupConfigured = settings.savedVideoBackup.dest.trim() !== ""; - // The retry-failures panel is interactive (a click mutates the file), so - // read it fresh on every render — the snapshot bucket only reflects state + // The retry-failures panels are interactive (a click mutates the file), so + // read them fresh on every render — the snapshot bucket only reflects state // at refresh time. const rawFailedVideoIds = await loadFailedTranscriptions(paths, slug); const failedTranscodingIds = await loadFailedTranscodings(paths, slug); - - const summarize = (c: ShardConfig | null) => - c - ? { - totalShards: c.totalShards, - shardIndex: c.shardIndex, - itemCount: c.items.length, - createdAt: c.createdAt, - } - : null; - const [downloadMissingShard, transcribeMissingShard, availabilityShard] = - await Promise.all( - ( - ["download-missing", "transcribe-missing", "availability"] as ShardOp[] - ).map((op) => loadShardConfig(paths, slug, op).then(summarize)), - ); + // ONE small readFile — the first station's denominator. Explicitly not the + // corpus walk that noCorpusWalkInRenderPaths.test.ts bans. + const playlistCount = await countPlaylist( + path.join(paths.channelsDir, slug, "playlist"), + ); const buckets = normalizeBuckets(snapshot.buckets); const undownloadedIds = snapshot.undownloadedIds ?? []; @@ -307,25 +235,22 @@ export default async function ChannelDetailPage({ const actionableDownloadedNoTranscriptIds = buckets.downloadedNoTranscript.filter( (id) => !excludedDownloadIds.has(id), ); - const availability = normalizeAvailability(snapshot.availability); - const maybeMissing = normalizeMaybeMissing(snapshot.maybeMissing); + const transcodeApplies = + config.handling === "transcribe" && !!config.audioFormat; + // Enabled lane backfills. A settings read, no I/O. + // + // laneBackfillKinds, still: this is the BACKFILL lane's list, and the snapshot + // now carries an entry for every catalog operation including digest, which has + // its own station and its own queue key. + const backfillKinds = laneBackfillKinds(settings); const stages = computeStageStatuses({ snapshot, failedVideoIds, failedTranscodingIds, config, runningJobs, + backfillEnabled: backfillKinds.length > 0, }); - const transcodeApplies = - config.handling === "transcribe" && !!config.audioFormat; - // Enabled lane backfills, for the stage card. A settings read, no I/O. - // - // laneBackfillKinds, still: this card is the BACKFILL lane's, and the snapshot - // now carries an entry for every catalog operation including digest, which has - // its own card and its own queue key. - const backfillKinds = laneBackfillKinds(settings); - // The digest operation's work list, from the registry or the legacy bucket. - const digestWork = digestWorkOf(snapshot); const stageOrder: StageId[] = [ "configure", @@ -340,307 +265,289 @@ export default async function ChannelDetailPage({ "danger", ]; - // Sites membership section of the configure form: every configured site plus - // this channel's current membership (and group) on each. - const allSites = listSites(paths); - const siteOptions: SiteMembershipOption[] = allSites.map((s) => ({ - siteId: s.siteId, - siteTitle: s.siteTitle, - defaultGroupId: s.defaultGroupId, - groups: sortGroups(s.groups).map((g) => ({ id: g.id, name: g.name })), - })); - const initialMemberships: InitialMembership[] = allSites.flatMap((s) => { - const m = s.channels.find((c) => c.slug === slug); - if (!m) return []; - return [ - m.groupId - ? { siteId: s.siteId, groupId: m.groupId } - : { siteId: s.siteId }, - ]; - }); + // `?stage=` replaces the old `#stage-` hash: the selection is server-rendered, + // shareable, and survives the global AutoRefresh's router.refresh(). An + // unknown or absent value lands on the overview rather than 404ing — a saved + // link to a stage that no longer applies (e.g. transcode) should still open + // the channel. + const rawStage = typeof sp.stage === "string" ? sp.stage : undefined; + const selectedStage: StageId | null = + (stageOrder.find((id) => id === rawStage) as StageId | undefined) ?? null; - const panels: Partial<Record<StageId, ReactNode>> = { - configure: ( - <ChannelFormClient - action={ - update as ( - prev: ActionResult, - formData: FormData, - ) => Promise<ActionResult> - } - initial={{ slug, config }} - submitLabel="Save changes" - sites={siteOptions} - initialMemberships={initialMemberships} - /> - ), - playlist: ( - <PlaylistStage - slug={slug} - hasUrl={!!config.url} - defaultQueueKey={platformDefaultQueueKey} - existingQueues={existingQueues} - /> - ), - download: ( - <DownloadStage - slug={slug} - hasUrl={!!config.url} - defaultQueueKey={platformDefaultQueueKey} - existingQueues={existingQueues} - undownloadedIds={undownloadedIds} - excludedFromDownload={excludedFromDownload} - noTranscriptIds={actionableNoTranscriptIds} - partialDownloadIds={buckets.partialDownloads} - needsCookiesIds={buckets.needsCookies} - missingShard={downloadMissingShard} - /> - ), - transcribe: ( - <TranscribeStage - slug={slug} - existingQueues={existingQueues} - failedVideoIds={failedVideoIds} - downloadedNoTranscriptIds={actionableDownloadedNoTranscriptIds} - autoSubsOnlyIds={buckets.autoSubsOnly} - downloadedAutoSubsOnlyIds={buckets.downloadedAutoSubsOnly} - defaultQueueKey={TRANSCRIPTION_QUEUE} - downloadQueueKey={platformDefaultQueueKey} - missingShard={transcribeMissingShard} - /> - ), - digest: ( - <DigestStage - slug={slug} - existingQueues={existingQueues} - // From the operation registry, falling back to the legacy `noDigest` - // bucket for a snapshot written before it (digestWorkOf). A third of the - // channels on disk are months old, so the fallback is what stops them - // reading as fully digested until the regen reaches them. - noDigestIds={digestWork.ids} - blocked={digestWork.blocked} - deferred={digestWork.deferred} - partial={digestWork.partial} - localQueueKey={DIGEST_LOCAL_QUEUE} - remoteQueueKey={DIGEST_REMOTE_QUEUE} - remoteEnabled={settings.digest.remoteEnabled} - /> - ), - backfill: ( - <BackfillStage - slug={slug} - existingQueues={existingQueues} - defaultQueueKey={BACKFILL_QUEUE} - // Labels and hints come from the registry HERE, on the server: it reads - // the filesystem and calls controllers, so a client component must never - // import it. Only ENABLED kinds are passed — a disabled feature reports - // no backfill anywhere. - kinds={backfillKinds.map((kind) => { - const entry = snapshot.backfill?.[kind.id]; - return { - id: kind.id, - label: kind.label, - reachableIds: entry?.ids ?? [], - missingInput: entry?.missingInput ?? 0, - // ?? 0 is load-bearing, not defensive: snapshots written before the - // cap existed have no `deferred` field, and .toLocaleString() on - // undefined throws in the render path. - deferred: entry?.deferred ?? 0, - // From the registry, not the card: the card sums several kinds and - // cannot know why any one of them deferred. - deferredHint: kind.deferredHint, - // Same reason: every snapshot on disk predates this field. - blocked: entry?.blocked ?? 0, - // Resolved here, on the server, because BACKFILL_KIND_BY_ID reads - // the filesystem and must never reach a client component. - dependsOnLabels: (kind.dependsOn ?? []) - .map((id) => getBackfillKind(id)?.label) - .filter((l): l is string => Boolean(l)), - stale: entry?.stale ?? 0, - }; - })} - allowRedownload={settings.backfill.allowRedownload} - anyEnabled={backfillKinds.length > 0} - /> - ), - cleanup: ( - <CleanupStage - slug={slug} - existingQueues={existingQueues} - multipleAudioFormatIds={buckets.multipleAudioFormats} - supersededAutoSubsIds={buckets.supersededAutoSubs} - foreignAudioIds={[ - ...new Set([ - ...buckets.untranscoded, - ...buckets.multipleAudioFormats, - ]), - ].sort()} - transcodeApplies={transcodeApplies} - transcribedAudioBytes={snapshot.cleanupBytes?.transcribedWithAudio ?? 0} - extraFormatsBytes={snapshot.cleanupBytes?.multipleAudioFormats ?? 0} - foreignAudioBytes={snapshot.cleanupBytes?.foreignAudio ?? 0} - keepLatest={config.keepLatest ?? 0} - keptCount={snapshot.keptCount ?? 0} - savedCount={savedTotals.count} - savedBytes={savedTotals.bytes} - backupConfigured={backupConfigured} - diarizationEnabled={settings.diarization.enabled} - /> - ), - diagnostics: ( - <DiagnosticsStage - slug={slug} - untranscribableIds={buckets.untranscribable} - noMetadataIds={buckets.noMetadata} - missingFromArchiveIds={buckets.missingFromArchive} - duplicateDirIds={buckets.duplicateDirs} - nonStandardVttIds={buckets.nonStandardVtt} - skippedByFilterIds={buckets.skippedByFilter} - totals={snapshot.totals} - availability={availability} - maybeMissing={maybeMissing} - missingNeverFetched={snapshot.missingNeverFetched ?? []} - availabilityShard={availabilityShard} - existingQueues={existingQueues} - downloadDefaultQueueKey={platformDefaultQueueKey} - /> - ), - danger: ( - <div className="flex flex-col gap-4"> - <RenameChannelForm - slug={slug} - action={ - rename as ( - prev: ActionResult, - formData: FormData, - ) => Promise<ActionResult> - } - /> - <hr className="border-border" /> - <DeleteChannelForm - slug={slug} - action={ - del as ( - prev: ActionResult, - formData: FormData, - ) => Promise<ActionResult> - } - /> - </div> - ), - }; - if (transcodeApplies) { - panels.transcode = ( - <TranscodeStage - slug={slug} - existingQueues={existingQueues} - failedTranscodingIds={failedTranscodingIds} - untranscodedIds={buckets.untranscoded} - transcodeDefaultQueueKey={TRANSCRIPTION_QUEUE} - /> - ); - } - - const channelDataDir = path.join(paths.channelsDir, slug, "data"); - const channelDataDirIds = await readDataDirVideoIds(channelDataDir); - const rows = computeVideoRows({ - channelDataDirIds, + const flow = computeChannelFlow({ snapshot, - failedTranscriptionIds: failedVideoIds, + stages, + config, + failedVideoIds, failedTranscodingIds, transcodeApplies, - runningJobs, - excludedIds: excludedDownloadIds, + backfillKinds, + playlistCount, }); - const filteredRows = filterRows(rows, filters); - const q = queryRaw.trim().toLowerCase(); - const orderedRows = q - ? filteredRows.filter((r) => r.id.toLowerCase().includes(q)) - : filteredRows; - const selectedIndex = selectedVideoId - ? orderedRows.findIndex((r) => r.id === selectedVideoId) - : -1; - const buildVideoHref = (videoId: string): string => { - const params = new URLSearchParams(); - const filterParam = serializeFilters(filters); - if (filterParam) params.set("filter", filterParam); - if (queryRaw) params.set("q", queryRaw); - params.set("video", videoId); - return `/channels/${slug}?${params.toString()}`; - }; + // ONLY THE SELECTED PANEL IS BUILT. + // + // Every load below this line belongs to exactly one panel, and each is behind + // the branch that needs it — the shard configs, the saved-video totals, the + // site list. Rendering all ten panels on every request is what put four file + // reads and a directory walk in front of a page whose default view needs + // neither. `panel` is null on the overview, which does no I/O at all. + const panel = selectedStage + ? await buildPanel(selectedStage) + : null; - let selectedPanel: ReactNode = null; - let selectedTitle: string | null = null; - if (selectedVideoId) { - const [dirData, title, outcome, availabilityRecord, cov, excludedTrunc] = - await Promise.all([ - loadVideoDir(channelDataDir, selectedVideoId), - loadVideoTitle(channelDataDir, selectedVideoId), - loadDownloadOutcome(path.join(channelDataDir, selectedVideoId)), - loadAvailability(path.join(channelDataDir, selectedVideoId)), - readTranscriptCoverage(path.join(channelDataDir, selectedVideoId)), - isExcludedFromTruncatedCheck( - path.join(channelDataDir, selectedVideoId), - ), - ]); - selectedTitle = title; - const coverage = cov - ? { - lastCueEnd: cov.cov.lastCueEnd, - duration: cov.cov.duration, - coverage: cov.cov.coverage, - incomplete: - !excludedTrunc && - isIncompleteTranscript(cov.cov, { - isLivestream: cov.isLivestream, - }), - } - : null; - const prevRow = - selectedIndex > 0 ? orderedRows[selectedIndex - 1] : undefined; - const nextRow = - selectedIndex >= 0 && selectedIndex < orderedRows.length - 1 - ? orderedRows[selectedIndex + 1] - : undefined; - selectedPanel = ( - <VideoPanel - slug={slug} - videoId={selectedVideoId} - files={dirData.files} - primaryVtt={resolvePrimaryVtt(dirData.files.map((f) => f.name))} - handling={config.handling} - defaultQueueKey={platformDefaultQueueKey} - existingQueues={existingQueues} - downloadOutcome={outcome} - availabilityHistory={availabilityRecord?.history ?? []} - channelAudioFormat={config.audioFormat} - excludedFromTruncatedCheck={excludedTrunc} - coverage={coverage} - prevHref={prevRow ? buildVideoHref(prevRow.id) : undefined} - nextHref={nextRow ? buildVideoHref(nextRow.id) : undefined} - position={ - selectedIndex >= 0 - ? { index: selectedIndex, total: orderedRows.length } - : undefined - } - /> - ); + async function buildPanel(id: StageId): Promise<ReactNode> { + const summarize = (c: ShardConfig | null) => + c + ? { + totalShards: c.totalShards, + shardIndex: c.shardIndex, + itemCount: c.items.length, + createdAt: c.createdAt, + } + : null; + const shard = async (op: ShardOp) => + summarize(await loadShardConfig(paths, slug, op)); + + switch (id) { + case "configure": { + // Sites membership section: every configured site plus this channel's + // current membership (and group) on each. + const allSites = listSites(paths); + const siteOptions: SiteMembershipOption[] = allSites.map((s) => ({ + siteId: s.siteId, + siteTitle: s.siteTitle, + defaultGroupId: s.defaultGroupId, + groups: sortGroups(s.groups).map((g) => ({ id: g.id, name: g.name })), + })); + const initialMemberships: InitialMembership[] = allSites.flatMap((s) => { + const m = s.channels.find((c) => c.slug === slug); + if (!m) return []; + return [ + m.groupId + ? { siteId: s.siteId, groupId: m.groupId } + : { siteId: s.siteId }, + ]; + }); + return ( + <ChannelFormClient + action={ + updateChannelAction.bind(null, slug) as ( + prev: ActionResult, + formData: FormData, + ) => Promise<ActionResult> + } + initial={{ slug, config: config! }} + submitLabel="Save changes" + sites={siteOptions} + initialMemberships={initialMemberships} + /> + ); + } + case "playlist": + return ( + <PlaylistStage + slug={slug} + hasUrl={!!config!.url} + defaultQueueKey={platformDefaultQueueKey} + existingQueues={existingQueues} + /> + ); + case "download": + return ( + <DownloadStage + slug={slug} + hasUrl={!!config!.url} + defaultQueueKey={platformDefaultQueueKey} + existingQueues={existingQueues} + undownloadedIds={undownloadedIds} + excludedFromDownload={excludedFromDownload} + noTranscriptIds={actionableNoTranscriptIds} + partialDownloadIds={buckets.partialDownloads} + needsCookiesIds={buckets.needsCookies} + missingShard={await shard("download-missing")} + /> + ); + case "transcode": + return ( + <TranscodeStage + slug={slug} + existingQueues={existingQueues} + failedTranscodingIds={failedTranscodingIds} + untranscodedIds={buckets.untranscoded} + transcodeDefaultQueueKey={TRANSCRIPTION_QUEUE} + /> + ); + case "transcribe": + return ( + <TranscribeStage + slug={slug} + existingQueues={existingQueues} + failedVideoIds={failedVideoIds} + downloadedNoTranscriptIds={actionableDownloadedNoTranscriptIds} + autoSubsOnlyIds={buckets.autoSubsOnly} + downloadedAutoSubsOnlyIds={buckets.downloadedAutoSubsOnly} + defaultQueueKey={TRANSCRIPTION_QUEUE} + downloadQueueKey={platformDefaultQueueKey} + missingShard={await shard("transcribe-missing")} + /> + ); + case "digest": { + // From the operation registry, falling back to the legacy `noDigest` + // bucket for a snapshot written before it (digestWorkOf). A third of the + // channels on disk are months old, so the fallback is what stops them + // reading as fully digested until the regen reaches them. + const digestWork = digestWorkOf(snapshot); + return ( + <DigestStage + slug={slug} + existingQueues={existingQueues} + noDigestIds={digestWork.ids} + blocked={digestWork.blocked} + deferred={digestWork.deferred} + partial={digestWork.partial} + localQueueKey={DIGEST_LOCAL_QUEUE} + remoteQueueKey={DIGEST_REMOTE_QUEUE} + remoteEnabled={settings.digest.remoteEnabled} + /> + ); + } + case "backfill": + return ( + <BackfillStage + slug={slug} + existingQueues={existingQueues} + defaultQueueKey={BACKFILL_QUEUE} + // Labels and hints come from the registry HERE, on the server: it + // reads the filesystem and calls controllers, so a client component + // must never import it. Only ENABLED kinds are passed — a disabled + // feature reports no backfill anywhere. + kinds={backfillKinds.map((kind) => { + const entry = snapshot.backfill?.[kind.id]; + return { + id: kind.id, + label: kind.label, + reachableIds: entry?.ids ?? [], + missingInput: entry?.missingInput ?? 0, + // ?? 0 is load-bearing, not defensive: snapshots written before + // the cap existed have no `deferred` field, and + // .toLocaleString() on undefined throws in the render path. + deferred: entry?.deferred ?? 0, + // From the registry, not the card: the card sums several kinds + // and cannot know why any one of them deferred. + deferredHint: kind.deferredHint, + // Same reason: every snapshot on disk predates this field. + blocked: entry?.blocked ?? 0, + // Resolved here, on the server, because BACKFILL_KIND_BY_ID + // reads the filesystem and must never reach a client component. + dependsOnLabels: (kind.dependsOn ?? []) + .map((id) => getBackfillKind(id)?.label) + .filter((l): l is string => Boolean(l)), + stale: entry?.stale ?? 0, + }; + })} + allowRedownload={settings.backfill.allowRedownload} + anyEnabled={backfillKinds.length > 0} + /> + ); + case "cleanup": { + // Saved-video store summary for this channel + whether backups are + // configured, for the Retention & persistence section. + const savedTotals = await savedVideoTotals({ paths, channelSlug: slug }); + return ( + <CleanupStage + slug={slug} + existingQueues={existingQueues} + multipleAudioFormatIds={buckets.multipleAudioFormats} + supersededAutoSubsIds={buckets.supersededAutoSubs} + foreignAudioIds={[ + ...new Set([ + ...buckets.untranscoded, + ...buckets.multipleAudioFormats, + ]), + ].sort()} + transcodeApplies={transcodeApplies} + transcribedAudioBytes={ + snapshot.cleanupBytes?.transcribedWithAudio ?? 0 + } + extraFormatsBytes={snapshot.cleanupBytes?.multipleAudioFormats ?? 0} + foreignAudioBytes={snapshot.cleanupBytes?.foreignAudio ?? 0} + keepLatest={config!.keepLatest ?? 0} + keptCount={snapshot.keptCount ?? 0} + savedCount={savedTotals.count} + savedBytes={savedTotals.bytes} + backupConfigured={settings.savedVideoBackup.dest.trim() !== ""} + diarizationEnabled={settings.diarization.enabled} + /> + ); + } + case "diagnostics": + return ( + <DiagnosticsStage + slug={slug} + untranscribableIds={buckets.untranscribable} + noMetadataIds={buckets.noMetadata} + missingFromArchiveIds={buckets.missingFromArchive} + duplicateDirIds={buckets.duplicateDirs} + nonStandardVttIds={buckets.nonStandardVtt} + skippedByFilterIds={buckets.skippedByFilter} + totals={snapshot.totals} + availability={normalizeAvailability(snapshot.availability)} + maybeMissing={normalizeMaybeMissing(snapshot.maybeMissing)} + missingNeverFetched={snapshot.missingNeverFetched ?? []} + availabilityShard={await shard("availability")} + existingQueues={existingQueues} + downloadDefaultQueueKey={platformDefaultQueueKey} + /> + ); + case "danger": + return ( + <div className="flex flex-col gap-4"> + <RenameChannelForm + slug={slug} + action={ + renameChannelAction.bind(null, slug) as ( + prev: ActionResult, + formData: FormData, + ) => Promise<ActionResult> + } + /> + <hr className="border-border" /> + <DeleteChannelForm + slug={slug} + action={ + deleteChannelAction.bind(null, slug) as ( + prev: ActionResult, + formData: FormData, + ) => Promise<ActionResult> + } + /> + </div> + ); + } } return ( <div className="flex flex-col gap-6"> {!existing && <NoReportYet slug={slug} />} - <StatusHeader + + <ChannelLine flow={flow} slug={slug} /> + + {/* The shortcut exists so you do not have to dig into a stage to start the + obvious thing. Once a stage IS open its own controls are right there, + and a second button that queues the same job — under the same name — + is a duplicate affordance rather than a shortcut. */} + {selectedStage === null && <NextAction flow={flow} slug={slug} />} + + <AttentionStrip slug={slug} - channelName={config.name ?? slug} - generatedAt={snapshot.generatedAt} - // Always false now: this page no longer generates a snapshot during the - // render, so one can never have been produced a moment ago. The header - // shows `generatedAt` instead, which is the honest signal anyway. - fresh={false} - stages={stages} + buckets={buckets} + digestWarnings={snapshot.buckets?.digestWarnings ?? []} + failedVideoIds={failedVideoIds} + failedTranscodingIds={failedTranscodingIds} transcodeApplies={transcodeApplies} + generatedAt={snapshot.generatedAt} + lastSyncedAt={config.lastSyncedAt} /> <RunningJobsList @@ -655,68 +562,52 @@ export default async function ChannelDetailPage({ hideChannelSlug /> - <VideoListPaneSection - left={ - <VideoListPane - slug={slug} - rows={rows} - selectedVideoId={selectedVideoId} - initialFilters={[...filters]} - initialQuery={queryRaw} - defaultTranscribeQueue={TRANSCRIPTION_QUEUE} - defaultDownloadQueue={platformDefaultQueueKey} - existingQueues={existingQueues} - /> - } - right={ - <> - {selectedPanel ?? ( - <EmptyVideoPanel - total={rows.length} - filtered={orderedRows.length} - /> - )} - {selectedVideoId && selectedTitle && ( + <div className="flex flex-col gap-4"> + <StageSwitcher + slug={slug} + stageOrder={stageOrder} + stages={stages} + selected={selectedStage} + /> + {selectedStage ? ( + <section + id={`stage-${selectedStage}`} + aria-label={`${stages[selectedStage].title} stage`} + className="rounded-md border border-border bg-card" + > + <div className="flex items-baseline gap-3 px-4 py-3 border-b border-border"> + <h2 + className={`text-lg font-semibold ${ + selectedStage === "danger" ? "text-destructive" : "" + }`} + > + {stages[selectedStage].title} + </h2> <p - className="text-sm text-muted-foreground mt-2 truncate" - aria-label="video title" + aria-label={`${stages[selectedStage].title} stage summary`} + className="text-sm text-muted-foreground truncate" > - {selectedTitle} + {stages[selectedStage].summary} </p> - )} - </> - } - /> - - <details - open - className="rounded-md border border-border bg-card" - > - <summary className="cursor-pointer px-4 py-3 text-sm font-medium select-none"> - Channel pipeline & settings - </summary> - <div className="px-4 pb-4"> - <StagesView stages={stages} stageOrder={stageOrder} panels={panels} /> - </div> - </details> - </div> - ); -} - -function EmptyVideoPanel({ - total, - filtered, -}: { - total: number; - filtered: number; -}) { - return ( - <div className="flex items-center justify-center text-sm text-muted-foreground border border-dashed border-border rounded-md p-12 text-center"> - <div className="flex flex-col gap-1"> - <p>Select a video from the list to view actions.</p> - <p className="text-xs"> - {filtered} matching · {total} total - </p> + </div> + <div className="px-4 py-4 flex flex-col gap-4">{panel}</div> + </section> + ) : ( + <OverviewPanel + slug={slug} + stageOrder={stageOrder} + stages={stages} + essentials={{ + handling: config.handling, + platform: config.platform, + url: config.url, + cookieMode: config.cookieMode, + keepLatest: config.keepLatest, + }} + generatedAt={snapshot.generatedAt} + lastSyncedAt={config.lastSyncedAt} + /> + )} </div> </div> ); diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx @@ -226,7 +226,10 @@ export default async function VideoDetailPage({ </div> <header className="flex flex-col gap-2"> - <h1 className="text-2xl font-semibold">{meta.title ?? id}</h1> + {/* h2, not h1. The channel layout above this route owns the page's + single h1 (the channel name); a second one would make "the page's + heading" ambiguous to a screen reader and to a locator. */} + <h2 className="text-2xl font-semibold">{meta.title ?? id}</h2> <div className="flex flex-wrap gap-3 text-sm text-muted-foreground"> {meta.webpageUrl && ( <a diff --git a/editor/app/channels/[slug]/videos/components/VideoWorkspace.tsx b/editor/app/channels/[slug]/videos/components/VideoWorkspace.tsx @@ -0,0 +1,64 @@ +"use client"; + +import type { ReactNode } from "react"; +import { VideoListPane } from "../../components/VideoListPane"; +import type { VideoFilter, VideoRow } from "../../lib/videoRows"; + +// The two-pane video workspace: the list on the left, the selected video's +// detail on the right. +// +// This replaces VideoListPaneSection, and the things it does NOT do are the +// point. That component carried a localStorage left-collapse toggle, a +// selection-driven right-collapse, and a double-requestAnimationFrame +// scrollIntoView dance — all of it there because the pane was squeezed onto a +// page it shared with eight stage panels and a sticky header. On a route of its +// own there is nothing to make room for: both panes are simply visible, the +// grid is fixed, and nothing has to scroll itself into view. +// +// `detail` arrives as a prop rather than being rendered here because it is a +// SERVER component (VideoPanel reads the video's directory, download outcome and +// transcript coverage). Passing the element through keeps the list interactive +// without dragging that I/O into the client. + +export function VideoWorkspace({ + slug, + rows, + selectedVideoId, + initialFilters, + initialQuery, + defaultTranscribeQueue, + defaultDownloadQueue, + existingQueues, + detail, +}: { + slug: string; + rows: VideoRow[]; + selectedVideoId: string | null; + initialFilters: VideoFilter[]; + initialQuery: string; + defaultTranscribeQueue: string; + defaultDownloadQueue: string; + existingQueues: string[]; + detail: ReactNode; +}) { + return ( + <section + aria-label="video workspace" + className="grid grid-cols-1 lg:grid-cols-[24rem_minmax(0,1fr)] gap-4 lg:gap-6 min-h-[calc(100vh-14rem)]" + > + <div className="flex flex-col min-h-0 lg:sticky lg:self-start lg:top-24 lg:max-h-[calc(100vh-8rem)]"> + <VideoListPane + slug={slug} + rows={rows} + selectedVideoId={selectedVideoId} + initialFilters={initialFilters} + initialQuery={initialQuery} + defaultTranscribeQueue={defaultTranscribeQueue} + defaultDownloadQueue={defaultDownloadQueue} + existingQueues={existingQueues} + /> + </div> + <div className="min-w-0">{detail}</div> + </section> + ); +} diff --git a/editor/app/channels/[slug]/videos/page.tsx b/editor/app/channels/[slug]/videos/page.tsx @@ -0,0 +1,269 @@ +import path from "node:path"; +import { readdir, readFile, stat } from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; +import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; +import { + excludedDownloadIdSet, + readChannelSnapshot, +} from "yt-dlp-transcript-common/controller/channelSnapshot"; +import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; +import { loadFailedTranscodings } from "yt-dlp-transcript-common/controller/failedTranscodings"; +import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server"; +import { loadAvailability } from "yt-dlp-transcript-common/lib/availability-server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { resolvePrimaryVtt } from "yt-dlp-transcript-common/lib/videoStatus"; +import { readTranscriptCoverage } from "yt-dlp-transcript-common/controller/normalizeTranscript"; +import { isIncompleteTranscript } from "yt-dlp-transcript-common/lib/transcriptCoverage"; +import { isExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server"; +import { + platformQueueKey, + queueKeyForUrl, + TRANSCRIPTION_QUEUE, +} from "yt-dlp-transcript-common/lib/platform"; +import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { VideoPanel, type VideoFile } from "../videos/[id]/components/VideoPanel"; +import { VideoWorkspace } from "./components/VideoWorkspace"; +import { readChannelConfigCached } from "../lib/channelConfigCache"; +import { parseFilters, filterRows, serializeFilters } from "../lib/videoRows"; +import { computeVideoRows, readDataDirVideoIds } from "../lib/videoRowsServer"; +import { normalizeBuckets } from "../lib/stageStatus"; + +export const dynamic = "force-dynamic"; + +async function loadVideoDir( + channelDataDir: string, + videoId: string, +): Promise<{ files: VideoFile[] }> { + const dir = path.join(channelDataDir, videoId); + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return { files: [] }; + } + const files: VideoFile[] = []; + for (const e of entries) { + if (!e.isFile()) continue; + if (e.name.includes(".tmp-")) continue; + const s = await stat(path.join(dir, e.name)); + files.push({ name: e.name, size: s.size, mtime: s.mtimeMs }); + } + files.sort((a, b) => a.name.localeCompare(b.name)); + return { files }; +} + +async function loadVideoTitle( + channelDataDir: string, + videoId: string, +): Promise<string | null> { + const file = path.join(channelDataDir, videoId, "metadata.info.json"); + try { + const raw = await readFile(file, "utf8"); + const j = JSON.parse(raw); + return typeof j?.title === "string" ? j.title : null; + } catch { + return null; + } +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise<Metadata> { + const { slug } = await params; + const config = await readChannelConfigCached(slug); + return { title: `${config?.name ?? slug} — Videos` }; +} + +export default async function ChannelVideosPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams?: Promise<Record<string, string | string[] | undefined>>; +}) { + const { slug } = await params; + const sp = (await searchParams) ?? {}; + const rawVideo = sp.video; + const selectedVideoId = + typeof rawVideo === "string" && rawVideo.length > 0 ? rawVideo : null; + const rawFilter = typeof sp.filter === "string" ? sp.filter : undefined; + const filters = parseFilters(rawFilter); + const queryRaw = typeof sp.q === "string" ? sp.q : ""; + + const paths = getPaths(); + const config = await readChannelConfigCached(slug); + if (!config) notFound(); + // A social channel has posts, not videos — there is no data directory to list + // and nothing here would render. 404 rather than an empty workspace. + if (isSocialChannel(config)) notFound(); + + const registry = getRegistry(); + const existingQueues = registry.activeQueueNames(); + const runningJobs = registry + .list() + .filter( + (j) => + j.channelSlug === slug && + (j.status === "running" || j.status === "queued"), + ); + const platformDefaultQueueKey = config.platform + ? platformQueueKey(config.platform) + : queueKeyForUrl(config.url); + + // Read what's there; never generate. Same rule as the overview page — a + // snapshot regen walks every video directory and must not run inside a GET. + // The list itself comes from the DATA DIRECTORY, not the snapshot, so a + // channel that has never been analysed still shows all of its videos. + const existing = await readChannelSnapshot(paths, slug); + const snapshot: ChannelSnapshot = existing ?? { + generatedAt: "", + totals: { videos: 0, transcribed: 0, downloaded: 0 }, + buckets: normalizeBuckets(undefined), + undownloadedIds: [], + }; + const excludedDownloadIds = excludedDownloadIdSet(snapshot); + const failedVideoIds = (await loadFailedTranscriptions(paths, slug)).filter( + (id) => !excludedDownloadIds.has(id), + ); + const failedTranscodingIds = await loadFailedTranscodings(paths, slug); + const transcodeApplies = + config.handling === "transcribe" && !!config.audioFormat; + + const channelDataDir = path.join(paths.channelsDir, slug, "data"); + const channelDataDirIds = await readDataDirVideoIds(channelDataDir); + const rows = computeVideoRows({ + channelDataDirIds, + snapshot, + failedTranscriptionIds: failedVideoIds, + failedTranscodingIds, + transcodeApplies, + runningJobs, + excludedIds: excludedDownloadIds, + }); + + // The server's view of the ordering, used only for the detail pane's prev/next + // and position readout. The list itself re-filters on the client, which is why + // chip toggles don't navigate. + const filteredRows = filterRows(rows, filters); + const q = queryRaw.trim().toLowerCase(); + const orderedRows = q + ? filteredRows.filter((r) => r.id.toLowerCase().includes(q)) + : filteredRows; + const selectedIndex = selectedVideoId + ? orderedRows.findIndex((r) => r.id === selectedVideoId) + : -1; + + const buildVideoHref = (videoId: string): string => { + const params = new URLSearchParams(); + const filterParam = serializeFilters(filters); + if (filterParam) params.set("filter", filterParam); + if (queryRaw) params.set("q", queryRaw); + params.set("video", videoId); + return `/channels/${slug}/videos?${params.toString()}`; + }; + + let detail = ( + <EmptyVideoPanel total={rows.length} filtered={orderedRows.length} /> + ); + if (selectedVideoId) { + const videoDir = path.join(channelDataDir, selectedVideoId); + const [dirData, title, outcome, availabilityRecord, cov, excludedTrunc] = + await Promise.all([ + loadVideoDir(channelDataDir, selectedVideoId), + loadVideoTitle(channelDataDir, selectedVideoId), + loadDownloadOutcome(videoDir), + loadAvailability(videoDir), + readTranscriptCoverage(videoDir), + isExcludedFromTruncatedCheck(videoDir), + ]); + const coverage = cov + ? { + lastCueEnd: cov.cov.lastCueEnd, + duration: cov.cov.duration, + coverage: cov.cov.coverage, + incomplete: + !excludedTrunc && + isIncompleteTranscript(cov.cov, { isLivestream: cov.isLivestream }), + } + : null; + const prevRow = + selectedIndex > 0 ? orderedRows[selectedIndex - 1] : undefined; + const nextRow = + selectedIndex >= 0 && selectedIndex < orderedRows.length - 1 + ? orderedRows[selectedIndex + 1] + : undefined; + detail = ( + <> + <VideoPanel + slug={slug} + videoId={selectedVideoId} + files={dirData.files} + primaryVtt={resolvePrimaryVtt(dirData.files.map((f) => f.name))} + handling={config.handling} + defaultQueueKey={platformDefaultQueueKey} + existingQueues={existingQueues} + downloadOutcome={outcome} + availabilityHistory={availabilityRecord?.history ?? []} + channelAudioFormat={config.audioFormat} + excludedFromTruncatedCheck={excludedTrunc} + coverage={coverage} + // The standalone /videos/[id] page has no list to walk, which is why + // the embedded pane keeps prev/next and the position readout. + prevHref={prevRow ? buildVideoHref(prevRow.id) : undefined} + nextHref={nextRow ? buildVideoHref(nextRow.id) : undefined} + position={ + selectedIndex >= 0 + ? { index: selectedIndex, total: orderedRows.length } + : undefined + } + /> + {title && ( + <p + className="text-sm text-muted-foreground mt-2 truncate" + aria-label="video title" + > + {title} + </p> + )} + </> + ); + } + + return ( + <VideoWorkspace + slug={slug} + rows={rows} + selectedVideoId={selectedVideoId} + initialFilters={[...filters]} + initialQuery={queryRaw} + defaultTranscribeQueue={TRANSCRIPTION_QUEUE} + defaultDownloadQueue={platformDefaultQueueKey} + existingQueues={existingQueues} + detail={detail} + /> + ); +} + +function EmptyVideoPanel({ + total, + filtered, +}: { + total: number; + filtered: number; +}) { + return ( + <div className="flex items-center justify-center text-sm text-muted-foreground border border-dashed border-border rounded-md p-12 text-center"> + <div className="flex flex-col gap-1"> + <p>Select a video from the list to view actions.</p> + <p className="text-xs"> + {filtered} matching · {total} total + </p> + </div> + </div> + ); +} diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; import { + channelStage, + generateReport, pathExists, readJson, resetData, @@ -10,7 +12,6 @@ import { writeChannelConfig, writeDigestVideo, writeSettings, - generateReport, } from "./helpers"; import type { AttributionRecord } from "../../common/lib/attribution"; @@ -147,8 +148,7 @@ async function seedChannel(opts: { diarization?: boolean } = {}) { } async function runBackfill(page: Page): Promise<void> { - await page.goto(`/channels/${CHANNEL}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(CHANNEL, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -224,7 +224,10 @@ test("the diarized lane upgrades a text-only record", async ({ page }) => { await generateReport(page, CHANNEL); // The card counts it as work, not as done. `missing` rather than `stale`: the - // diarized record genuinely was never made. + // diarized record genuinely was never made. One panel is open at a time now, + // so the Backfill card has to be asked for — it used to be on screen because + // every stage rendered expanded. + await page.goto(channelStage(CHANNEL, "backfill")); await expect( page.getByLabel("video needing a backfill attrvid0001"), ).toBeVisible(); @@ -298,8 +301,7 @@ test("the stage card and /actionable show attribution beside diarization, with t }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(CHANNEL, "backfill")); const section = page.getByLabel("backfill section"); // Per-kind lines appear because there is now more than one kind — the same diff --git a/editor/e2e/audio-check-scenarios.spec.ts b/editor/e2e/audio-check-scenarios.spec.ts @@ -1,6 +1,13 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, + resolvePath, +} from "./helpers"; import { baseUrl } from "./baseUrl"; import type { DownloadOutcomeRecord, @@ -69,7 +76,7 @@ async function maxChunkGapMs(): Promise<number> { async function triggerDownload(page: import("@playwright/test").Page) { await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); } diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts @@ -1,11 +1,14 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { - resetData, - resolvePath, + channelStage, + generateReport, pathExists, readJson, - writeSettings, generateReport } from "./helpers"; + resetData, + resolvePath, + writeSettings, +} from "./helpers"; import { baseUrl } from "./baseUrl"; // End-to-end coverage for the automatic priority-queue runner @@ -606,7 +609,7 @@ test("Drain completes when an auto-transcribe unit is parked behind a busy worke // 1) Manual foreground transcription holds the single worker (slowop → ~50s). await generateReport(page, "busy"); - await page.goto("/channels/busy"); + await page.goto(channelStage("busy", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // Wait until it's actually running (worker leased), so the auto unit will park. await expect diff --git a/editor/e2e/auto-subs-replace.spec.ts b/editor/e2e/auto-subs-replace.spec.ts @@ -17,11 +17,14 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { + channelStage, + generateReport, pathExists, readJson, resetData, resolvePath, - writeSettings, generateReport } from "./helpers"; + writeSettings, +} from "./helpers"; import { baseUrl } from "./baseUrl"; const SLUG = "test-auto-subs"; @@ -172,7 +175,7 @@ test("walks an auto-caption video through fetch → transcribe → purge", async // --- Step 0: classification ----------------------------------------------- await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); + await page.goto(channelStage(SLUG, "transcribe")); await expectBuckets({ autoSubsOnly: ["asrvid0001"] }); await expect( @@ -201,7 +204,7 @@ test("walks an auto-caption video through fetch → transcribe → purge", async // --- Step 2: transcribe over the auto-captions ---------------------------- await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); + await page.goto(channelStage(SLUG, "transcribe")); await expectBuckets({ downloadedAutoSubsOnly: ["asrvid0001"] }); await page.reload(); @@ -224,11 +227,11 @@ test("walks an auto-caption video through fetch → transcribe → purge", async // --- Step 3: the backup shows up as purgeable inventory ------------------- await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); + await page.goto(channelStage(SLUG, "transcribe")); await expectBuckets({ supersededAutoSubs: ["asrvid0001"] }); await page.reload(); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); const section = page.getByLabel("superseded auto captions section"); await expect( section.getByRole("heading", { name: "Superseded auto-captions (1)" }), @@ -260,7 +263,7 @@ test("walks an auto-caption video through fetch → transcribe → purge", async ); await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); + await page.goto(channelStage(SLUG, "transcribe")); await expectBuckets({}); }); @@ -281,13 +284,13 @@ test("never buckets or purges captions it can't prove are auto-generated", async ]); await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); + await page.goto(channelStage(SLUG, "transcribe")); // Only the unprotected ASR-plus-whisper video is listed as a backup, and no // manual-caption video appears in the work lane at all. await expectBuckets({ supersededAutoSubs: ["asrdone0001"] }); await page.reload(); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); const section = page.getByLabel("superseded auto captions section"); await section .getByLabel("confirm purge superseded auto captions") diff --git a/editor/e2e/availability-backfill.spec.ts b/editor/e2e/availability-backfill.spec.ts @@ -1,6 +1,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, + resolvePath, +} from "./helpers"; type AvailabilityFile = { availability: string; @@ -40,7 +47,7 @@ test("download backfills availability.json from metadata", async ({ page }) => { await seedVideo("seededPlain1", {}); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "playlist")); // Sync pages the flat-playlist (fake emits fake0000000{1..5}) and downloads // each via the managed per-URL path. After it finishes, runYtdlp calls // backfillAvailabilityFromMetadata, which sweeps ALL existing data dirs. @@ -94,7 +101,7 @@ test("backfill skips videos that already have an availability sidecar", async ({ ); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "playlist")); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("backfill", { timeout: 20_000, diff --git a/editor/e2e/availability.spec.ts b/editor/e2e/availability.spec.ts @@ -1,11 +1,14 @@ import { stat } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { + channelStage, + generateReport, pathExists, readJson, resetData, resolvePath, - writeSite, generateReport } from "./helpers"; + writeSite, +} from "./helpers"; const CHANNEL = "availability-test"; const FIXTURE = "availability-baseline"; @@ -39,7 +42,7 @@ test("resume checks classify each fixture video into its expected bucket", async }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page.getByRole("button", { name: "Resume checks" }).click(); const log = page.getByLabel("Resume availability checks output"); await expect(log).toContainText("attempted=6", { timeout: 20_000 }); @@ -62,7 +65,7 @@ test("resume checks classify each fixture video into its expected bucket", async test("resume is idempotent: a second run skips all videos", async ({ page }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page.getByRole("button", { name: "Resume checks" }).click(); const log = page.getByLabel("Resume availability checks output"); await expect(log).toContainText("attempted=6", { timeout: 20_000 }); @@ -78,7 +81,7 @@ test("resume log breaks down already-checked vs unchecked counts", async ({ }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page.getByRole("button", { name: "Resume checks" }).click(); const log = page.getByLabel("Resume availability checks output"); // First run: nothing checked yet. @@ -100,7 +103,7 @@ test("recheck non-deleted skips deleted but advances other checkedAt", async ({ }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page.getByRole("button", { name: "Resume checks" }).click(); await expect( page.getByLabel("Resume availability checks output"), @@ -128,7 +131,7 @@ test("recheck non-deleted skips deleted but advances other checkedAt", async ({ test("recheck all re-probes every video including deleted", async ({ page }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page.getByRole("button", { name: "Resume checks" }).click(); await expect( page.getByLabel("Resume availability checks output"), @@ -159,7 +162,7 @@ test("build index marks deleted videos in the export summaries", async ({ }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page.getByRole("button", { name: "Resume checks" }).click(); await expect( page.getByLabel("Resume availability checks output"), @@ -203,7 +206,7 @@ test("build index marks deleted videos in the export summaries", async ({ test("availability.json shape matches the type contract", async ({ page }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page.getByRole("button", { name: "Resume checks" }).click(); await expect( page.getByLabel("Resume availability checks output"), diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts @@ -3,13 +3,14 @@ import { test, expect } from "@playwright/test"; import type { APIRequestContext } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { + channelStage, + generateReport, pathExists, readJson, resetData, resolvePath, - writeSettings, writeDigestVideo, - generateReport, + writeSettings, } from "./helpers"; // Active jobs, straight off the JSON API. Reloading /jobs costs hundreds of @@ -150,8 +151,7 @@ test("the stage card separates reachable work from what needs its media back", a }); await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); const section = page.getByLabel("backfill section"); await expect(section.getByRole("heading")).toContainText( "Backfill derived data (1)", @@ -181,8 +181,7 @@ test("running the lane captures the reachable video and skips the one with no me await rm(resolvePath(dataRel("vidB", f)), { force: true }); } - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -226,8 +225,7 @@ test("a sidecar from a different threshold is regenerated", async ({ page }) => }) + "\n", ); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -260,8 +258,7 @@ test("re-acquired media is deleted after a successful backfill", async ({ } expect(await audioFiles("vidB")).toEqual([]); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -295,8 +292,7 @@ test("re-acquired media is deleted even when the backfill fails", async ({ await rm(resolvePath(dataRel("vidC", f)), { force: true }); } - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -332,8 +328,7 @@ test("re-acquired media is KEPT when the video is marked do-not-clean", async ({ JSON.stringify({ setAt: new Date().toISOString(), note: "e2e" }) + "\n", ); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -369,8 +364,7 @@ test("the disk floor refuses to re-acquire anything", async ({ page }) => { await rm(resolvePath(dataRel("vidB", f)), { force: true }); } - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -502,10 +496,9 @@ test("a backfill runs concurrently with a transcription", async ({ await mkdir(slowDir, { recursive: true }); await writeFile(`${slowDir}/audio.mp3`, "fake audio slowop1\n"); - await page.goto(`/channels/${SLUG}`); // Transcription first — it is the one that has to still be running when the // backfill lands. - await page.getByRole("button", { name: "Transcribe stage summary" }).click(); + await page.goto(channelStage(SLUG, "transcribe")); await page .getByRole("button", { name: "Transcribe missing", exact: true }) .click(); @@ -515,7 +508,7 @@ test("a backfill runs concurrently with a transcription", async ({ ), { timeout: 30_000 }) .toBe(true); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -599,8 +592,7 @@ test("a digest runs concurrently with a backfill, not behind it", async ({ await writeDigestVideo({ channelSlug: SLUG, videoId: "digestme" }); await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); await page .getByRole("button", { name: "Backfill channel", exact: true }) .click(); @@ -614,7 +606,7 @@ test("a digest runs concurrently with a backfill, not behind it", async ({ ) .toBe(true); - await page.getByRole("button", { name: "Digest stage summary" }).click(); + await page.goto(channelStage(SLUG, "digest")); await page.getByRole("button", { name: "Digest channel" }).click(); const seen = await expect @@ -788,8 +780,7 @@ test("a digest entry in the snapshot does not move the backfill instrument", asy expect(snapshot.backfill?.diarization?.missing).toBe(1); // HALF TWO: every surface that sums the lane ignores it. - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Backfill stage summary" }).click(); + await page.goto(channelStage(SLUG, "backfill")); const backfillSection = page.getByLabel("backfill section"); // 1 — vidA's diarization. NOT 3, which is what folding the digest entry in // would produce here. @@ -798,7 +789,7 @@ test("a digest entry in the snapshot does not move the backfill instrument", asy ); // The digest card reports its own work, off the same snapshot entry. - await page.getByRole("button", { name: "Digest stage summary" }).click(); + await page.goto(channelStage(SLUG, "digest")); await expect(page.getByLabel("digest section").getByRole("heading")).toContainText( "Generate digests (2)", ); diff --git a/editor/e2e/bulk-actions.spec.ts b/editor/e2e/bulk-actions.spec.ts @@ -1,6 +1,13 @@ import { readFile, rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; +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 @@ -42,7 +49,7 @@ test("bulk transcribe submits a single batch job on the transcription queue", as }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelVideos("test-transcribe")); await selectAllAndOpenBar(page); // Transcribe is the default action, so its queue control is already shown. @@ -69,7 +76,7 @@ test("bulk transcribe submits a single batch job on the transcription queue", as test("bulk transcribe honors a custom queue", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelVideos("test-transcribe")); await selectAllAndOpenBar(page); await page @@ -99,7 +106,7 @@ test("bulk retry download submits a single retry-bucket job on the platform queu }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelVideos("test-transcribe")); await selectAllAndOpenBar(page); await chooseAction(page, "Retry download"); @@ -133,7 +140,7 @@ test("Select failed + Delete directories removes the dirs and queues no job", as await writeFile(resolvePath(failedTranscriptionsFile), "vidA\n"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/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(); @@ -175,7 +182,7 @@ test("Clear failed markers prunes both failure files and clears the glyphs", asy await writeFile(resolvePath(failedTranscodingsFile), "vidB\n"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelVideos("test-transcribe")); // Two failure glyphs are shown before clearing. await expect(page.getByTitle("failure recorded")).toHaveCount(2); @@ -214,7 +221,7 @@ test("Select wrong-format + Remove wrong-format audio removes off-target files a ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelVideos("test-transcribe")); // The quick-select helper grabs exactly the wrong-format video. await page @@ -248,7 +255,7 @@ test("Remove audio files deletes finalized audio but keeps .part partials, queue await writeFile(resolvePath(`${dataDir}/vidB/audio.m4a.part`), "partial\n"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelVideos("test-transcribe")); await page.getByLabel("select vidB").check(); await expect(page.getByText("1 selected", { exact: true })).toBeVisible(); @@ -295,8 +302,9 @@ test("Cleanup stage 'Remove wrong-format audio' sweeps orphans but skips do-not- ); // vidC keeps its on-target audio.m4a untouched. - // The #stage-cleanup hash selects + expands the Cleanup stage. - await page.goto("/channels/test-transcribe#stage-cleanup"); + // ?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"); diff --git a/editor/e2e/cadence-ui.spec.ts b/editor/e2e/cadence-ui.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import { + channelStage, generateReport, readJson, resetData, @@ -46,7 +47,7 @@ test("channel form saves a full-sweep preset as a minute count", async ({ }) => { await seedChannel(); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "configure")); await page.getByLabel("Full sweep", { exact: true }).selectOption("1440"); // The echo line restates the raw stored value, so nothing is hidden from @@ -69,7 +70,7 @@ test("a hand-set non-preset interval round-trips unchanged", async ({ // "137 minutes" and save it back untouched rather than rounding to 2 hours. await seedChannel({ fullSweepIntervalMinutes: 137 }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "configure")); await expect(page.getByLabel("Full sweep amount")).toHaveValue("137"); await expect(page.getByLabel("Full sweep unit")).toHaveValue("minutes"); @@ -87,7 +88,7 @@ test("clearing a cadence to Default removes the key from config", async ({ // actually delete the stored value, not re-layer the previous one. await seedChannel({ fullSweepIntervalMinutes: 720 }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "configure")); await page.getByLabel("Full sweep", { exact: true }).selectOption("inherit"); await saveChannelForm(page); @@ -217,7 +218,7 @@ test("the Full sweep button forces a sweep the cadence would have skipped", asyn const slug = "availability-test"; const configRel = `test-transcripts/channels/${slug}/config.json`; await generateReport(page, slug); - await page.goto(`/channels/${slug}`); + await page.goto(channelStage(slug, "playlist")); const button = page.getByRole("button", { name: "Full sweep", exact: true }); // Retried: a click landing before React hydrates fires nothing at all. diff --git a/editor/e2e/cancel.spec.ts b/editor/e2e/cancel.spec.ts @@ -1,10 +1,15 @@ import { test, expect } from "@playwright/test"; -import { pathExists, resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, +} from "./helpers"; test("cancels a running pipeline job from the channel page", async ({ page }) => { await resetData("slow-pipeline-channel"); await generateReport(page, "slow-channel"); - await page.goto("/channels/slow-channel"); + await page.goto(channelStage("slow-channel", "playlist")); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { timeout: 15_000, @@ -28,7 +33,7 @@ test("cancels a running pipeline job from the channel page", async ({ page }) => test("cancels a running job from the jobs list", async ({ page }) => { await resetData("slow-pipeline-channel"); await generateReport(page, "slow-channel"); - await page.goto("/channels/slow-channel"); + await page.goto(channelStage("slow-channel", "playlist")); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { timeout: 15_000, diff --git a/editor/e2e/channel-embedded-video.spec.ts b/editor/e2e/channel-embedded-video.spec.ts @@ -1,76 +1,69 @@ import { test, expect } from "@playwright/test"; -import { resetData, generateReport } from "./helpers"; +import { channelVideos, generateReport, resetData } from "./helpers"; -// The embedded single-video view on the channel page is collapsed by default and -// driven by the ?video= selection: clicking a video expands it and scrolls to it, -// clearing the selection collapses it again. +// The video workspace: the list on the left, the selected video's detail on the +// right, both always present. +// +// This spec used to assert the opposite — a collapsed-by-default detail strip +// with expand/collapse toggles and a scroll-into-view on selection. All of that +// existed because the pane was squeezed onto a page it shared with the stage +// panels; on a route of its own there is nothing to make room for, so the +// affordances are gone and their absence is what is asserted now. -test("embedded video view is collapsed by default and expands on selection", async ({ +test("both panes are present, and the selection drives the detail pane", async ({ page, }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelVideos("test-transcribe")); - // No video selected: the thin-strip expand button is shown, the panel is not. - await expect(page.getByLabel("expand video", { exact: true })).toBeVisible(); - await expect(page.getByLabel("collapse video", { exact: true })).toHaveCount(0); + // Nothing selected: the list is there and the detail pane says what to do. + await expect(page.getByLabel("videos", { exact: true })).toBeVisible(); + await expect( + page.getByText("Select a video from the list to view actions."), + ).toBeVisible(); await expect(page.getByLabel("pipeline status")).toBeHidden(); + // No collapse machinery survives the move. + await expect(page.getByLabel("expand video", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("collapse video", { exact: true })).toHaveCount(0); - // Click a video in the list -> URL updates, panel expands and scrolls in. + // Clicking a video updates the URL and fills the detail pane. await page.getByLabel("open vidA").click(); await expect(page).toHaveURL(/[?&]video=vidA\b/); - await expect(page.getByLabel("expand video", { exact: true })).toHaveCount(0); await expect(page.getByLabel("pipeline status")).toBeVisible(); - // The page jumped to the panel: its top (the collapse toggle) is in view. - await expect(page.getByLabel("collapse video", { exact: true })).toBeInViewport(); + await expect(page.getByLabel("files for vidA")).toBeVisible(); - // Selecting a different video keeps the panel expanded. + // Selecting a different video swaps the pane's contents. await page.getByLabel("open vidB").click(); await expect(page).toHaveURL(/[?&]video=vidB\b/); - await expect(page.getByLabel("collapse video", { exact: true })).toBeVisible(); await expect(page.getByLabel("files for vidB")).toBeVisible(); }); -test("embedded video view auto-collapses when the selection is cleared", async ({ - page, -}) => { +test("a deep link opens straight onto the selected video", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); - await page.goto("/channels/test-transcribe?video=vidA"); + await page.goto(channelVideos("test-transcribe", { video: "vidA" })); - // Deep-linked with a selection: starts expanded. - await expect(page.getByLabel("collapse video", { exact: true })).toBeVisible(); await expect(page.getByLabel("files for vidA")).toBeVisible(); + await expect(page.getByLabel("pipeline status")).toBeVisible(); - // Manually collapse via the toggle. - await page.getByLabel("collapse video", { exact: true }).click(); - await expect(page.getByLabel("expand video", { exact: true })).toBeVisible(); - - // Selecting another video re-expands (manual collapse is overridden by a - // change in selection). - await page.getByLabel("open vidB").click(); - await expect(page.getByLabel("collapse video", { exact: true })).toBeVisible(); - await expect(page.getByLabel("files for vidB")).toBeVisible(); - - // Clearing the selection auto-collapses. + // Dropping the selection returns the placeholder. await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); - await expect(page.getByLabel("expand video", { exact: true })).toBeVisible(); - await expect(page.getByLabel("collapse video", { exact: true })).toHaveCount(0); + await page.goto(channelVideos("test-transcribe")); + await expect( + page.getByText("Select a video from the list to view actions."), + ).toBeVisible(); + await expect(page.getByLabel("pipeline status")).toBeHidden(); }); -test("on mobile the panel stacks below the list without a collapse strip", async ({ - page, -}) => { +test("on mobile the panes stack, list first", async ({ page }) => { await page.setViewportSize({ width: 600, height: 900 }); await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelVideos("test-transcribe")); - // The desktop-only collapse affordances are hidden; the placeholder shows. - await expect(page.getByLabel("expand video", { exact: true })).toBeHidden(); - await expect(page.getByLabel("collapse video", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("videos", { exact: true })).toBeVisible(); await expect( page.getByText("Select a video from the list to view actions."), ).toBeVisible(); + await expect(page.getByLabel("expand video", { exact: true })).toHaveCount(0); }); diff --git a/editor/e2e/channel-line.spec.ts b/editor/e2e/channel-line.spec.ts @@ -0,0 +1,285 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { test, expect } from "@playwright/test"; +import { baseUrl } from "./baseUrl"; +import { + channelStage, + resetData, + resolvePath, + writeChannelConfig, + writeSettings, +} from "./helpers"; + +// THE CHANNEL LINE. +// +// The hero of the channel page: stations are stages, the gap between two +// stations carries the shortfall. Three of the four cases below are invariants +// this codebase has paid for once already — +// +// * a pre-`eligible` snapshot must render "—", never "0%". A third of the +// snapshots on disk are months old and predate the denominator; a 0 there +// reads as "nothing digested" on a fully digested channel. +// * a lane that is switched OFF is neutral, not "ok" and not amber. An empty +// work list because a feature is off is not the same as being finished. +// * `deferred` / `blocked` / `missingInput` are OFF the line and must never be +// added to a gap's reachable count. +// +// — and the fourth (the promoted bottleneck) is the whole reason the line is +// drawn as a line. + +const SLUG = "line-chan"; +const ROOT = `test-transcripts/channels/${SLUG}`; + +type Entry = Record<string, unknown>; + +// Settings with the diarization backfill on or off. The models are /dev/null +// placeholders — nothing here runs the lane, it only has to be ENABLED so that +// laneBackfillKinds reports it. +function laneSettings(enabled: boolean) { + return { + adminTitle: "Test Admin", + maxTranscriptPageBytes: 8388608, + sleepBetweenDownloadsSeconds: 0, + minFreeDiskGB: 0, + verifyAvailabilityBeforeClean: false, + syncScheduler: { fullSweepIntervalMinutes: 0 }, + diarization: { + enabled, + inlineAfterTranscribe: false, + threshold: 0.5, + threads: 1, + python: "python3", + segModel: "/dev/null", + embModel: "/dev/null", + concurrency: 1, + }, + backfill: { + enabled, + weight: 1, + concurrency: 1, + sweepEnabled: false, + sweepKinds: [], + sweepChannels: [], + allowRedownload: false, + }, + }; +} + +async function seed(snapshot: Record<string, unknown>) { + await resetData(); + await writeChannelConfig(SLUG, { + handling: "transcribe", + platform: "youtube", + name: "Line Channel", + lastSyncedAt: new Date().toISOString(), + }); + await mkdir(resolvePath(`${ROOT}/data`), { recursive: true }); + // The playlist file is the first station's denominator — one small read, and + // the only thing on this page that is not the snapshot. + await writeFile( + resolvePath(`${ROOT}/playlist`), + Array.from({ length: 125 }, (_, i) => `https://youtu.be/v${i}`).join("\n") + + "\n", + ); + await writeFile( + resolvePath(`${ROOT}/snapshot.json`), + JSON.stringify( + { + generatedAt: new Date().toISOString(), + totals: { videos: 100, transcribed: 40, downloaded: 60 }, + buckets: {}, + undownloadedIds: [], + ...snapshot, + }, + null, + 2, + ), + ); + await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); +} + +// A per-kind entry as an OLD build wrote it: no `eligible`, no `deferred`, no +// `blocked`. This is the shape most snapshots on disk are still in. +function legacyEntry(patch: Entry = {}): Entry { + return { ids: [], missing: 0, stale: 0, missingInput: 0, ...patch }; +} + +test("stations show their counts and coverage against the right denominators", async ({ + page, +}) => { + await seed({}); + await page.goto(`/channels/${SLUG}`); + + const line = page.getByLabel("channel line"); + await expect(line).toBeVisible(); + + // Playlist: 100 known of the 125 the playlist file names. + await expect(line.getByLabel("Playlist through")).toHaveText("100"); + await expect(line.getByLabel("Playlist coverage", { exact: true })).toContainText("80%"); + // Download: 60 of the 100 known. + await expect(line.getByLabel("Download through")).toHaveText("60"); + await expect(line.getByLabel("Download coverage", { exact: true })).toContainText("60%"); + // Transcribe's denominator is what was DOWNLOADED, not what is known: an + // undownloaded video is not eligible for transcription. + await expect(line.getByLabel("Transcribe through")).toHaveText("40"); + await expect(line.getByLabel("Transcribe coverage", { exact: true })).toContainText("67%"); +}); + +test("a pre-`eligible` snapshot renders an em dash, not 0%", async ({ + page, +}) => { + await seed({ + backfill: { + digest: legacyEntry({ missing: 5 }), + diarization: legacyEntry({ missing: 3 }), + }, + }); + await page.goto(`/channels/${SLUG}`); + + const line = page.getByLabel("channel line"); + await expect(line.getByLabel("Digest through")).toHaveText("—"); + await expect(line.getByLabel("Digest coverage", { exact: true })).toHaveText("—"); + await expect(line.getByLabel("Backfill through")).toHaveText("—"); + // The negative assertion is the point: a 0% here is the bug. + await expect(line.getByLabel("Digest coverage", { exact: true })).not.toContainText("0%"); +}); + +test("the largest gap is promoted and the rest recede", async ({ page }) => { + await seed({ + undownloadedIds: ["a", "b"], + backfill: { + digest: { + ids: [], + missing: 1675, + stale: 0, + partial: 0, + missingInput: 0, + deferred: 0, + blocked: 0, + eligible: 1797, + }, + }, + }); + await page.goto(`/channels/${SLUG}`); + + const line = page.getByLabel("channel line"); + // 1,675 to digest dwarfs the 2 to download, so the digest gap is the promoted + // one — exactly one gap carries the marker. + await expect(line.locator("[data-bottleneck='true']")).toHaveCount(1); + await expect(line.locator("[data-bottleneck='true']")).toHaveAttribute( + "data-gap", + "digest", + ); + await expect(line.getByLabel("to digest shortfall")).toContainText("1,675"); + await expect(line.getByLabel("to download shortfall")).toContainText("2"); +}); + +test("deferred and blocked hang off the line, never inside the shortfall", async ({ + page, +}) => { + await seed({ + backfill: { + digest: { + ids: ["a", "b", "c", "d"], + missing: 2, + stale: 1, + partial: 1, + missingInput: 900, + deferred: 40, + blocked: 1631, + eligible: 3000, + }, + }, + }); + await page.goto(`/channels/${SLUG}`); + + const line = page.getByLabel("channel line"); + // missing + stale + partial. NOT + 900 + 40 + 1631. + await expect(line.getByLabel("to digest shortfall")).toContainText("4"); + await expect(line.getByLabel("to digest shortfall")).not.toContainText("2,575"); + // The excluded populations are present — on the other axis. + await expect( + line.getByLabel("waiting on a transcript siding"), + ).toContainText("1,631"); +}); + +test("a lane that is switched off reads neutral, not finished", async ({ + page, +}) => { + const laneWork = { + backfill: { + diarization: { + ids: ["a", "b"], + missing: 7, + stale: 0, + partial: 0, + missingInput: 0, + deferred: 0, + blocked: 0, + eligible: 50, + }, + }, + }; + + // writeSettings AFTER seed: seed() calls resetData(), which restores the + // default settings file. + // Lane ON: the recorded work is real work, on the line. + await seed(laneWork); + await writeSettings(laneSettings(true)); + await page.goto(`/channels/${SLUG}`); + await expect( + page.getByLabel("channel line").getByLabel("to backfill shortfall"), + ).toContainText("7"); + await expect( + page.getByLabel("channel line").getByLabel("Backfill through"), + ).toHaveText("43"); + + // Lane OFF, same snapshot: nothing is offered, and coverage is unknown rather + // than complete. The station must not go green on a feature nobody enabled. + await seed(laneWork); + await writeSettings(laneSettings(false)); + await page.goto(`/channels/${SLUG}`); + const line = page.getByLabel("channel line"); + await expect(line.getByLabel("Backfill through")).toHaveText("—"); + await expect(line.getByLabel("to backfill shortfall")).toContainText("0"); + // …and the Backfill stage says why rather than claiming completion. + await page.goto(channelStage(SLUG, "backfill")); + await expect(page.getByLabel("Backfill stage summary")).toContainText( + "No backfill is enabled", + ); +}); + +test("the next action is the furthest upstream gap, and vanishes when clean", async ({ + page, +}) => { + await seed({ undownloadedIds: ["a", "b"] }); + await page.goto(`/channels/${SLUG}`); + const action = page.getByLabel("next action"); + await expect(action).toBeVisible(); + await expect(action.getByRole("button")).toHaveText(/Download missing/); + + // A channel with nothing outstanding offers nothing. + await seed({ totals: { videos: 10, transcribed: 10, downloaded: 10 } }); + await writeFile( + resolvePath(`${ROOT}/playlist`), + Array.from({ length: 10 }, (_, i) => `https://youtu.be/v${i}`).join("\n") + + "\n", + ); + await page.goto(`/channels/${SLUG}`); + await expect(page.getByLabel("next action")).toHaveCount(0); +}); + +test("the attention strip says 'all clear' rather than disappearing", async ({ + page, +}) => { + await seed({ totals: { videos: 10, transcribed: 10, downloaded: 10 } }); + await page.goto(`/channels/${SLUG}`); + const strip = page.getByLabel("attention strip"); + await expect(strip).toBeVisible(); + await expect(strip).toHaveAttribute("data-attention", "clear"); + + await seed({ buckets: { needsCookies: ["a", "b", "c", "d"] } }); + await page.goto(`/channels/${SLUG}`); + const flagged = page.getByLabel("attention strip"); + await expect(flagged).toHaveAttribute("data-attention", "flagged"); + await expect(flagged.getByLabel("need cookies anomaly")).toContainText("4"); +}); diff --git a/editor/e2e/channel-rename.spec.ts b/editor/e2e/channel-rename.spec.ts @@ -1,5 +1,10 @@ import { test, expect } from "@playwright/test"; -import { readJson, resetData, writeSite } from "./helpers"; +import { + channelStage, + readJson, + resetData, + writeSite, +} from "./helpers"; // Renaming a channel's slug lives in the Danger zone and requires typing the // current slug to confirm (mirroring delete). On success it migrates slug-keyed @@ -19,7 +24,7 @@ test("rename requires the exact current slug and then moves the channel", async // renaming refuses while any job for the channel is active. This test doesn't // need a report — the channel page renders fine without one (empty buckets // plus a banner), which is the whole point of not generating inside a GET. - await page.goto(`/channels/${OLD}`); + await page.goto(channelStage(OLD, "danger")); const newSlug = page.getByLabel("new slug"); const confirm = page.getByLabel("confirm current slug"); diff --git a/editor/e2e/channel-site-membership.spec.ts b/editor/e2e/channel-site-membership.spec.ts @@ -1,5 +1,11 @@ import { test, expect, type Page } from "@playwright/test"; -import { readJson, resetData, writeSite, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + readJson, + resetData, + writeSite, +} from "./helpers"; // The channel form's "Sites" membership section: per-site checkbox + group // dropdown (+ inline "+ New group…") on both the create and edit screens. @@ -117,7 +123,7 @@ test("edit: switching news → (default) keeps order, sibling untouched", async }) => { await seed(); await generateReport(page, "slow-b"); - await page.goto("/channels/slow-b"); + await page.goto(channelStage("slow-b", "configure")); await expect(page.getByLabel("Include on Alpha")).toBeChecked(); await expect(page.getByLabel("Group for Alpha")).toHaveValue("news"); @@ -138,7 +144,7 @@ test("edit: unchecking removes the membership, sibling intact", async ({ }) => { await seed(); await generateReport(page, "slow-b"); - await page.goto("/channels/slow-b"); + await page.goto(channelStage("slow-b", "configure")); await expect(page.getByLabel("Include on Alpha")).toBeChecked(); await setSiteChecked(page, "Alpha", false); @@ -152,7 +158,7 @@ test("edit: unchecking removes the membership, sibling intact", async ({ test("edit: + New group on another site", async ({ page }) => { await seed(); await generateReport(page, "slow-a"); - await page.goto("/channels/slow-a"); + await page.goto(channelStage("slow-a", "configure")); await expect(page.getByLabel("Include on Alpha")).toBeChecked(); await expect(page.getByLabel("Include on Beta")).not.toBeChecked(); diff --git a/editor/e2e/channel-stage-selection.spec.ts b/editor/e2e/channel-stage-selection.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "@playwright/test"; +import { channelStage, generateReport, resetData } from "./helpers"; + +// ONE PANEL AT A TIME. This is the change, stated as a test. +// +// The old page rendered every stage panel expanded at lg+ and stacked ten of +// them below a full-height video browser, so the channel's actual lifecycle was +// the last thing you reached. Now `?stage=` selects exactly one, server-side, +// and no selection lands on an overview. +// +// The negative assertions are the load-bearing half: it is easy to make a stage +// visible and not notice that the other nine came with it. + +const SLUG = "test-transcribe"; + +test("?stage=download renders the Download panel and NOT the Transcribe one", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, SLUG); + await page.goto(channelStage(SLUG, "download")); + + await expect(page.getByRole("button", { name: "Download videos" })).toBeVisible(); + // The Transcribe panel's primary action is not merely hidden — it is absent. + await expect( + page.getByRole("button", { name: "Transcribe missing" }), + ).toHaveCount(0); + // Nor is any other panel along for the ride. + await expect(page.getByRole("button", { name: "Verify" })).toHaveCount(0); + await expect(page.getByLabel("backfill section")).toHaveCount(0); + + // The selected tab says so. + await expect(page.getByLabel("Download stage tab")).toHaveAttribute( + "aria-current", + "page", + ); +}); + +test("?stage=transcribe swaps the panel over", async ({ page }) => { + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, SLUG); + await page.goto(channelStage(SLUG, "transcribe")); + + await expect( + page.getByRole("button", { name: "Transcribe missing" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Download videos" }), + ).toHaveCount(0); +}); + +test("no ?stage= lands on the overview, which lists every stage without opening one", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, SLUG); + await page.goto(`/channels/${SLUG}`); + + const overview = page.getByLabel("overview panel"); + await expect(overview).toBeVisible(); + // Every stage is legible from here… + for (const title of [ + "Playlist", + "Download", + "Transcribe", + "Digest", + "Backfill", + "Cleanup", + "Diagnostics", + ]) { + await expect(overview.getByLabel(`${title} overview row`)).toBeVisible(); + } + // …and none of them is open. + await expect(page.getByRole("button", { name: "Download videos" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Transcribe missing" })).toHaveCount(0); + await expect(page.getByLabel("Overview stage tab")).toHaveAttribute( + "aria-current", + "page", + ); +}); + +test("an unknown ?stage= falls back to the overview rather than 404ing", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, SLUG); + const resp = await page.goto(`/channels/${SLUG}?stage=not-a-stage`); + expect(resp?.status()).toBe(200); + await expect(page.getByLabel("overview panel")).toBeVisible(); +}); + +test("the Videos tab is a separate route, and the stage panels are not on it", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, SLUG); + await page.goto(`/channels/${SLUG}`); + + await page.getByLabel("Videos tab").click(); + await expect(page).toHaveURL(new RegExp(`/channels/${SLUG}/videos$`)); + await expect(page.getByLabel("videos", { exact: true })).toBeVisible(); + await expect(page.getByLabel("channel line")).toHaveCount(0); + await expect(page.getByLabel("overview panel")).toHaveCount(0); + + // …and back. + await page.getByLabel("Overview tab").click(); + await expect(page.getByLabel("channel line")).toBeVisible(); +}); diff --git a/editor/e2e/channels-actions.spec.ts b/editor/e2e/channels-actions.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "@playwright/test"; -import { resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, +} from "./helpers"; // The channel index page lets you launch jobs without drilling into the // detail page. Today: Sync + Check availability. Both default to the platform @@ -44,7 +48,7 @@ test("Transcribe missing defaults to the system-wide 'transcription' queue", asy }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "succeeded", diff --git a/editor/e2e/channels-counts.spec.ts b/editor/e2e/channels-counts.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "@playwright/test"; -import { resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, +} from "./helpers"; // /channels counts come from each channel's last generated REPORT, not from a // live disk walk — that swap is what took the page from ~4.5s to ~0.1s. The @@ -65,7 +69,7 @@ test("counts update after store-playlist and download", async ({ page }) => { ).toHaveText("0"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( "Wrote 5 URLs", @@ -78,7 +82,7 @@ test("counts update after store-playlist and download", async ({ page }) => { ).toHaveText("0"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "download")); await page.getByRole("button", { name: "Download from playlist" }).click(); await expect( page.getByLabel("Download from playlist output"), diff --git a/editor/e2e/channels.spec.ts b/editor/e2e/channels.spec.ts @@ -1,5 +1,11 @@ import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, +} from "./helpers"; test("shows empty state with no channels", async ({ page }) => { await resetData("empty"); @@ -65,7 +71,7 @@ test("rejects duplicate slug", async ({ page }) => { test("edits an existing channel", async ({ page }) => { await resetData("one-youtube-channel"); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "configure")); await page.getByLabel(/^name/i).fill("Renamed Channel"); await page.getByRole("button", { name: /save changes/i }).click(); await expect(page.getByText("Renamed Channel").first()).toBeVisible(); @@ -78,7 +84,7 @@ test("edits an existing channel", async ({ page }) => { test("requires typed-confirmation to delete", async ({ page }) => { await resetData("one-youtube-channel"); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "danger")); // getByPlaceholder would be ambiguous: the rename form's confirm input // shares the slug placeholder. @@ -98,7 +104,7 @@ test("hides transcribe form fields for youtube channels (cosmetic)", async ({ }) => { await resetData("one-transcribe-channel"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "configure")); await expect( page.getByLabel(/audio format \(transcribe only\)/i), ).toHaveValue("m4a"); diff --git a/editor/e2e/chough.spec.ts b/editor/e2e/chough.spec.ts @@ -1,6 +1,13 @@ import { readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, + writeSettings, +} from "./helpers"; const CHANNEL = "test-transcribe"; const DATA = `test-transcripts/channels/${CHANNEL}/data`; @@ -30,7 +37,7 @@ test("chough app transcribes audio and writes a transcript.json", async ({ await resetData("one-transcribe-channel-with-audio"); await selectChough(); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", @@ -58,7 +65,7 @@ test("chough output normalizes to chough-tagged cues", async ({ page }) => { await writeFile(resolvePath(`${DATA}/vidA/metadata.info.json`), META); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", diff --git a/editor/e2e/cookies-mode.spec.ts b/editor/e2e/cookies-mode.spec.ts @@ -3,11 +3,14 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; import { test, expect } from "@playwright/test"; import { + channelStage, + generateReport, pathExists, readJson, resetData, resolvePath, - writeSettings, generateReport } from "./helpers"; + writeSettings, +} from "./helpers"; // Configurable cookies-from-browser: global + per-channel value, and a cookie // MODE (always / when-required / defer). The fake yt-dlp's `cookiegated` @@ -98,7 +101,7 @@ test("channel form: overrides persist and clear back to inherit", async ({ }) => { await resetData(FIXTURE); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "configure")); await page.locator("summary").filter({ hasText: "Advanced" }).click(); await page.getByLabel(/^cookies from browser/i).fill("chrome:Profile 1"); await page.getByLabel("cookie mode").selectOption("defer"); @@ -149,7 +152,7 @@ test("always mode: every yt-dlp invocation carries the cookie value", async ({ cookieMode: "always", }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( @@ -193,7 +196,7 @@ test("when-required: failed prefetch is retried with cookies -> ok-with-cookies" // cookieMode omitted -> back-compat default "when-required" }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( @@ -249,7 +252,7 @@ test("defer: excluded from batches, surfaced in Needs cookies, downloads via the // Run 1: the gated video fails cookie-less (no prefetch retry, no auth // retry — defer never uses cookies in normal runs). await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( @@ -325,7 +328,7 @@ test("members_only videos surface in Needs cookies regardless of mode", async ({ ); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); const bucket = page.getByLabel("retry needs cookies bucket"); await expect(bucket).toBeVisible(); await expect( diff --git a/editor/e2e/diarization.spec.ts b/editor/e2e/diarization.spec.ts @@ -1,12 +1,13 @@ import { writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { + channelStage, + generateReport, pathExists, readJson, resetData, resolvePath, writeSettings, - generateReport, } from "./helpers"; import { baseUrl } from "./baseUrl"; @@ -83,8 +84,7 @@ test("transcribing a video captures its speaker turns", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await writeSettings(diarizationSettings()); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Transcribe stage summary" }).click(); + await page.goto(channelStage(SLUG, "transcribe")); await page .getByRole("button", { name: "Transcribe missing", exact: true }) .click(); @@ -137,8 +137,7 @@ test("a failing diarizer does not fail the transcription", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await writeSettings(diarizationSettings()); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Transcribe stage summary" }).click(); + await page.goto(channelStage(SLUG, "transcribe")); await page .getByRole("button", { name: "Transcribe missing", exact: true }) .click(); @@ -170,7 +169,7 @@ test("a failing diarizer does not fail the transcription", async ({ page }) => { expect(await pathExists(dataRel("vidC", "diarization.json"))).toBe(false); // And the guard holds vidC's audio while releasing the diarized ones. - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); await page.getByRole("button", { name: "Clean audio", exact: true }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( "awaiting diarization", @@ -220,7 +219,7 @@ test("cleanup refuses to delete audio for a transcribed-but-undiarized video", a ) .toEqual(["vidA"]); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); await page.getByRole("button", { name: "Clean audio", exact: true }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( "awaiting diarization", @@ -247,8 +246,7 @@ test("the Diarize speakers backfill clears the hold and releases the audio", asy // Nothing is diarized yet, so the sweep must take nothing. await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); await page.getByRole("button", { name: "Clean audio", exact: true }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( "awaiting diarization", @@ -275,8 +273,7 @@ test("the Diarize speakers backfill clears the hold and releases the audio", asy // With the sidecars in place the hold clears and the audio is reclaimable — // the whole point of the guard being transient rather than a protection. await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); await page.getByRole("button", { name: "Clean audio", exact: true }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( "Cleaned 2 audio file", @@ -299,8 +296,7 @@ test("with diarization disabled, cleanup deletes undiarized audio as before", as await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); await page.getByRole("button", { name: "Clean audio", exact: true }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( "Cleaned 2 audio file", diff --git a/editor/e2e/digest.spec.ts b/editor/e2e/digest.spec.ts @@ -14,13 +14,16 @@ import { type DigestRecord, } from "../../common/lib/digest"; import { + channelStage, + generateReport, pathExists, readJson, resetData, resolvePath, writeChannelConfig, writeDigestVideo, - writeSettings, generateReport } from "./helpers"; + writeSettings, +} from "./helpers"; // The digest lane, end to end through the real job path. // @@ -76,7 +79,7 @@ const BATCH_DONE = "Digest batch:"; async function runDigest(page: import("@playwright/test").Page, slug: string) { await generateReport(page, slug); - await page.goto(`/channels/${slug}`); + await page.goto(channelStage(slug, "digest")); await page.getByRole("button", { name: "Digest channel" }).click(); await expect(page.getByLabel("Digest channel output")).toContainText( BATCH_DONE, @@ -426,7 +429,7 @@ test("the two lanes land on different queue keys", async ({ page }) => { await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "digest")); await page.getByRole("button", { name: "Digest channel" }).click(); await expect(page.getByLabel("Digest channel output")).toContainText( BATCH_DONE, @@ -437,7 +440,7 @@ test("the two lanes land on different queue keys", async ({ page }) => { // if it did not, both lanes would share one key and the registry's // concurrency-1-per-key rule would serialize a GPU lane behind a network one. await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "digest")); await page.getByLabel("lane for Digest channel").selectOption("remote"); await expect(page.getByLabel("queue for Digest channel")).toHaveValue( "digest:remote", @@ -480,7 +483,7 @@ test("the metered lane is refused while it is disabled in settings", async ({ await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "digest")); const lane = page.getByLabel("lane for Digest channel"); // Nothing can spend money until it is explicitly turned on, so the option is // disabled at the control rather than rejected after the click. @@ -801,7 +804,7 @@ test("a transcript with no cues.json is deferred, and Normalize transcripts make expect(await pathExists(cuesRel)).toBe(false); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "digest")); // Counted as deferred — NOT as reachable work, and NOT as blocked (the video // is transcribed; nothing is waiting on the transcription lane). @@ -825,7 +828,7 @@ test("a transcript with no cues.json is deferred, and Normalize transcripts make // And the point of all of it: the video is now work the digest lane will take. await regenerateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "digest")); await expect( page.getByRole("heading", { name: "Generate digests (1)" }), ).toBeVisible(); diff --git a/editor/e2e/disk-space.spec.ts b/editor/e2e/disk-space.spec.ts @@ -1,11 +1,12 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { + channelStage, + generateReport, pathExists, resetData, resolvePath, writeSettings, - generateReport, } from "./helpers"; import { baseUrl } from "./baseUrl"; @@ -34,7 +35,7 @@ test("prevents a download from starting when free disk is below the floor", asyn await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB }); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "download")); // The preflight runs before the job is queued (and before the playlist is // even read), so the action returns an error instead of starting yt-dlp. await page.getByRole("button", { name: "Download from playlist" }).click(); @@ -58,13 +59,14 @@ test("lets downloads proceed when the gate is disabled (0)", async ({ await writeSettings({ minFreeDiskGB: 0 }); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( "Wrote 5 URLs", { timeout: 20_000 }, ); + await page.goto(channelStage("test-pipeline", "download")); await page.getByRole("button", { name: "Download from playlist" }).click(); await expect( page.getByLabel("Download from playlist output"), diff --git a/editor/e2e/do-not-clean.spec.ts b/editor/e2e/do-not-clean.spec.ts @@ -1,6 +1,13 @@ import { stat, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, + resolvePath, +} from "./helpers"; import { baseUrl } from "./baseUrl"; // Mirror common/lib/format.ts formatBytes so the e2e assertion matches the UI @@ -56,7 +63,7 @@ test("'do not clean' protects a video's audio from cleanup; toggling off restore expect(snapshot.buckets.transcribedWithAudio).not.toContain("vidA"); // Run the transcribed-audio cleanup from the channel's Cleanup stage. - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); await page.getByRole("button", { name: "Clean audio", exact: true }).click(); const log = page.getByLabel("Clean audio output"); await expect(log).toContainText("Skipped 1 (do not clean)", { @@ -75,8 +82,7 @@ test("'do not clean' protects a video's audio from cleanup; toggling off restore await expect(page.getByLabel("media archived")).toHaveCount(0); await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); await page.getByRole("button", { name: "Clean audio", exact: true }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( "Cleaned 1 audio file", @@ -108,7 +114,7 @@ test("snapshot records reclaimable cleanup bytes and the Cleanup stage shows the }>(SNAPSHOT_REL); expect(snapshot.cleanupBytes?.transcribedWithAudio).toBe(expectedBytes); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); await expect( page.getByText(`Estimated space to reclaim: ~${formatBytes(expectedBytes)}`), ).toBeVisible(); diff --git a/editor/e2e/download-format-guard.spec.ts b/editor/e2e/download-format-guard.spec.ts @@ -15,10 +15,14 @@ import { mkdir, writeFile, readFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { + channelStage, + channelVideos, + generateReport, pathExists, readJson, resetData, - resolvePath, generateReport } from "./helpers"; + resolvePath, +} from "./helpers"; type DownloadOutcome = { status: string; @@ -62,7 +66,7 @@ async function makeChannel( async function downloadAll(page: import("@playwright/test").Page, slug: string) { await generateReport(page, slug); - await page.goto(`/channels/${slug}`); + await page.goto(channelStage(slug, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("download complete", { timeout: 30_000 }); @@ -130,7 +134,7 @@ test("duration guard flags a source-truncated download and keeps the stub", asyn await expect .poll( async () => { - await page.goto(`/channels/${SLUG}?filter=short_audio`); + await page.goto(channelVideos(SLUG, { filter: "short_audio" })); return list.getByLabel(`open ${TRUNC}`).count(); }, { timeout: 15_000 }, @@ -138,7 +142,7 @@ test("duration guard flags a source-truncated download and keeps the stub", asyn .toBe(1); await expect(list.getByLabel(`open ${FULL}`)).toBeHidden(); // The kept stub is excluded from auto-transcribe (downloadedNoTranscript). - await page.goto(`/channels/${SLUG}?filter=downloaded_no_transcript`); + await page.goto(channelVideos(SLUG, { filter: "downloaded_no_transcript" })); await expect(list.getByLabel(`open ${TRUNC}`)).toBeHidden(); // Video page: the short-audio banner offers a re-download as Original. diff --git a/editor/e2e/download-part-files.spec.ts b/editor/e2e/download-part-files.spec.ts @@ -1,6 +1,11 @@ import { rename } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, + resolvePath, +} from "./helpers"; // `.part` files are yt-dlp's in-progress download placeholders. They should // NOT count as a complete audio download — otherwise the prefilter marks the @@ -22,7 +27,7 @@ test("audio.<ext>.part doesn't count as a complete audio file", async ({ ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); // The "Downloaded but not transcribed" list shows videos with audio on // disk but no transcript. With vidA's audio now hidden behind .part, only diff --git a/editor/e2e/exclude-from-counts.spec.ts b/editor/e2e/exclude-from-counts.spec.ts @@ -1,7 +1,12 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, + resolvePath, +} from "./helpers"; const CHANNEL = "test-transcribe"; const FIXTURE = "one-transcribe-channel-with-audio"; @@ -39,7 +44,7 @@ test("members-only video is hidden from the Missing metadata bucket", async ({ await writeAvailability("vidA", "members_only"); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await expect( page.getByRole("heading", { name: /Missing metadata\.info\.json \(2\)/ }), @@ -62,7 +67,7 @@ test("members-only video is hidden from the Failed transcriptions list", async ( ); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "transcribe")); await expect( page.getByRole("heading", { name: /Failed transcriptions \(1\)/ }), diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts @@ -327,6 +327,47 @@ function vttStamp(seconds: number): string { return `${h}:${m}:${s}.000`; } +// ── Channel route helpers ──────────────────────────────────────────────────── +// +// The channel page shows ONE stage panel at a time, selected by `?stage=`, and +// the video browser lives on its own route. Both are URLs a spec has to build, +// so they are built here rather than a hundred times inline. + +export type ChannelStage = + | "configure" + | "playlist" + | "download" + | "transcode" + | "transcribe" + | "digest" + | "backfill" + | "cleanup" + | "diagnostics" + | "danger"; + +// The channel overview with one stage panel open. Landing here is what the old +// `#stage-` hash plus a click on the section header used to do; the panel is +// server-rendered open, so a spec navigates and interacts — there is no expand +// step to perform first. +export function channelStage(slug: string, stage: ChannelStage): string { + return `/channels/${slug}?stage=${stage}`; +} + +// The video workspace: list on the left, selected video on the right. Carries +// the same `?video=` / `?filter=` / `?q=` parameters the browser has always +// used, so filter and selection semantics survive the move to its own route. +export function channelVideos( + slug: string, + params: { video?: string; filter?: string; q?: string } = {}, +): string { + const qs = new URLSearchParams(); + if (params.filter) qs.set("filter", params.filter); + if (params.q) qs.set("q", params.q); + if (params.video) qs.set("video", params.video); + const s = qs.toString(); + return s ? `/channels/${slug}/videos?${s}` : `/channels/${slug}/videos`; +} + // Minimal channel config so the channel page renders and the batch can run. export async function writeChannelConfig( channelSlug: string, diff --git a/editor/e2e/import-video.spec.ts b/editor/e2e/import-video.spec.ts @@ -1,6 +1,12 @@ import { readFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, +} from "./helpers"; // The Playlist stage's "Import single video" panel fetches one off-playlist // video by URL into the channel using downloadOneManaged (the same primitive @@ -11,7 +17,7 @@ test("import single video by URL fetches it and appends to the archive", async ( test.setTimeout(60_000); await resetData("test-pipeline"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); const importBtn = page.getByRole("button", { name: "Import video" }); // The button is gated until a URL is entered. @@ -54,7 +60,7 @@ test("import single video by URL fetches it and appends to the archive", async ( test("import rejects a non-URL input", async ({ page }) => { await resetData("test-pipeline"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); await page.getByLabel("Video URL to import").fill("not a url"); await page.getByRole("button", { name: "Import video" }).click(); diff --git a/editor/e2e/incomplete-transcript.spec.ts b/editor/e2e/incomplete-transcript.spec.ts @@ -9,7 +9,14 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelVideos, + generateReport, + pathExists, + readJson, + resetData, + resolvePath, +} from "./helpers"; const CHANNEL = "test-transcribe"; const DATA = `test-transcripts/channels/${CHANNEL}/data`; @@ -77,7 +84,7 @@ test("incomplete-transcript filter, glyph, panel banner, and actionable", async // --- Channel list: the chip filters to exactly the flagged video --- await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelVideos(CHANNEL)); const list = page.getByLabel("videos", { exact: true }); await expect(list.getByLabel("open vidTrunc")).toBeVisible(); @@ -133,7 +140,7 @@ test("channel bulk bar: clear incomplete resets the video and enables auto-runne }) => { await seed(); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelVideos(CHANNEL)); // Select the flagged video via the new quick-select, then clear it. await page @@ -175,13 +182,13 @@ test("channel bulk bar: clear incomplete resets the video and enables auto-runne await expect .poll( async () => { - await page.goto(`/channels/${CHANNEL}?filter=incomplete_transcript`); + await page.goto(channelVideos(CHANNEL, { filter: "incomplete_transcript" })); return list.getByLabel("open vidTrunc").count(); }, { timeout: 15000 }, ) .toBe(0); - await page.goto(`/channels/${CHANNEL}?filter=no_audio`); + await page.goto(channelVideos(CHANNEL, { filter: "no_audio" })); await expect(list.getByLabel("open vidTrunc")).toBeVisible(); }); @@ -190,7 +197,7 @@ test("channel bulk bar: re-download & re-transcribe queues a batch fix", async ( }) => { await seed(); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelVideos(CHANNEL)); await page .getByRole("button", { name: "Select incomplete", exact: true }) diff --git a/editor/e2e/job-stream-cancel.spec.ts b/editor/e2e/job-stream-cancel.spec.ts @@ -6,7 +6,11 @@ // guards against the regression. import { test, expect } from "@playwright/test"; -import { resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, +} from "./helpers"; import { baseUrl } from "./baseUrl"; async function readUncaughtCount(): Promise<{ @@ -36,7 +40,7 @@ test("disconnecting from a running job stream does not crash with 'Controller is // Start the slow sync — fake-ytdlp sleeps 30s before producing output, so // the producer is alive and pushing into the stream for the duration. await generateReport(page, "slow-channel"); - await page.goto("/channels/slow-channel"); + await page.goto(channelStage("slow-channel", "playlist")); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { timeout: 15_000, diff --git a/editor/e2e/jobs-active-order.spec.ts b/editor/e2e/jobs-active-order.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "@playwright/test"; -import { resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, +} from "./helpers"; async function setQueueViaCustom( page: import("@playwright/test").Page, @@ -23,7 +27,7 @@ test("active jobs page sorts channels with running jobs before queued-only chann await resetData("two-slow-channels"); await generateReport(page, "slow-a"); - await page.goto("/channels/slow-a"); + await page.goto(channelStage("slow-a", "playlist")); await setQueueViaCustom(page, "Sync", "qShared"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { @@ -31,7 +35,7 @@ test("active jobs page sorts channels with running jobs before queued-only chann }); await generateReport(page, "slow-b"); - await page.goto("/channels/slow-b"); + await page.goto(channelStage("slow-b", "playlist")); await page .getByLabel("queue for Sync", { exact: true }) .selectOption({ label: "qShared" }); diff --git a/editor/e2e/jobs-batch-tasks-drain.spec.ts b/editor/e2e/jobs-batch-tasks-drain.spec.ts @@ -6,7 +6,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, +} from "./helpers"; import { baseUrl } from "./baseUrl"; async function invalidateCache() { @@ -55,7 +61,7 @@ test("active jobs shows a per-operation progress bar that advances", async ({ await invalidateCache(); await generateReport(page, "tasks-one"); - await page.goto("/channels/tasks-one"); + await page.goto(channelStage("tasks-one", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/jobs/active"); @@ -103,7 +109,7 @@ test("active jobs shows an estimated time remaining once a task completes", asyn await invalidateCache(); await generateReport(page, "eta-one"); - await page.goto("/channels/eta-one"); + await page.goto(channelStage("eta-one", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/jobs/active"); @@ -138,10 +144,10 @@ test("draining a batch finishes in-flight work, skips the rest, and releases the // Both default to the shared transcription queue, so B waits behind A. await generateReport(page, "drain-a"); - await page.goto("/channels/drain-a"); + await page.goto(channelStage("drain-a", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await generateReport(page, "drain-b"); - await page.goto("/channels/drain-b"); + await page.goto(channelStage("drain-b", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/jobs/active"); @@ -185,10 +191,10 @@ test("'Drain all' drains the running batch and cancels the queued one", async ({ await invalidateCache(); await generateReport(page, "drainall-a"); - await page.goto("/channels/drainall-a"); + await page.goto(channelStage("drainall-a", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await generateReport(page, "drainall-b"); - await page.goto("/channels/drainall-b"); + await page.goto(channelStage("drainall-b", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/jobs/active"); @@ -239,10 +245,10 @@ test("a queued job can be cancelled directly from its row without opening the lo await invalidateCache(); await generateReport(page, "qcancel-a"); - await page.goto("/channels/qcancel-a"); + await page.goto(channelStage("qcancel-a", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await generateReport(page, "qcancel-b"); - await page.goto("/channels/qcancel-b"); + await page.goto(channelStage("qcancel-b", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/jobs/active"); @@ -279,7 +285,7 @@ test("hard Cancel during a drain ends the job cancelled without stream errors", ); await generateReport(page, "cancel-drain"); - await page.goto("/channels/cancel-drain"); + await page.goto(channelStage("cancel-drain", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/jobs/active"); diff --git a/editor/e2e/jobs-channel.spec.ts b/editor/e2e/jobs-channel.spec.ts @@ -1,12 +1,16 @@ import { test, expect } from "@playwright/test"; -import { resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, +} from "./helpers"; test("shows the originating channel as a link on the jobs list and detail", async ({ page, }) => { await resetData("test-pipeline"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( "Wrote 5 URLs", @@ -28,7 +32,7 @@ test("shows the originating channel as a link on the jobs list and detail", asyn test("auto-refreshes the jobs list when a job is running", async ({ page }) => { await resetData("slow-pipeline-channel"); await generateReport(page, "slow-channel"); - await page.goto("/channels/slow-channel"); + await page.goto(channelStage("slow-channel", "playlist")); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { timeout: 15_000, diff --git a/editor/e2e/jobs-reorder.spec.ts b/editor/e2e/jobs-reorder.spec.ts @@ -5,7 +5,12 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, + resolvePath, +} from "./helpers"; import { baseUrl } from "./baseUrl"; async function invalidateCache() { @@ -50,7 +55,7 @@ async function syncToQueue( queue: string, ) { await generateReport(page, slug); - await page.goto(`/channels/${slug}`); + await page.goto(channelStage(slug, "playlist")); await setQueueViaCustom(page, "Sync", queue); await page.getByRole("button", { name: "Sync" }).click(); } diff --git a/editor/e2e/jobs-retry.spec.ts b/editor/e2e/jobs-retry.spec.ts @@ -6,7 +6,13 @@ import { mkdir, rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { generateReport, pathExists, resetData, resolvePath } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, +} from "./helpers"; import { baseUrl } from "./baseUrl"; async function invalidateCache() { @@ -114,7 +120,9 @@ test("Retry on an archived bucket job re-derives the bucket's current members", resolvePath(`${ROOT}/data/vidB/audio.m4a.part`), ); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); // regenerates the snapshot when absent + // The partial-downloads bucket lives on the Download stage, and the channel + // page shows one stage at a time. + await page.goto(channelStage(CHANNEL, "download")); await expect( page.getByRole("heading", { name: /Partial downloads \(1\)/ }), ).toBeVisible(); diff --git a/editor/e2e/maybe-missing.spec.ts b/editor/e2e/maybe-missing.spec.ts @@ -1,6 +1,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { readJson, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + readJson, + resetData, + resolvePath, +} from "./helpers"; const CHANNEL = "availability-test"; const FIXTURE = "availability-baseline"; @@ -106,7 +112,7 @@ test("quick check flags videos missing from the fresh playlist", async ({ await setFreshPlaylist(BASELINE_IDS.filter((id) => id !== "viddeleted1")); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page .getByRole("button", { name: "Quick check (flat playlist)" }) .click(); @@ -146,7 +152,7 @@ test("full-check skips hard-excluded but re-checks unlisted/needs_auth", async ( await setFreshPlaylist(["vidpublic1", "vidpublic2", "vidprivate1"]); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page .getByRole("button", { name: "Quick check (flat playlist)" }) .click(); @@ -178,7 +184,7 @@ test("full-check resolves deleted vs unlisted and records history", async ({ await setFreshPlaylist(["vidpublic1", "vidpublic2"]); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page .getByRole("button", { name: "Quick check (flat playlist)" }) .click(); diff --git a/editor/e2e/no-orphan-fixtures.spec.ts b/editor/e2e/no-orphan-fixtures.spec.ts @@ -20,7 +20,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath, writeSettings, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, + resolvePath, + writeSettings, +} from "./helpers"; import { listFixtureProcesses } from "./fixtureProcs"; async function makeTranscribeChannel(slug: string, ids: string[]) { @@ -67,7 +73,7 @@ test("resetData cancels in-flight fixture children instead of orphaning them", a await makeTranscribeChannel("orphan-chan", ["slowoporphan1"]); await generateReport(page, "orphan-chan"); - await page.goto("/channels/orphan-chan"); + await page.goto(channelStage("orphan-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // Wait for the child to actually exist before trying to strand it, otherwise diff --git a/editor/e2e/no-subs-fallback.spec.ts b/editor/e2e/no-subs-fallback.spec.ts @@ -2,7 +2,14 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, writeSettings, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, + writeSettings, +} from "./helpers"; const CHANNEL = "test-livechat"; const ROOT = `test-transcripts/channels/${CHANNEL}`; @@ -28,7 +35,7 @@ test("default off: live_chat-only video downloads audio but defers whisper", asy test.setTimeout(120_000); await resetData("livechat-fallback-channel"); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( @@ -81,7 +88,7 @@ test("default off: no-captions video downloads audio but defers whisper", async test.setTimeout(120_000); await resetData("livechat-fallback-channel"); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( @@ -110,7 +117,7 @@ test("video with real auto-subs does NOT trigger fallback (regression)", async ( test.setTimeout(120_000); await resetData("livechat-fallback-channel"); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("Managed download complete", { @@ -133,7 +140,7 @@ test("inlineTranscribeOnFallback=true runs whisper inline after the fallback", a inlineTranscribeOnFallback: true, }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText( diff --git a/editor/e2e/parakeet-partial.spec.ts b/editor/e2e/parakeet-partial.spec.ts @@ -5,7 +5,15 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, + resolvePath, + writeSettings, +} from "./helpers"; async function makeTranscribeChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); @@ -44,7 +52,7 @@ test("Stop & keep progress pauses a parakeet run, caches the window, and resumes const transcript = `${dir}/transcript.json`; await generateReport(page, "partial-chan"); - await page.goto("/channels/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". @@ -67,7 +75,7 @@ test("Stop & keep progress pauses a parakeet run, caches the window, and resumes // cached window and completes the transcript. await gpu.getByRole("button", { name: /enable GPU parakeet/i }).click(); await generateReport(page, "partial-chan"); - await page.goto("/channels/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); @@ -91,7 +99,7 @@ test("Pause Transcriptions stops a parakeet run after the current segment, cache const transcript = `${dir}/transcript.json`; await generateReport(page, "gpause-chan"); - await page.goto("/channels/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 @@ -113,7 +121,7 @@ test("Pause Transcriptions stops a parakeet run after the current segment, cache // window and completes the transcript. await page.getByRole("button", { name: "Resume Transcriptions" }).click(); await generateReport(page, "gpause-chan"); - await page.goto("/channels/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); @@ -161,7 +169,7 @@ test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a f const failedFile = "test-transcripts/channels/hangterm-chan/failed-transcriptions"; await generateReport(page, "hangterm-chan"); - await page.goto("/channels/hangterm-chan"); + await page.goto(channelStage("hangterm-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await page.goto("/workers"); @@ -188,7 +196,7 @@ test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a f // transcript would never appear. await enable.click(); await generateReport(page, "hangterm-chan"); - await page.goto("/channels/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); diff --git a/editor/e2e/parakeet.spec.ts b/editor/e2e/parakeet.spec.ts @@ -1,6 +1,13 @@ import { readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, + writeSettings, +} from "./helpers"; const CHANNEL = "test-transcribe"; const DATA = `test-transcripts/channels/${CHANNEL}/data`; @@ -30,7 +37,7 @@ test("parakeet app transcribes audio and writes a transcript.json", async ({ await resetData("one-transcribe-channel-with-audio"); await selectParakeet(); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", @@ -59,7 +66,7 @@ test("parakeet output normalizes to chough-tagged cues", async ({ page }) => { await writeFile(resolvePath(`${DATA}/vidA/metadata.info.json`), META); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", diff --git a/editor/e2e/partial-downloads-bucket.spec.ts b/editor/e2e/partial-downloads-bucket.spec.ts @@ -5,7 +5,13 @@ import { rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, +} from "./helpers"; const CHANNEL = "test-transcribe"; const CHANNEL_ROOT = `test-transcripts/channels/${CHANNEL}`; @@ -21,7 +27,7 @@ test("partial download surfaces in a bucket on the channel page", async ({ ); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await expect( page.getByRole("heading", { name: /Partial downloads \(1\)/ }), @@ -47,7 +53,7 @@ test("resume action re-downloads the partial video", async ({ page }) => { ); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page .getByLabel("retry resume partial downloads bucket") @@ -91,7 +97,7 @@ test("resume retries a partial that carries an audio-check snapshot", async ({ ); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page .getByLabel("retry resume partial downloads bucket") diff --git a/editor/e2e/pipeline.spec.ts b/editor/e2e/pipeline.spec.ts @@ -1,11 +1,18 @@ import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, + resolvePath, +} from "./helpers"; test("store playlist writes a playlist file with 5 URLs", async ({ page }) => { await resetData("test-pipeline"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( "Wrote 5 URLs", @@ -22,13 +29,17 @@ test("download from playlist fetches all 5 URLs and writes the archive", async ( test.setTimeout(120_000); await resetData("test-pipeline"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( "Wrote 5 URLs", { timeout: 20_000 }, ); + // Store playlist lives on the Playlist stage and the download on the + // Download one; one panel is open at a time, so crossing between them is a + // navigation. + await page.goto(channelStage("test-pipeline", "download")); await page.getByRole("button", { name: "Download from playlist" }).click(); const downloadLog = page.getByLabel("Download from playlist output"); await expect(downloadLog).toContainText("5 new, 0 already archived", { @@ -61,13 +72,14 @@ test("re-running download skips already-archived entries", async ({ page }) => { test.setTimeout(120_000); await resetData("test-pipeline"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Store playlist" }).click(); await expect(page.getByLabel("Store playlist output")).toContainText( "Wrote 5 URLs", { timeout: 20_000 }, ); + await page.goto(channelStage("test-pipeline", "download")); await page.getByRole("button", { name: "Download from playlist" }).click(); const downloadLog = page.getByLabel("Download from playlist output"); await expect(downloadLog).toContainText("Managed download complete", { @@ -87,7 +99,7 @@ test("sync enumerates the channel, downloads new entries, writes lastSyncedAt", test.setTimeout(120_000); await resetData("test-pipeline"); await generateReport(page, "test-pipeline"); - await page.goto("/channels/test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); await page.getByRole("button", { name: "Sync" }).click(); // Sync now pages the flat-playlist (fake emits 5 entries) and downloads the // archive diff per page via the managed per-URL path. @@ -133,7 +145,7 @@ test("download videos skips a transcribed video even when its audio was cleaned ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -172,6 +184,9 @@ test("blocks pipeline buttons when channel has no url", async ({ page }) => { await page.getByLabel(/^slug/i).fill("no-url"); await page.getByRole("button", { name: /create channel/i }).click(); await page.waitForURL("**/channels/no-url", { timeout: 10_000 }); + // Creation lands on the channel overview; the no-URL notice and the buttons + // it blocks both live on the Playlist stage. + await page.goto(channelStage("no-url", "playlist")); await expect(page.getByText(/set a/i)).toBeVisible(); await expect( page.getByRole("button", { name: "Store playlist" }), diff --git a/editor/e2e/pre-clean-availability.spec.ts b/editor/e2e/pre-clean-availability.spec.ts @@ -3,11 +3,14 @@ import { fileURLToPath } from "node:url"; import { readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { + channelStage, + generateReport, pathExists, readJson, resetData, resolvePath, - writeSettings, generateReport } from "./helpers"; + writeSettings, +} from "./helpers"; import { baseUrl } from "./baseUrl"; // The transcribed-audio sweep deletes source audio irreversibly, so it first @@ -91,8 +94,7 @@ async function seedAll(): Promise<void> { async function runCleanAudio(page: import("@playwright/test").Page) { await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(CHANNEL, "cleanup")); const button = page.getByRole("button", { name: "Clean audio", exact: true }); await expect(button).toBeEnabled(); await button.click(); diff --git a/editor/e2e/queues.spec.ts b/editor/e2e/queues.spec.ts @@ -1,6 +1,11 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + resetData, + resolvePath, +} from "./helpers"; async function setQueueViaCustom( page: import("@playwright/test").Page, @@ -21,7 +26,7 @@ async function setQueueViaCustom( test("default queue is platform:<platform>", async ({ page }) => { await resetData("slow-pipeline-channel"); await generateReport(page, "slow-channel"); - await page.goto("/channels/slow-channel"); + await page.goto(channelStage("slow-channel", "playlist")); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { timeout: 15_000, @@ -55,7 +60,7 @@ test("Sync is refused while its platform is in a rate-limit cooldown", async ({ ); await generateReport(page, "slow-channel"); - await page.goto("/channels/slow-channel"); + await page.goto(channelStage("slow-channel", "playlist")); await page.getByRole("button", { name: "Sync" }).click(); // The refusal is a neutral notice (info), naming the platform + cooldown. await expect(page.getByLabel("Sync notice")).toContainText( @@ -81,6 +86,9 @@ test("unknown platform infers its queue from the URL domain", async ({ await page.getByLabel(/^url/i).fill("https://player.vimeo.com/video/12345"); await page.getByRole("button", { name: /create channel/i }).click(); await page.waitForURL("**/channels/vimeo-test", { timeout: 10_000 }); + // Creation lands on the overview; Sync's queue control is on the Playlist + // stage. + await page.goto(channelStage("vimeo-test", "playlist")); // No known platform → queue keyed by the registrable domain (subdomain // stripped), not the catch-all platform:unknown. @@ -95,7 +103,7 @@ test("queues a second job in the same queue, runs sequentially", async ({ await resetData("two-slow-channels"); await generateReport(page, "slow-a"); - await page.goto("/channels/slow-a"); + await page.goto(channelStage("slow-a", "playlist")); await setQueueViaCustom(page, "Sync", "qShared"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { @@ -103,7 +111,7 @@ test("queues a second job in the same queue, runs sequentially", async ({ }); await generateReport(page, "slow-b"); - await page.goto("/channels/slow-b"); + await page.goto(channelStage("slow-b", "playlist")); await page .getByLabel("queue for Sync", { exact: true }) .selectOption({ label: "qShared" }); @@ -137,7 +145,7 @@ test("runs jobs in different queues in parallel", async ({ page }) => { await resetData("two-slow-channels"); await generateReport(page, "slow-a"); - await page.goto("/channels/slow-a"); + await page.goto(channelStage("slow-a", "playlist")); await setQueueViaCustom(page, "Sync", "qA"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { @@ -145,7 +153,7 @@ test("runs jobs in different queues in parallel", async ({ page }) => { }); await generateReport(page, "slow-b"); - await page.goto("/channels/slow-b"); + await page.goto(channelStage("slow-b", "playlist")); await setQueueViaCustom(page, "Sync", "qB"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { @@ -165,7 +173,7 @@ test("cancels a queued job without disturbing the one running ahead of it", asyn await resetData("two-slow-channels"); await generateReport(page, "slow-a"); - await page.goto("/channels/slow-a"); + await page.goto(channelStage("slow-a", "playlist")); await setQueueViaCustom(page, "Sync", "qShared"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { @@ -173,7 +181,7 @@ test("cancels a queued job without disturbing the one running ahead of it", asyn }); await generateReport(page, "slow-b"); - await page.goto("/channels/slow-b"); + await page.goto(channelStage("slow-b", "playlist")); await page .getByLabel("queue for Sync", { exact: true }) .selectOption({ label: "qShared" }); @@ -203,7 +211,7 @@ test("empty queue runs immediately even when another job is queued in the same n // Park slow-a's Sync into qShared and start it. await generateReport(page, "slow-a"); - await page.goto("/channels/slow-a"); + await page.goto(channelStage("slow-a", "playlist")); await setQueueViaCustom(page, "Sync", "qShared"); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("test-slow", { @@ -213,7 +221,7 @@ test("empty queue runs immediately even when another job is queued in the same n // slow-b sets queue to immediate (the default empty option) and starts; // it should bypass qShared and run right away. await generateReport(page, "slow-b"); - await page.goto("/channels/slow-b"); + await page.goto(channelStage("slow-b", "playlist")); await page .getByLabel("queue for Sync", { exact: true }) .selectOption({ label: "(immediate)" }); diff --git a/editor/e2e/retry-bucket.spec.ts b/editor/e2e/retry-bucket.spec.ts @@ -1,7 +1,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, +} from "./helpers"; const CHANNEL = "availability-test"; const FIXTURE = "availability-baseline"; @@ -45,7 +51,7 @@ test("retry control renders for needs_auth and is absent on excluded buckets", a await writeAvailability("vidmembers1", "members_only"); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); const needsAuth = page.getByLabel("retry needs auth bucket"); await expect(needsAuth).toBeVisible(); @@ -68,7 +74,7 @@ test("retry control renders for the error bucket", async ({ page }) => { await writeAvailability("vidneedsauth1", "error", { error: "transient" }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); const errorBucket = page.getByLabel("retry error bucket"); await expect(errorBucket).toBeVisible(); @@ -90,7 +96,7 @@ test("clicking retry on needs_auth re-downloads the listed video", async ({ ); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await page .getByLabel("retry needs auth bucket") diff --git a/editor/e2e/saved-videos.spec.ts b/editor/e2e/saved-videos.spec.ts @@ -1,6 +1,12 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { readJson, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + readJson, + resetData, + resolvePath, +} from "./helpers"; import { baseUrl } from "./baseUrl"; // The transcribe fixture's channel slug. @@ -85,8 +91,7 @@ test("channel Cleanup stage shows the retention & persistence section", async ({ await seedSavedVideo("vidA", 1024); await generateReport(page, SLUG); - await page.goto(`/channels/${SLUG}`); - await page.getByRole("button", { name: "Cleanup stage summary" }).click(); + await page.goto(channelStage(SLUG, "cleanup")); const section = page.getByLabel("retention and persistence section"); await expect(section).toBeVisible(); await expect(section).toContainText("Saved source videos"); diff --git a/editor/e2e/shard.spec.ts b/editor/e2e/shard.spec.ts @@ -1,6 +1,13 @@ import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, + resolvePath, +} from "./helpers"; const AVAILABILITY_CHANNEL = "availability-test"; @@ -19,7 +26,7 @@ test.describe("Availability check sharding", () => { }) => { await resetData("availability-baseline"); await generateReport(page, AVAILABILITY_CHANNEL); - await page.goto(`/channels/${AVAILABILITY_CHANNEL}`); + await page.goto(channelStage(AVAILABILITY_CHANNEL, "diagnostics")); await page.getByLabel("shard availability total").first().fill("2"); await page.getByLabel("shard availability index").first().fill("1"); @@ -67,7 +74,7 @@ test.describe("Availability check sharding", () => { }) => { await resetData("availability-baseline"); await generateReport(page, AVAILABILITY_CHANNEL); - await page.goto(`/channels/${AVAILABILITY_CHANNEL}`); + await page.goto(channelStage(AVAILABILITY_CHANNEL, "diagnostics")); await page.getByLabel("shard availability total").first().fill("2"); await page.getByLabel("shard availability index").first().fill("1"); await page.getByRole("button", { name: "Resume checks" }).click(); @@ -85,7 +92,7 @@ test.describe("Availability check sharding", () => { test("Clear button removes the saved shard config", async ({ page }) => { await resetData("availability-baseline"); await generateReport(page, AVAILABILITY_CHANNEL); - await page.goto(`/channels/${AVAILABILITY_CHANNEL}`); + await page.goto(channelStage(AVAILABILITY_CHANNEL, "diagnostics")); await page.getByLabel("shard availability total").first().fill("2"); await page.getByLabel("shard availability index").first().fill("1"); await page.getByRole("button", { name: "Resume checks" }).click(); @@ -122,7 +129,7 @@ test.describe("Transcribe missing sharding", () => { }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await page .getByLabel("shard transcribe-missing total") @@ -187,7 +194,7 @@ test.describe("Transcribe missing sharding", () => { JSON.stringify({ cues: [] }), ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByLabel("shard transcribe-missing total").first().fill("1"); await page.getByLabel("shard transcribe-missing index").first().fill("1"); @@ -211,7 +218,7 @@ test.describe("Save shard (no run)", () => { }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByLabel("shard transcribe-missing total").first().fill("2"); await page.getByLabel("shard transcribe-missing index").first().fill("2"); @@ -249,7 +256,7 @@ test.describe("Save shard (no run)", () => { test("a run after Save reuses the saved slice", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByLabel("shard transcribe-missing total").first().fill("2"); await page.getByLabel("shard transcribe-missing index").first().fill("2"); @@ -313,7 +320,7 @@ test.describe("Download missing sharding", () => { await resetData("one-transcribe-channel-with-audio"); await setUpMissing(); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "download")); await page.getByLabel("shard download-missing total").first().fill("2"); await page.getByLabel("shard download-missing index").first().fill("1"); @@ -355,7 +362,7 @@ test.describe("Download missing sharding", () => { await resetData("one-transcribe-channel-with-audio"); await setUpMissing(); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "download")); await page.getByLabel("shard download-missing total").first().fill("2"); await page.getByLabel("shard download-missing index").first().fill("1"); diff --git a/editor/e2e/skip-live.spec.ts b/editor/e2e/skip-live.spec.ts @@ -3,11 +3,14 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; import { test, expect } from "@playwright/test"; import { + channelStage, + generateReport, pathExists, readJson, resetData, resolvePath, - writeSettings, generateReport } from "./helpers"; + writeSettings, +} from "./helpers"; import { baseUrl } from "./baseUrl"; const CHANNEL = "test-live"; @@ -49,7 +52,7 @@ test("skip-live on by default: live + upcoming skipped, VOD + normal download", test.setTimeout(120_000); await resetData("skip-live-channel"); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); @@ -129,7 +132,7 @@ test("global skipLiveDownloads=false downloads live videos (split still runs)", skipLiveDownloads: false, }); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("Managed download complete", { @@ -167,7 +170,7 @@ test("per-channel skipLiveDownloads=false overrides the global default", async ( await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "download")); await page.getByRole("button", { name: "Download videos" }).click(); const log = page.getByLabel("Download videos output"); await expect(log).toContainText("Managed download complete", { diff --git a/editor/e2e/sync-break-on-existing.spec.ts b/editor/e2e/sync-break-on-existing.spec.ts @@ -1,12 +1,17 @@ import { test, expect } from "@playwright/test"; -import { readJson, resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + readJson, + resetData, +} from "./helpers"; test("treats yt-dlp exit code 101 as success and updates lastSyncedAt", async ({ page, }) => { await resetData("sync-already-archived"); await generateReport(page, "archived-channel"); - await page.goto("/channels/archived-channel"); + await page.goto(channelStage("archived-channel", "playlist")); await page.getByRole("button", { name: "Sync" }).click(); await expect(page.getByLabel("Sync output")).toContainText("exit 101", { diff --git a/editor/e2e/sync-deep.spec.ts b/editor/e2e/sync-deep.spec.ts @@ -1,6 +1,7 @@ import { readFile, rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { + channelStage, generateReport, readJson, resetData, @@ -95,7 +96,7 @@ async function runFullSweep( page: import("@playwright/test").Page, previousLastSyncedAt: string | undefined, ): Promise<void> { - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "playlist")); for (let attempt = 0; attempt < 5; attempt++) { await page .getByRole("button", { name: "Full sweep", exact: true }) @@ -188,7 +189,7 @@ async function runSync( page: import("@playwright/test").Page, previousLastSyncedAt: string | undefined, ): Promise<void> { - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "playlist")); // Retried: a click landing before React hydrates fires nothing at all — the // long-standing flake pattern in this suite. Sync is idempotent here. for (let attempt = 0; attempt < 5; attempt++) { @@ -390,7 +391,7 @@ test("a never-downloaded video that leaves the listing is surfaced with its URL" ]); // And it is rendered, with a usable link and a recovery action. - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); const row = page.getByLabel("never fetched ghost0000001"); await expect(row).toBeVisible(); await expect(page.getByLabel("never fetched url ghost0000001")).toHaveAttribute( diff --git a/editor/e2e/transcript-source.spec.ts b/editor/e2e/transcript-source.spec.ts @@ -1,6 +1,12 @@ import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, +} from "./helpers"; const CHANNEL = "test-youtube"; const VIDEO_DIR = "20240101_test1234567"; @@ -76,7 +82,7 @@ test("diagnostics list a video with only transcript.en-US.vtt", async ({ // First channel-page load generates a fresh snapshot including the bucket. await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelStage(CHANNEL, "diagnostics")); await expect( page.getByRole("heading", { diff --git a/editor/e2e/undownloaded.spec.ts b/editor/e2e/undownloaded.spec.ts @@ -1,10 +1,15 @@ import { test, expect } from "@playwright/test"; -import { pathExists, resetData, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, +} from "./helpers"; test("shows empty state when there is no playlist", async ({ page }) => { await resetData("one-youtube-channel"); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "download")); await expect(page.getByText(/Undownloaded videos \(0\)/)).toBeVisible(); await expect(page.getByLabel("undownloaded videos empty")).toContainText( "All playlist videos are downloaded.", @@ -14,7 +19,7 @@ test("shows empty state when there is no playlist", async ({ page }) => { test("lists every playlist entry that is missing on disk", async ({ page }) => { await resetData("youtube-with-playlist"); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "download")); await expect(page.getByText(/Undownloaded videos \(4\)/)).toBeVisible(); const list = page.getByLabel("undownloaded videos list"); @@ -40,13 +45,15 @@ test("clicking an undownloaded entry lands on a usable video page", async ({ }) => { await resetData("youtube-with-playlist"); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "download")); await page.getByLabel("undownloaded video fake00000002").click(); await expect(page).toHaveURL(/\/videos\/fake00000002$/); - await expect(page.getByRole("heading", { level: 1 })).toContainText( - "fake00000002", - ); + // By name: the video title is an h2 (the channel layout owns the h1), and so + // is every section heading beside it. + await expect( + page.getByRole("heading", { name: "fake00000002" }), + ).toBeVisible(); await expect( page.getByRole("button", { name: /^Run download pipeline$/ }), ).toBeVisible(); @@ -71,7 +78,7 @@ test("undownloaded list shrinks after a one-click whisper completes", async ({ ).toBe(true); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "download")); await expect(page.getByText(/Undownloaded videos \(3\)/)).toBeVisible(); await expect( page.getByLabel("undownloaded video fake00000002"), diff --git a/editor/e2e/video-filter-combine.spec.ts b/editor/e2e/video-filter-combine.spec.ts @@ -5,7 +5,12 @@ import { rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath, generateReport } from "./helpers"; +import { + channelVideos, + generateReport, + resetData, + resolvePath, +} from "./helpers"; const CHANNEL = "test-transcribe"; const DATA = `test-transcripts/channels/${CHANNEL}/data`; @@ -37,7 +42,7 @@ test("Transcribed + Partial filters intersect to the both-states video", async ( }) => { await seedThreeStates(); await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); + await page.goto(channelVideos(CHANNEL)); const list = page.getByLabel("videos", { exact: true }); // All three visible by default. diff --git a/editor/e2e/video-page.spec.ts b/editor/e2e/video-page.spec.ts @@ -1,13 +1,22 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, +} from "./helpers"; test("per-video page renders metadata and file list", async ({ page }) => { await resetData("one-youtube-channel-with-data"); await page.goto("/channels/test-youtube/videos/20240101_test1234567"); - await expect(page.getByRole("heading", { level: 1 })).toContainText( - "Synthetic Test Video", - ); + // By NAME, not by level: the channel layout owns the page's single h1 (the + // channel name) so the video title is an h2 — and so is every section + // heading in the panel below it, which makes `{ level: 2 }` ambiguous. + await expect( + page.getByRole("heading", { name: "Synthetic Test Video" }), + ).toBeVisible(); const fileList = page.getByLabel("files for 20240101_test1234567"); await expect( fileList.getByLabel("file name metadata.info.json"), @@ -36,7 +45,7 @@ test("WhisperPanel renders failure list as links to per-video pages", async ({ "vidA\nvidB\n", ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await expect(page.getByLabel("failed transcription vidA")).toHaveAttribute( "href", "/channels/test-transcribe/videos/vidA", @@ -55,7 +64,7 @@ test("failed entry stays in the list across reloads (skip semantics)", async ({ "vidA\n", ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await expect(page.getByLabel("failed transcription vidA")).toBeVisible(); // Running Transcribe missing should skip the listed failure rather than // re-attempt it (the old retry-failures flow has been replaced by Clear). @@ -142,9 +151,9 @@ test("audio + whisper for a YouTube video with no auto-subs", async ({ }), ); await page.goto("/channels/test-youtube/videos/test1234567"); - await expect(page.getByRole("heading", { level: 1 })).toContainText( - "No-subs video", - ); + await expect( + page.getByRole("heading", { name: "No-subs video" }), + ).toBeVisible(); await page.getByLabel("download mode").selectOption("whisper"); await page.getByRole("button", { name: /^Audio \+ Whisper$/ }).click(); await expect( @@ -160,7 +169,7 @@ test("audio + whisper for a YouTube video with no auto-subs", async ({ test("Go to video form navigates to per-video page", async ({ page }) => { await resetData("one-youtube-channel-with-data"); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "diagnostics")); const form = page.getByLabel("Go to video"); await form.getByLabel("Video ID").fill("20240101_test1234567"); await form.getByRole("button", { name: "Open" }).click(); @@ -172,7 +181,7 @@ test("Go to video form navigates to per-video page", async ({ page }) => { test("Empty failure list shows muted placeholder", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await expect( page.getByLabel("failed transcriptions empty"), ).toBeVisible(); diff --git a/editor/e2e/whisper.spec.ts b/editor/e2e/whisper.spec.ts @@ -1,6 +1,13 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; +import { + channelStage, + channelVideos, + generateReport, + pathExists, + resetData, + resolvePath, +} from "./helpers"; // A video the downloader couldn't produce real audio for: the dir exists but the // only audio is an in-progress `.part`, and the audio-check pipeline recorded a @@ -33,7 +40,7 @@ async function writeCorruptSourceVideo(id: string, alreadyFailed: boolean) { test("transcribes every audio file with no transcript", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", @@ -53,7 +60,7 @@ test("Transcribe missing excludes VTT-only videos instead of failing them", asyn }) => { await resetData("youtube-with-playlist"); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); const log = page.getByLabel("Transcribe missing output"); // A video that already has a yt-dlp VTT transcript is not "remaining work", @@ -84,7 +91,7 @@ test("Transcribe missing skips corrupt-source videos and auto-prunes them from t // vidBad has no real audio (only a .part) and is stale-listed as failed. await writeCorruptSourceVideo("vidBad", true); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The three genuinely-downloaded videos transcribe; vidBad is never attempted. await expect(page.getByLabel("Transcribe missing output")).toContainText( @@ -114,12 +121,14 @@ test("a corrupt-source video surfaces as corrupt_source, not a failed transcript await resetData("one-transcribe-channel-with-audio"); await writeCorruptSourceVideo("vidBad", false); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); // The video row is labeled corrupt_source (download/source problem) rather - // than failed, and the Download stage summary calls it out for re-download. + // than failed… + await page.goto(channelVideos("test-transcribe")); await expect(page.getByLabel("status corrupt_source")).toBeVisible({ timeout: 15_000, }); + // …and the Download stage calls it out for re-download. + await page.goto(channelStage("test-transcribe", "download")); await expect( page.getByText(/corrupt source \(needs re-download\)/), ).toBeVisible(); @@ -128,12 +137,14 @@ test("a corrupt-source video surfaces as corrupt_source, not a failed transcript test("verify reports nothing missing once transcribed", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect(page.getByLabel("Transcribe missing output")).toContainText( "3 succeeded", { timeout: 30_000 }, ); + // Verify lives on the Diagnostics stage, one panel over. + await page.goto(channelStage("test-transcribe", "diagnostics")); await page.getByRole("button", { name: "Verify" }).click(); const result = page.getByLabel("verify result"); await expect(result).toContainText("Duplicates (0)", { timeout: 10_000 }); @@ -143,7 +154,7 @@ test("verify reports nothing missing once transcribed", async ({ page }) => { test("verify flags missing transcripts before whisper runs", async ({ page }) => { await resetData("one-transcribe-channel-with-audio"); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "diagnostics")); await page.getByRole("button", { name: "Verify" }).click(); await expect(page.getByLabel("verify result")).toContainText("Missing (3)", { timeout: 10_000, @@ -161,7 +172,7 @@ test("Retry failures button is gone; Clear failure list takes its place", async "vidA\nvidB\n", ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); await expect(page.getByRole("button", { name: "Retry failures" })).toHaveCount( 0, ); @@ -181,7 +192,7 @@ test("clearing the failure list lets the next Transcribe missing retry them", as "vidA\nvidB\n", ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcribe")); // Confirm the button is gated behind typing "clear". const clearButton = page.getByRole("button", { name: /Clear failure list \(2\)/, @@ -224,7 +235,7 @@ test("Transcode failures re-encodes each listed video to the chosen format", asy "vidA\nvidB\n", ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "transcode")); // Default format on the new dropdown is mp3. await page .getByRole("button", { name: /^Transcode failures → mp3$/ }) @@ -260,7 +271,7 @@ test("Clean audio removes audio files only from transcribed videos", async ({ JSON.stringify({ result: { language: "en" }, transcription: [] }), ); await generateReport(page, "test-transcribe"); - await page.goto("/channels/test-transcribe"); + await page.goto(channelStage("test-transcribe", "cleanup")); await page.getByRole("button", { name: "Clean audio" }).click(); await expect(page.getByLabel("Clean audio output")).toContainText( "Cleaned 1 audio file(s) from 1 of 3 video dir(s).", @@ -288,11 +299,14 @@ test("Clean audio removes audio files only from transcribed videos", async ({ test("whisper panel is shown on youtube channels for hybrid use", async ({ page }) => { await resetData("one-youtube-channel"); await generateReport(page, "test-youtube"); - await page.goto("/channels/test-youtube"); + await page.goto(channelStage("test-youtube", "transcribe")); // Channels can carry whisper transcripts alongside auto-subs; the // transcription panel actions should always be reachable. await expect( page.getByRole("button", { name: "Transcribe missing" }), ).toBeVisible(); + // Verify is the Diagnostics stage's, and it is reachable on a youtube channel + // for the same reason. + await page.goto(channelStage("test-youtube", "diagnostics")); await expect(page.getByRole("button", { name: "Verify" })).toBeVisible(); }); diff --git a/editor/e2e/worker-remote.spec.ts b/editor/e2e/worker-remote.spec.ts @@ -8,7 +8,14 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + resetData, + resolvePath, + writeSettings, +} from "./helpers"; import { baseUrl } from "./baseUrl"; const TOKEN = "test-worker-token"; @@ -129,7 +136,7 @@ test("a batch dispatched to a remote worker transcribes via the HTTP round-trip" await makeTranscribeChannel("remote-chan", ["vidremote1"]); await generateReport(page, "remote-chan"); - await page.goto("/channels/remote-chan"); + await page.goto(channelStage("remote-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The job log proves the video was dispatched over the remote path. @@ -163,7 +170,7 @@ test("an unreachable remote is auto-disabled and the video fails over to a local await makeTranscribeChannel("failover-chan", ["vidfo1"]); await generateReport(page, "failover-chan"); - await page.goto("/channels/failover-chan"); + await page.goto(channelStage("failover-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The video is still transcribed — on the local worker, after a health check diff --git a/editor/e2e/workers.spec.ts b/editor/e2e/workers.spec.ts @@ -4,7 +4,15 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData, resolvePath, writeSettings, generateReport } from "./helpers"; +import { + channelStage, + generateReport, + pathExists, + readJson, + resetData, + resolvePath, + writeSettings, +} from "./helpers"; import { baseUrl } from "./baseUrl"; // Reset the in-memory worker pool (and job registry) without restarting the @@ -116,7 +124,7 @@ test("pausing all workers pauses a running batch instead of failing it; resume c await makeTranscribeChannel("pause-batch", ids); await generateReport(page, "pause-batch"); - await page.goto("/channels/pause-batch"); + await page.goto(channelStage("pause-batch", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // Wait until the batch is running with its first transcription in flight. @@ -167,7 +175,7 @@ test("Workers page and Active jobs cross-reference the running task", async ({ await makeTranscribeChannel("xref-chan", ["slowopx1"]); await generateReport(page, "xref-chan"); - await page.goto("/channels/xref-chan"); + await page.goto(channelStage("xref-chan", "transcribe")); await page.getByRole("button", { name: "Transcribe missing" }).click(); // The Workers page shows the worker actively transcribing the video, with the