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