commit 1184ba988be42eb9907163068e66f7623774da84
parent df86e89f789a560a2bbf3ec188c972829900133f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 22 Aug 2026 18:56:37 -0400
channels: six pipelines, one strip, sortable by what can run now
/channels printed two bare integers and said nothing at all about the four
derived pipelines. All six now draw the same state band the /auto-queue rail
uses, at table scale.
NOT A PERCENT BAR, and that is the measurement talking: digest is at 0 done on
every large channel and diarization is 99.96% media-gone, so "% complete"
renders 0% on all 68 rows. What varies is the SHAPE of the remainder.
The signature: six bands across, 68 rows down, and what you see is a vertical
stripe of identical texture — verified offline against the real corpus, digest
all-accent, diarization all-hollow, attribution-diarized all-hatched, on every
one of the eight largest channels. That uniformity IS the finding. A channel
that breaks the stripe is the only thing your eye lands on, and finding it used
to mean opening 68 pages.
Every pipeline column sorts by `reachable`, which answers a question this page
has never been able to answer at all: which channel has the most diarizable
audio left right now.
Free: /channels already read every channel's full snapshot for its counts and
threw the rest away. No new I/O.
channels-counts.spec.ts passes UNCHANGED — the labelled cell keeps a text node
holding the raw count, as sr-only beside an aria-hidden band. That is strictly
better than the bare <td> it replaces: a screen reader now gets the number and a
label saying what it counts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
7 files changed, 426 insertions(+), 47 deletions(-)
diff --git a/common/lib/backfillKinds.test.ts b/common/lib/backfillKinds.test.ts
@@ -1204,6 +1204,11 @@ test("every catalogued operation declares a group and a cost basis", () => {
// model calls while every screen called it "Backfill".
for (const op of operationCatalog()) {
assert.ok(op.group, `${op.id} has no group`);
+ assert.ok(op.shortLabel.length > 0, `${op.id} has no shortLabel`);
+ assert.ok(
+ op.shortLabel.length <= 12,
+ `${op.id} shortLabel "${op.shortLabel}" is too long for a column header`,
+ );
assert.ok(op.costBasis.length > 0, `${op.id} has no costBasis`);
}
});
diff --git a/common/lib/backfillKinds.ts b/common/lib/backfillKinds.ts
@@ -259,6 +259,16 @@ export type BackfillRunOutcome =
// members.
export type OperationGroup = "media" | "transcript" | "digest" | "speakers";
+// Group order: upstream first. The /channels columns and the transit line both
+// lay their pipelines out in this order, so a reader moving between the two
+// pages sees the same left-to-right sequence.
+export const OPERATION_GROUP_ORDER: readonly OperationGroup[] = [
+ "media",
+ "transcript",
+ "digest",
+ "speakers",
+];
+
export function groupLabel(group: OperationGroup): string {
switch (group) {
case "media":
@@ -279,6 +289,14 @@ export type BackfillKind = {
hint: string;
// The pipeline this operation belongs to. See OperationGroup.
group: OperationGroup;
+ // The label at COLUMN width — one or two words, for a header that has to sit
+ // above a 48px band on a table 68 rows deep.
+ //
+ // Declared rather than abbreviated in the component, for the same reason
+ // `group` is: "Speaker names (from the transcript)" cannot be shortened
+ // mechanically, and a map of abbreviations maintained next to a table is a
+ // second place to forget when a kind is added.
+ shortLabel: string;
// WHAT ONE UNIT OF THIS OPERATION COSTS, as a phrase — "one audio pass per
// video", "~1 model call per transcript chunk".
//
@@ -367,6 +385,7 @@ const diarization: BackfillKind = {
label: "Speaker diarization",
hint: "Speaker turns captured from the audio, written to diarization.json beside the transcript.",
group: "speakers",
+ shortLabel: "Diarize",
costBasis: "one pass over the audio per video",
// The wording BackfillStage used to hardcode for every kind at once.
deferredHint:
@@ -538,6 +557,7 @@ const attributionText: BackfillKind = {
label: "Speaker names (from the transcript)",
hint: "Speakers reconstructed from the transcript alone, for videos with no diarization. Cheaper to reach, worse than the diarized lane, and it never overwrites one.",
group: "speakers",
+ shortLabel: "Names·T",
// The expensive one, and the reason costBasis is a field. A transcript is
// many chunks; this is the only lane in the table whose unit is not the video.
costBasis: "~1 model call per transcript chunk",
@@ -591,6 +611,7 @@ const attributionDiarized: BackfillKind = {
label: "Speaker names (from the audio)",
hint: "Names put to the speaker clusters in diarization.json — about one model call per video, and better than the text-only lane. Needs diarization to have run first.",
group: "speakers",
+ shortLabel: "Names·A",
costBasis: "~1 model call per video",
tier: "lane",
// The dependency the hint has always stated in prose. Declaring it is what
@@ -725,6 +746,7 @@ const digest: BackfillKind = {
label: "Digest",
hint: "Chapters and tags generated from the transcript by a local or metered model. Needs a transcript first.",
group: "digest",
+ shortLabel: "Digest",
costBasis: "~1 model call per transcript chunk",
// See the `deferred` branch in state() below for the measurement behind this
// wording. It says "run the normalize pass" and NOT "it clears itself",
@@ -936,6 +958,7 @@ export type ExternalOperation = {
label: string;
hint: string;
group: OperationGroup;
+ shortLabel: string;
costBasis: string;
lane: BackfillLane;
dependsOn?: readonly string[];
@@ -948,6 +971,7 @@ export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [
label: "Download",
hint: "Fetching the media. Dispatched by the auto-download runner and the per-channel pipeline actions.",
group: "media",
+ shortLabel: "Download",
costBasis: "one fetch per video, over the network",
// Really one queue per platform (downloadQueueKey), not a single key. Named
// here as the shape rather than the exact key, because the catalog's job is
@@ -960,6 +984,7 @@ export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [
label: "Transcription",
hint: "Turning audio into a transcript. Dispatched by the auto-transcribe runner across the worker pool.",
group: "transcript",
+ shortLabel: "Transcribe",
costBasis: "one pass over the audio per video, on a worker",
lane: { queueKey: TRANSCRIPTION_QUEUE, contendsFor: "gpu" },
dependsOn: ["download"],
@@ -974,6 +999,7 @@ export type OperationDescriptor = {
label: string;
hint: string;
group: OperationGroup;
+ shortLabel: string;
costBasis: string;
lane: BackfillLane;
dependsOn?: readonly string[];
@@ -988,6 +1014,7 @@ export function operationCatalog(): OperationDescriptor[] {
label: k.label,
hint: k.hint,
group: k.group,
+ shortLabel: k.shortLabel,
costBasis: k.costBasis,
lane: k.lane,
dependsOn: k.dependsOn,
@@ -1013,6 +1040,10 @@ export function operationGroup(id: string): OperationGroup | null {
// What one unit of an operation costs, in words. Empty string for an unknown
// id, so a surface can print it unconditionally without a placeholder.
+export function operationShortLabel(id: string): string {
+ return operationCatalog().find((o) => o.id === id)?.shortLabel ?? id;
+}
+
export function operationCostBasis(id: string): string {
return operationCatalog().find((o) => o.id === id)?.costBasis ?? "";
}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
+- **Every pipeline is now visible on /channels, not just two of them.** The table printed two bare integers — `Downloads` and `Transcripts` — and said nothing whatsoever about the four derived pipelines, which were collapsed everywhere else behind the word *backfill*. All six now draw the same **state band** the comparison rail on /auto-queue uses, at table scale: one fill per population, `can run now` the only saturated colour on the page, and pattern (solid / hatched / dotted / hollow) carrying the meaning ahead of hue, because no four-colour palette clears all-pairs colour-blindness. **Not a percent bar, deliberately** — digest sits at 0 done on every large channel and diarization is 99.96% media-gone, so "% complete" renders `0%` on all 68 rows and says nothing; what varies, and what an operator needs, is the *shape* of the remainder. Every pipeline column **sorts by what can run now**, which answers a question the page has never been able to answer: *which channel has the most diarizable audio left right now* previously meant opening 68 channel pages one at a time. The costs nothing extra to draw — `/channels` was already reading every channel's full snapshot for its counts and throwing the rest away.
+
- **`docker compose up -d` now stands up a working archive.** The repo had two Dockerfiles and neither ran the app: one fans per-site export builds out across containers, the other runs sharded e2e. So the only way to host this was to install the whole Unix toolchain by hand, which is why the Windows instructions said "use WSL2 and follow the Linux steps". There is now a runtime image and a compose stack — the editor plus Caddy by default, with the published site, the project homepage and umtool behind compose **profiles**, so somebody who only wants an archive runs two containers rather than five. First boot creates the volumes, downloads a speech model, and seeds a `settings.json` **carrying one enabled worker**: the defaults ship `workers: []`, zero workers means zero transcription slots, and a fresh container that looks healthy and silently transcribes nothing is the worst possible first run. Two things are deliberately not baked into the image and cannot be: the corpus, and the export site — that site is a static render *of* a corpus, and there is no corpus at image-build time, so `docker/publish-site.sh` builds it at run time into the volume the `site` service serves.
- **Nothing the container runs is reachable from outside the machine until you say so.** The editor has no authentication of any kind, shells out to yt-dlp, and deletes media — so **no application container publishes a port at all**. Caddy is the single front door, and every one of the four ports it publishes binds `127.0.0.1` by default, the public sites included; opening one is a deliberate edit of a single line in `.env`. Because a bind address is exactly the sort of thing that gets changed in a hurry, there is also a rail: if a private app is bound off-loopback with nothing checking credentials, **the containers refuse to start** — both the app and Caddy, which is the process that actually opens the ports — and print the four ways to fix it. `basic_auth` is built into Caddy so a password needs nothing installed; Tinyauth and Authelia attach through `forward_auth` as documented drop-in overlays; `ARCHILYZER_AUTH_MODE=none` is the one explicit escape hatch for people who already have their own front door.
- **The GPU is usable from the container, including on AMD.** Alongside the default CPU whisper.cpp image there is a **Vulkan** target running parakeet.cpp — one build that covers AMD (RADV), Intel and NVIDIA, needing nothing on the host but a render node at `/dev/dri` and no vendor container toolkit — and a CUDA target for NVIDIA whisper. Measured on an RX 6600 XT, through the app's own overlapping-window wrapper: **3.4 s against 36.3 s** for the same 33-second clip pinned to the CPU. That gap is also the thing to watch for, because the failure here is silent — a Vulkan container with no `/dev/dri` does not error, it transcribes correctly on the CPU about ten times slower. The entrypoint prints which one it got on every boot, and the image ships `vulkaninfo` so you can ask directly.
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -3,11 +3,45 @@
import Link from "next/link";
import { useMemo, useState } from "react";
import type { ChannelStat } from "yt-dlp-transcript-common/controller/channels";
+import type { OperationBand } from "../../components/pipelines/band";
+import {
+ bandSentence,
+ BandLegend,
+ StateBand,
+} from "../../components/pipelines/StateBand";
import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton";
import { ChannelBuildToggle } from "./ChannelBuildToggle";
import { ChannelSyncButton } from "./ChannelSyncButton";
import { ChannelSyncToggle } from "./ChannelSyncToggle";
+// A row is a stat plus its pipeline bands, in column order. The bands are
+// projected on the server from the same snapshot the counts come from, so a
+// figure in a band and the count beside it cannot disagree.
+export type ChannelRow = ChannelStat & {
+ pipelines: OperationBand[];
+};
+
+// A column heading for one pipeline. Comes off the operation registry on the
+// server (shortLabel, costBasis) rather than being abbreviated here — see
+// backfillKinds.ts.
+export type PipelineColumn = {
+ id: string;
+ shortLabel: string;
+ label: string;
+ costBasis: string;
+ // The two externally-dispatched pipelines keep their existing count column
+ // semantics: the cell's text node is the bare number the suite asserts on,
+ // under this accessible name. The slug is appended by the cell, so this is
+ // the prefix — `"downloads count"` becomes `"downloads count for foo"`, which
+ // is the name channels-counts.spec.ts has always addressed these cells by.
+ // Null for the derived operations, which have never had a count column and
+ // get the band's sentence as their text.
+ countLabel: string | null;
+};
+
+// Sorting a pipeline column sorts by `reachable` — "which channel has the most
+// diarizable audio left right now", a question this page has never been able to
+// answer without opening 68 channel pages one at a time.
type SortKey =
| "slug"
| "name"
@@ -16,14 +50,13 @@ type SortKey =
| "sync"
| "playlist"
| "lastSync"
- | "downloads"
- | "transcripts";
+ | `op:${string}`;
type SortDir = "asc" | "desc";
type SortState = { key: SortKey; dir: SortDir } | null;
-const DEFAULT_DIR: Record<SortKey, SortDir> = {
+const DEFAULT_DIR: Record<string, SortDir> = {
slug: "asc",
name: "asc",
handling: "asc",
@@ -31,10 +64,19 @@ const DEFAULT_DIR: Record<SortKey, SortDir> = {
sync: "asc",
playlist: "desc",
lastSync: "asc",
- downloads: "desc",
- transcripts: "desc",
};
+// A pipeline column defaults to `reachable` descending: the first click puts the
+// channels with work at the top, which is the only reason to sort by a pipeline
+// at all.
+function defaultDirFor(key: SortKey): SortDir {
+ return DEFAULT_DIR[key] ?? "desc";
+}
+
+function bandOf(row: ChannelRow, id: string): OperationBand | undefined {
+ return row.pipelines.find((b) => b.id === id);
+}
+
function compareStrings(a: string | undefined, b: string | undefined): number {
const aHas = a !== undefined && a !== "";
const bHas = b !== undefined && b !== "";
@@ -71,7 +113,11 @@ function compareBools(a: boolean, b: boolean): number {
return (a ? 1 : 0) - (b ? 1 : 0);
}
-function cmp(a: ChannelStat, b: ChannelStat, key: SortKey): number {
+function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number {
+ if (key.startsWith("op:")) {
+ const id = key.slice(3);
+ return compareNumbers(bandOf(a, id)?.reachable, bandOf(b, id)?.reachable);
+ }
switch (key) {
case "slug":
return a.slug.localeCompare(b.slug);
@@ -93,14 +139,22 @@ function cmp(a: ChannelStat, b: ChannelStat, key: SortKey): number {
return compareNumbers(a.playlistCount, b.playlistCount);
case "lastSync":
return compareDates(a.config.lastSyncedAt, b.config.lastSyncedAt);
- case "downloads":
- return compareNumbers(a.downloadCount, b.downloadCount);
- case "transcripts":
- return compareNumbers(a.transcriptCount, b.transcriptCount);
+ default:
+ return 0;
}
}
-export function ChannelsTable({ channels }: { channels: ChannelStat[] }) {
+export function ChannelsTable({
+ channels,
+ columns,
+}: {
+ channels: ChannelRow[];
+ // Which pipelines to draw, in group order, resolved on the server from the
+ // live settings. A switched-off feature is simply absent — which is the
+ // honest rendering: an empty column because nobody enabled attribution is not
+ // the same as an attribution lane with nothing left to do.
+ columns: PipelineColumn[];
+}) {
const [sort, setSort] = useState<SortState>(null);
const rows = useMemo(() => {
@@ -114,7 +168,7 @@ export function ChannelsTable({ channels }: { channels: ChannelStat[] }) {
if (prev && prev.key === key) {
return { key, dir: prev.dir === "asc" ? "desc" : "asc" };
}
- return { key, dir: DEFAULT_DIR[key] };
+ return { key, dir: defaultDirFor(key) };
});
}
@@ -167,20 +221,17 @@ export function ChannelsTable({ channels }: { channels: ChannelStat[] }) {
onClick={onHeaderClick}
className="whitespace-nowrap"
/>
- <SortableTh
- label="Downloads"
- sortKey="downloads"
- sort={sort}
- onClick={onHeaderClick}
- align="right"
- />
- <SortableTh
- label="Transcripts"
- sortKey="transcripts"
- sort={sort}
- onClick={onHeaderClick}
- align="right"
- />
+ {columns.map((col) => (
+ <SortableTh
+ key={col.id}
+ label={col.shortLabel}
+ sortKey={`op:${col.id}`}
+ sort={sort}
+ onClick={onHeaderClick}
+ className="w-28 min-w-24"
+ title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`}
+ />
+ ))}
<th className="text-left font-medium px-3 py-2 whitespace-nowrap">
Actions
</th>
@@ -230,18 +281,21 @@ export function ChannelsTable({ channels }: { channels: ChannelStat[] }) {
? new Date(c.config.lastSyncedAt).toLocaleString()
: ""}
</Td>
- <Td
- className="text-right"
- ariaLabel={`downloads count for ${c.slug}`}
- >
- {c.downloadCount}
- </Td>
- <Td
- className="text-right"
- ariaLabel={`transcripts count for ${c.slug}`}
- >
- {c.transcriptCount}
- </Td>
+ {columns.map((col) => (
+ <PipelineCell
+ key={col.id}
+ slug={c.slug}
+ column={col}
+ band={bandOf(c, col.id)}
+ count={
+ col.id === "download"
+ ? c.downloadCount
+ : col.id === "transcription"
+ ? c.transcriptCount
+ : null
+ }
+ />
+ ))}
<Td className="whitespace-nowrap">
<div className="flex items-center gap-2">
<ChannelSyncButton
@@ -258,10 +312,67 @@ export function ChannelsTable({ channels }: { channels: ChannelStat[] }) {
))}
</tbody>
</table>
+ <div className="flex flex-col gap-1 px-3 py-2 md:px-0">
+ <BandLegend />
+ {columns.some((c) => c.id.startsWith("attribution-")) && (
+ <p className="text-xs text-muted-foreground">
+ Speaker names come from the audio (Names·A) or the transcript
+ (Names·T) — two routes to the same field, counted
+ separately because they cost different things.
+ </p>
+ )}
+ </div>
</div>
);
}
+// ONE CELL OF THE STRIP.
+//
+// THE SIGNATURE OF THIS TABLE: six bands across, 68 rows down, and what you
+// actually see is a vertical stripe of identical texture running the height of
+// the page — because on this corpus every channel IS in the same state. That
+// uniformity is the finding, not a failure of the chart. A channel that breaks
+// the stripe (audio still retained, digests actually done) is the only thing
+// your eye lands on, and finding it used to mean opening 68 pages.
+//
+// NO PERCENTAGE TEXT, deliberately. Digest is at 0 done on every large channel
+// and diarization is 99.96% media-gone, so "% complete" renders 0% on all 68
+// rows and says nothing. What varies is the SHAPE of the remainder, which is
+// what the band draws. The exact figures live in `title` and in the cell's own
+// text, so nothing is hidden — it is just not repeated 408 times.
+function PipelineCell({
+ slug,
+ column,
+ band,
+ count,
+}: {
+ slug: string;
+ column: PipelineColumn;
+ band: OperationBand | undefined;
+ // The bare number for a column that already had one. THE SUITE ASSERTS ON
+ // THIS: channels-counts.spec.ts does getByLabel("transcripts count for X")
+ // .toHaveText("3"), so the labelled cell keeps a text node holding the raw
+ // count. It moves to sr-only beside an aria-hidden band, which is strictly
+ // better than the bare <td> it replaces — a screen reader now gets the number
+ // AND a label saying what it counts.
+ count: number | null;
+}) {
+ if (!band) return <Td className="text-muted-foreground">—</Td>;
+ const sentence = bandSentence(band);
+ return (
+ <Td
+ className="align-middle"
+ ariaLabel={`${column.countLabel ?? column.label} for ${slug}`}
+ title={`${column.label} — ${sentence}`}
+ >
+ <span className="sr-only">
+ {count === null ? sentence : String(count)}
+ </span>
+ <StateBand band={band} size="strip" />
+ </Td>
+ );
+}
+
function SortableTh({
label,
sortKey,
@@ -269,6 +380,7 @@ function SortableTh({
onClick,
align = "left",
className = "",
+ title,
}: {
label: string;
sortKey: SortKey;
@@ -276,6 +388,7 @@ function SortableTh({
onClick: (key: SortKey) => void;
align?: "left" | "right";
className?: string;
+ title?: string;
}) {
const active = sort?.key === sortKey;
const indicator = active ? (sort?.dir === "asc" ? "▲" : "▼") : "";
@@ -292,6 +405,7 @@ function SortableTh({
type="button"
onClick={() => onClick(sortKey)}
aria-label={`sort by ${label}`}
+ title={title}
className={
`inline-flex w-full items-center gap-1 ${justify} ` +
"font-medium hover:text-foreground cursor-pointer"
@@ -312,13 +426,19 @@ function Td({
children,
className = "",
ariaLabel,
+ title,
}: {
children: React.ReactNode;
className?: string;
ariaLabel?: string;
+ title?: string;
}) {
return (
- <td className={`px-3 py-2 ${className}`} aria-label={ariaLabel}>
+ <td
+ className={`px-3 py-2 ${className}`}
+ aria-label={ariaLabel}
+ title={title}
+ >
{children}
</td>
);
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -11,7 +11,20 @@ import {
listSiteIds,
siteChannelSlugs,
} from "yt-dlp-transcript-common/lib/site";
-import { ChannelsTable } from "./components/ChannelsTable";
+import {
+ allBackfillKinds,
+ operationCatalog,
+ OPERATION_GROUP_ORDER,
+ type OperationGroup,
+} from "yt-dlp-transcript-common/lib/backfillKinds";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { buildChannelBands } from "../components/pipelines/buildBands";
+import { EXTERNAL_BAND_IDS } from "../components/pipelines/buildBands";
+import {
+ ChannelsTable,
+ type ChannelRow,
+ type PipelineColumn,
+} from "./components/ChannelsTable";
import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton";
import { resolveActiveSite } from "../lib/activeSite";
@@ -46,6 +59,52 @@ function summariseFreshness(
return { oldest: oldestIso, missing };
}
+// WHICH PIPELINES GET A COLUMN, and in what order.
+//
+// Both answers come off the registry rather than a list maintained here: the
+// set is the two external operations plus every ENABLED backfill kind, and the
+// order is OPERATION_GROUP_ORDER, which the channel transit line lays its
+// stations out in too. A reader moving between the two pages sees the same
+// left-to-right sequence, and adding a kind adds a column without touching this
+// file.
+function pipelineColumns(operationIds: ReadonlyArray<string>): {
+ ids: string[];
+ columns: PipelineColumn[];
+} {
+ const catalog = new Map(operationCatalog().map((o) => [o.id, o]));
+ const ids = [...EXTERNAL_BAND_IDS, ...operationIds].filter((id) =>
+ catalog.has(id),
+ );
+ const rank = (id: string): number => {
+ const group = catalog.get(id)?.group as OperationGroup | undefined;
+ const i = group ? OPERATION_GROUP_ORDER.indexOf(group) : -1;
+ return i === -1 ? OPERATION_GROUP_ORDER.length : i;
+ };
+ // Stable within a group, so registry order still decides that diarization
+ // precedes the two attribution lanes that consume it.
+ const ordered = ids
+ .map((id, i) => ({ id, i }))
+ .sort((a, b) => rank(a.id) - rank(b.id) || a.i - b.i)
+ .map((e) => e.id);
+ const countLabels: Record<string, string> = {
+ download: "downloads count",
+ transcription: "transcripts count",
+ };
+ return {
+ ids: ordered,
+ columns: ordered.map((id) => {
+ const op = catalog.get(id)!;
+ return {
+ id,
+ shortLabel: op.shortLabel,
+ label: op.label,
+ costBasis: op.costBasis,
+ countLabel: countLabels[id] ?? null,
+ };
+ }),
+ };
+}
+
export default async function ChannelsPage({
searchParams,
}: {
@@ -57,7 +116,24 @@ export default async function ChannelsPage({
// Counts come from each channel's last snapshot, not a corpus walk. One read
// serves both the table and the freshness footer below.
const briefs = await listChannelBriefs(paths);
- const all = await listChannelStatsFromSnapshots(paths, briefs);
+ const stats = await listChannelStatsFromSnapshots(paths, briefs);
+ // The bands come off the SAME briefs the counts do — the snapshot is already
+ // in hand, so this is a fold over memory and not a second read. (The band for
+ // a channel and the corpus band it contributes to are literally the same
+ // function over the same data, which is what stops a channel figure and a
+ // rail figure disagreeing about what "downloaded" means.)
+ const { ids, columns } = pipelineColumns(
+ allBackfillKinds(getSettings()).map((k) => k.id),
+ );
+ // Keyed by slug rather than by index: listChannelStatsFromSnapshots happens
+ // to map the briefs in order today, and pairing a channel's counts with
+ // another channel's bands is exactly the kind of silent wrongness this whole
+ // change exists to remove.
+ const snapshots = new Map(briefs.map((b) => [b.slug, b.snapshot]));
+ const all: ChannelRow[] = stats.map((stat) => ({
+ ...stat,
+ pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids),
+ }));
// Scope to the active site's membership; "all sites" shows the full pool.
const channels =
active.isAll || !active.siteId
@@ -92,15 +168,17 @@ export default async function ChannelsPage({
</p>
) : (
<>
- <ChannelsTable channels={channels} />
+ <ChannelsTable channels={channels} columns={columns} />
<p
className="text-xs text-muted-foreground"
data-testid="channels-freshness"
>
{freshness.oldest ? (
<>
- Video, transcript and download counts come from each channel’s
- last report; the oldest on this page was generated{" "}
+ Every pipeline on this page — downloads, transcripts,
+ digests and the speaker lanes — is read from each
+ channel’s last report; the oldest on this page was
+ generated{" "}
<time dateTime={freshness.oldest}>
{new Date(freshness.oldest).toLocaleString()}
</time>
@@ -108,9 +186,9 @@ export default async function ChannelsPage({
</>
) : (
<>
- No channel on this page has a generated report yet, so every count
- reads zero. Run <em>Refresh report</em> from a channel to populate
- them.
+ No channel on this page has a generated report yet, so every
+ pipeline reads empty. Run <em>Refresh report</em> from a channel
+ to populate them.
</>
)}
{freshness.missing.length > 0 && freshness.oldest && (
diff --git a/editor/app/components/pipelines/buildBands.test.ts b/editor/app/components/pipelines/buildBands.test.ts
@@ -4,6 +4,7 @@ import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channe
import type { BackfillSnapshotEntry } from "yt-dlp-transcript-common/lib/backfillKinds";
import {
bandCoverage,
+ buildChannelBands,
buildOperationBands,
sumOrNull,
type OperationBand,
@@ -198,3 +199,130 @@ test("sumOrNull latches null and never returns a partial total", () => {
assert.equal(sumOrNull([1, null, 3]), null);
assert.equal(sumOrNull([]), 0);
});
+
+// ── THE PER-CHANNEL PROJECTION ──────────────────────────────────────────────
+//
+// The /channels strip and the channel page's station foot are the same fold as
+// the corpus rail, over one snapshot. These tests pin the three cases the strip
+// actually meets on the live corpus: every operation present, one operation the
+// snapshot has no entry for, and one that predates `eligible`.
+
+const CHANNEL_OPS = [
+ "diarization",
+ "attribution-diarized",
+ "attribution-text",
+ "digest",
+];
+
+test("a channel's bands carry every operation, and the two external ones", () => {
+ const bands = buildChannelBands(
+ snapshotOf({
+ totals: { videos: 11_344, transcribed: 11_339, downloaded: 11_340 },
+ buckets: {
+ downloadedNoTranscript: ["a", "b"],
+ noTranscript: ["a", "b", "c"],
+ untranscribable: ["c"],
+ } as ChannelSnapshot["buckets"],
+ undownloadedIds: ["x", "y", "z", "w"],
+ backfill: {
+ // The measured shape of the-quartering, in miniature: digest is all
+ // reachable with nothing done, diarization is almost all media-gone,
+ // and attribution-diarized is almost all blocked behind it.
+ diarization: entryOf({ missing: 1, missingInput: 11_333, eligible: 11_338 }),
+ "attribution-diarized": entryOf({
+ missing: 4,
+ blocked: 11_334,
+ eligible: 11_338,
+ }),
+ "attribution-text": entryOf({ missing: 11_337, eligible: 11_338 }),
+ digest: entryOf({ missing: 11_329, blocked: 2, eligible: 11_340 }),
+ },
+ }),
+ CHANNEL_OPS,
+ );
+
+ assert.deepEqual(
+ bands.map((b) => b.id),
+ ["download", "transcription", ...CHANNEL_OPS],
+ );
+
+ // Digest: ALL accent, nothing done. This is the row that makes a percent bar
+ // useless and the state band useful — "0% complete" is true of every large
+ // channel and says nothing; "11,329 can run now" is the whole story.
+ const digest = bandOf(bands, "digest");
+ assert.equal(digest.reachable, 11_329);
+ assert.equal(digest.blocked, 2);
+ assert.equal(digest.present, 9);
+ assert.equal(bandCoverage(digest), 9 / 11_340);
+
+ // Diarization: all hollow. 11,333 with no media left is not work, and must
+ // never be added to the 1 video that is.
+ const diarize = bandOf(bands, "diarization");
+ assert.equal(diarize.reachable, 1);
+ assert.equal(diarize.missingInput, 11_333);
+
+ // Attribution-diarized: all hatched, waiting on the lane above it.
+ const named = bandOf(bands, "attribution-diarized");
+ assert.equal(named.reachable, 4);
+ assert.equal(named.blocked, 11_334);
+
+ // The external pipelines use the SAME definitions the transit line does, so a
+ // channel figure and a corpus figure cannot disagree about "downloaded".
+ const download = bandOf(bands, "download");
+ assert.equal(download.eligible, 11_344 + 4);
+ assert.equal(download.present, 11_340);
+ assert.equal(download.reachable, 4);
+});
+
+test("an operation the snapshot has no entry for is an EMPTY band, not a missing column", () => {
+ // A channel whose report predates a kind still gets a cell — drawn empty,
+ // with a known denominator of 0, which bandCoverage reports as unknown rather
+ // than as 0% done. Dropping the column instead would make the table ragged
+ // and hide the fact that nothing has been measured yet.
+ const bands = buildChannelBands(
+ snapshotOf({
+ backfill: { diarization: entryOf({ missing: 5, eligible: 10 }) },
+ }),
+ CHANNEL_OPS,
+ );
+ const text = bandOf(bands, "attribution-text");
+ assert.equal(text.reachable, 0);
+ assert.equal(text.blocked, 0);
+ assert.equal(text.missingInput, 0);
+ assert.equal(text.eligible, 0);
+ assert.equal(bandCoverage(text), null);
+});
+
+test("an entry with no `eligible` draws an outline, never 0%", () => {
+ // UNKNOWN IS NOT ZERO, at channel scale. A snapshot written before the field
+ // existed — or one that has lapsed — has work counts but no denominator, and
+ // the band must say "we cannot tell you the coverage" rather than "none of it
+ // is done", which on a fully-diarized channel would be a lie.
+ const bands = buildChannelBands(
+ snapshotOf({
+ backfill: {
+ diarization: entryOf({ missing: 3, missingInput: 90 }),
+ },
+ }),
+ ["diarization"],
+ );
+ const diarize = bandOf(bands, "diarization");
+ assert.equal(diarize.eligible, null);
+ assert.equal(bandCoverage(diarize), null);
+ // The work counts survive the unknown denominator — they are separately
+ // known, and the cell still says how much can run now.
+ assert.equal(diarize.reachable, 3);
+ assert.equal(diarize.missingInput, 90);
+});
+
+test("a channel with no snapshot at all is every band empty", () => {
+ // A brand-new channel, before its first report. Every column present, every
+ // one empty, coverage unknown — the freshness note under the table is what
+ // explains why.
+ const bands = buildChannelBands(null, CHANNEL_OPS);
+ assert.equal(bands.length, 2 + CHANNEL_OPS.length);
+ for (const band of bands) {
+ assert.equal(band.reachable, 0);
+ assert.equal(bandCoverage(band), null);
+ }
+});
diff --git a/editor/app/components/pipelines/buildBands.ts b/editor/app/components/pipelines/buildBands.ts
@@ -194,3 +194,18 @@ export function buildOperationBands({
return [...bands.values()];
}
+
+// ONE CHANNEL'S BANDS — the strip on /channels and the station foot on a
+// channel page.
+//
+// A thin wrapper and deliberately not a second implementation: a channel figure
+// and the corpus figure it contributes to MUST agree about what "downloaded"
+// or "reachable" means, and the only way to guarantee that is for both to be
+// the same fold over the same snapshot. buildOperationBands already takes an
+// array; a channel is an array of one.
+export function buildChannelBands(
+ snapshot: ChannelSnapshot | null,
+ operationIds: ReadonlyArray<string>,
+): OperationBand[] {
+ return buildOperationBands({ snapshots: [snapshot], operationIds });
+}