commit 9833ffaf00bca48ddc813197fac67356d9550328
parent 9262398967117e1fc8c9fd16c9d291d920f7514c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 11 Aug 2026 01:19:37 -0400
Make the channel page about the channel's lifecycle, not its video list
The first screen now shows a single transit line — Playlist → Download →
Transcode → Transcribe → Digest → Backfill — where stations carry counts and
coverage and the GAP between two stations carries the shortfall, because the gap
is the work. Only the largest shortfall is promoted typographically, so the
bottleneck is what the eye lands on without the widths lying about proportion.
The load-bearing invariant is that work ON the line and work that has LEFT it
are on different axes. `reachable` (missing + stale + partial) sits on the rule;
`missingInput`, `deferred`, `blocked`, `excludedFromDownload` and
`untranscribable` hang below it as muted "sidings". Four surfaces once summed
those together and put every channel permanently at the top of every list; a
layout that cannot express the sum cannot make the mistake. `channelFlow.ts`
holds the model, snapshot-only (the guard test bans corpus walks from render
paths), with `—` rather than `0` wherever the snapshot cannot say — a third of
the reports on disk predate `eligible`.
Below the line: one brand-coloured next action that disappears when there is
nothing to do, an attention strip that says "All clear" rather than vanishing,
and the stage panels ONE AT A TIME behind `?stage=`, so a stage is a shareable
link and the default render does no per-panel I/O. The old page stacked a
full-height video browser above eight simultaneously-expanded panels.
The video browser moves to /channels/<slug>/videos, a two-pane workspace with a
virtualized list — the largest channel put ~11,000 <li> + <Link> nodes in the
DOM for a pane showing twenty.
Five defects found while migrating the suite, four of them real bugs:
- NextAction duplicated NoReportYet's "Refresh report" accessible name, so the
e2e helper's locator hit a strict-mode violation and silently never clicked.
- The coverage <Progress> meter's aria-label contained the coverage text's own
label: the number was announced twice and the locator was ambiguous.
- aria-label="videos" had moved onto a <div> (role=generic, which cannot take
an accessible name), so assertions scoped to the list passed vacuously.
- The virtualizer rendered ZERO rows on first paint, deriving its window from
a not-yet-measured 0x0 rect. Invisible to a human; fatal to any read of the
DOM right after navigation. Fixed with initialRect.
- The Backfill card reported "N videos need derived data" directly above its
own copy saying "No backfill is enabled" — snapshot counts outlive the
feature being switched off. computeStageStatuses now takes backfillEnabled.
467 of 469 e2e pass. The two failures are load-sensitive flakes on a contended
box: cleanup-actionable:97 passes 8/8 in isolation, and
jobs-batch-tasks-drain:275 fails 2/3 on main as well.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
90 files changed, 3873 insertions(+), 1433 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.
- **The widget's "+N more channels" is now a control instead of dead text.** Both the monitor widget's Needs-work and Needs-cleaning lists show six channels and then said how many more there were, with no way to see them — even though the widget already had every channel in hand. That line is now a button: it expands the full list inside its own scrollable box, so a pinned window shows the whole worklist without stretching to the height of the corpus, and **Show fewer** puts it back.
- **The digest backlog that could never start now has a button that clears it, and honest copy about why.** Nearly 2,000 videos across the corpus have a transcript but no compact transcript file beside it, and the digest generator refuses those — so they sat in a "waiting" count on the Digest card under text that said the problem would resolve on its own. It does not. Nothing writes that file automatically for a channel whose captions are *downloaded* rather than transcribed here, which is why one channel alone accounts for 1,683 of them and was showing about 6% digest coverage. The card now says what is actually wrong, says that nothing will fix it unattended, and offers **Normalize transcripts** right there to fix it for that channel — instead of the corpus-wide button on the Build page that walks all 79,000 video folders. The button appears only when there is something for it to do.
- **Skipped-video explanations on the Backfill card now match the reason each one was skipped.** The card had one hardcoded sentence about the speaker-capture length limit and showed it for every kind of skipped video, including ones skipped for completely unrelated reasons. Each kind of work now supplies its own explanation and gets its own line.
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 stale
+ // bookmark 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/bookmarks.spec.ts b/editor/e2e/bookmarks.spec.ts
@@ -14,7 +14,13 @@
import { mkdir, rename, 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-transcribe";
const ROOT = `test-transcripts/channels/${CHANNEL}`;
@@ -24,7 +30,7 @@ const ROOT = `test-transcripts/channels/${CHANNEL}`;
// where the compact bookmarks menu is now shown.
async function transcribeAndBookmark(page: import("@playwright/test").Page) {
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",
@@ -150,7 +156,7 @@ test("management page: Run again retries whatever is partial now, then delete it
// Original run: resume the partial download (vidA).
await generateReport(page, CHANNEL);
- await page.goto(`/channels/${CHANNEL}`);
+ await page.goto(channelStage(CHANNEL, "download"));
await page
.getByLabel("retry resume partial downloads bucket")
.getByRole("button", { name: /^Retry \(1\)$/ })
@@ -184,7 +190,7 @@ test("management page: Run again retries whatever is partial now, then delete it
);
await rm(resolvePath(`${ROOT}/snapshot.json`), { force: true });
await generateReport(page, CHANNEL);
- await page.goto(`/channels/${CHANNEL}`); // regenerates snapshot when absent
+ await page.goto(channelStage(CHANNEL, "download")); // regenerates snapshot when absent
await expect(
page.getByRole("heading", { name: /Partial downloads \(1\)/ }),
).toBeVisible();
@@ -310,7 +316,7 @@ test("management page: an empty re-derived bucket reads as a neutral notice, not
);
await generateReport(page, CHANNEL);
- await page.goto(`/channels/${CHANNEL}`);
+ await page.goto(channelStage(CHANNEL, "download"));
await page
.getByLabel("retry resume partial downloads bucket")
.getByRole("button", { name: /^Retry \(1\)$/ })
@@ -338,7 +344,7 @@ test("management page: an empty re-derived bucket reads as a neutral notice, not
// Refresh the snapshot to reflect that.
await rm(resolvePath(`${ROOT}/snapshot.json`), { force: true });
await generateReport(page, CHANNEL);
- await page.goto(`/channels/${CHANNEL}`);
+ await page.goto(channelStage(CHANNEL, "download"));
await expect(
page.getByRole("heading", { name: /Partial downloads/ }),
).toHaveCount(0);
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/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