commit ac6e6a0068ea1678f579b5cd94330ac60aa2ed48
parent 212f9175ecd5bafc3473237009643033a22db53f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 26 Aug 2026 00:46:35 -0400
channels: the speakers stage stops being called "backfill"
StageId "backfill" was a QUEUE KEY WEARING A STAGE'S NAME. Nobody can arm, pause
or run "a backfill" — BACKFILL_QUEUE is a scheduler key and correctly named as
one — while what the card actually holds is the speaker work: diarization and
the two attribution kinds. The card's own TITLE has been derived from the group
(operationsGroupLabel → "Speakers") for a while; only the id lagged.
StageId and FlowStationId rename together, because a station links to its card
as `?stage=${station.stage}` and a mismatch opens the wrong panel. tsc names
every site: the flow components hold Record<StageId, StageStatus>, GAP_LABEL and
the sidings maps are keyed by station id, and every in-app link is built from
the id.
?stage=backfill STILL WORKS. An unknown ?stage= resolves to the overview at 200,
which is right for a stage that no longer applies and wrong for one that was
renamed — a bookmark would open the overview with nothing saying the panel it
named is still there. STAGE_ALIASES resolves the old id first; new spec case.
TWO ASSERTIONS THAT WOULD HAVE PASSED VACUOUSLY after the rename, both updated
and both now carrying a note saying so:
- channelFlow.test.ts's `notEqual(off.next?.stage, "backfill")` — a notEqual
against a value the union can no longer hold is always true.
- channel-stage-selection.spec.ts's `getByLabel("backfill section")` count-0 —
an absent-count against a label nothing renders is always 0.
Renamed only the stage WRAPPER's aria-label ("backfill section" → "speakers
section", 4 spec lines). The POPULATION labels — "backfill kind …", "backfill
reachable/blocked/deferred/needs re-acquiring", "videos needing a backfill …" —
name the LANE's populations, appear on 16 spec lines, and go in the vocabulary
pass that follows this slice. Fewer hand-edited spec lines is less e2e risk for
the same result.
DELIBERATELY LEFT, each decided rather than missed:
- snapshot.backfill and BACKFILL_QUEUE — keyed by KIND id / a scheduler key.
- StationId (the /channels group buttons) — vocabulary pass.
- the /actionable section id, and its `getByLabel("backfill", {exact:true})`
at backfill.spec:767 and attribution.spec:363 — slice 4.
- the widget's SectionId — persisted in user URLs. Never.
ALSO, step 5: the stage list's middle comes from the registry.
GROUP_STAGES is an exhaustive Record<OperationGroup, readonly StageId[]> spread
in OPERATION_GROUP_ORDER, so a new operation group does not compile until it
names its stage(s). NOT a cardinality change and not 1:1 — `media` owns both
download and transcode, which is why it is a Record of ARRAYS. Same array, same
order; every ?stage= consumer is unaffected.
Deliberately only the middle: configure/playlist and cleanup/diagnostics/danger
are channel CHORES, not operations, and stay hand-listed. `transcode` is
filtered rather than omitted because whether it applies is a per-CHANNEL fact
and the Record is a per-install one.
New stageOrder.test.ts pins the derived list against the literal that shipped,
for both transcodeApplies values — tsc checks membership but neither order nor
cardinality — plus that no group names zero stages and no stage is claimed twice.
tsc clean; editor unit tests 85/85 (69 + 3 new + the renamed channelFlow cases).
e2e runs once at the end of the slice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
12 files changed, 245 insertions(+), 70 deletions(-)
diff --git a/editor/app/channels/[slug]/components/flow/NextAction.tsx b/editor/app/channels/[slug]/components/flow/NextAction.tsx
@@ -23,7 +23,7 @@ const VARIANT_FOR: Partial<
download: "downloadMissing",
transcribe: "transcribeMissing",
digest: "digestChannel",
- backfill: "backfillChannel",
+ speakers: "backfillChannel",
};
export function NextAction({
diff --git a/editor/app/channels/[slug]/components/stages/BackfillStage.tsx b/editor/app/channels/[slug]/components/stages/BackfillStage.tsx
@@ -114,7 +114,12 @@ export function BackfillStage({
].sort();
return (
- <div aria-label="backfill section" className="flex flex-col gap-3">
+ // RENAMED WITH THE STAGE. The POPULATION labels below ("backfill reachable",
+ // "backfill kind …") are deliberately NOT renamed here: they name the LANE's
+ // populations, appear on 16 spec lines, and go in the vocabulary pass that
+ // follows this slice. Fewer hand-edited spec lines is less e2e risk for the
+ // same result.
+ <div aria-label="speakers section" className="flex flex-col gap-3">
<div>
<h3 className="text-base font-semibold">
{heading} · {kinds.length}{" "}
diff --git a/editor/app/channels/[slug]/lib/channelFlow.test.ts b/editor/app/channels/[slug]/lib/channelFlow.test.ts
@@ -93,8 +93,8 @@ test("a pre-`eligible` snapshot reports unknown coverage, not zero", () => {
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);
+ assert.equal(station(flow, "speakers").through, null);
+ assert.equal(station(flow, "speakers").coverage, null);
});
test("the lane station does NOT sum its operations — it reads the lead one", () => {
@@ -123,7 +123,7 @@ test("the lane station does NOT sum its operations — it reads the lead one", (
},
);
- const lane = station(flow, "backfill");
+ const lane = station(flow, "speakers");
assert.equal(lane.through, 10);
assert.equal(lane.denominator, 10);
// The sum would be 1,010. Asserting the negative is the point.
@@ -149,17 +149,17 @@ test("the lane station is named after its operations, not its queue key", () =>
kind({ id: "attribution-text" }),
],
});
- assert.equal(station(speakers, "backfill").label, "Speakers");
+ assert.equal(station(speakers, "speakers").label, "Speakers");
const mixed = flowOf(snapshotOf(), {
backfillKinds: [kind({ id: "diarization" }), kind({ id: "digest" })],
});
- assert.equal(station(mixed, "backfill").label, "Derived data");
+ assert.equal(station(mixed, "speakers").label, "Derived data");
// Nothing enabled: the generic name, and no operations to state.
const off = flowOf(snapshotOf(), { backfillKinds: [] });
- assert.equal(station(off, "backfill").label, "Derived data");
- assert.deepEqual(station(off, "backfill").operations, []);
+ assert.equal(station(off, "speakers").label, "Derived data");
+ assert.deepEqual(station(off, "speakers").operations, []);
});
test("an unknown `eligible` on the lead operation still renders unknown, not zero", () => {
@@ -170,7 +170,7 @@ test("an unknown `eligible` on the lead operation still renders unknown, not zer
snapshotOf({ backfill: { diarization: entry({ missing: 3 }) } }),
{ backfillKinds: [kind({ id: "diarization" })] },
);
- const lane = station(flow, "backfill");
+ const lane = station(flow, "speakers");
assert.equal(lane.through, null);
assert.equal(lane.denominator, null);
assert.equal(lane.coverage, null);
@@ -198,14 +198,18 @@ test("a lane that is switched off reads neutral, never ok and never amber", () =
});
const off = flowOf(snapshot, { backfillKinds: [] });
- assert.equal(station(off, "backfill").tone, "neutral");
+ assert.equal(station(off, "speakers").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");
+ assert.equal(off.gaps.find((g) => g.to === "speakers")?.reachable, 0);
+ // NOT the pre-rename literal. This assertion passed vacuously the moment the
+ // stage id changed — a notEqual against a value the union can no longer hold
+ // is always true — so it is spelled with the live id and would fail if the
+ // disabled lane were ever offered as the next action again.
+ assert.notEqual(off.next?.stage, "speakers");
const on = flowOf(snapshot, { backfillKinds: [kind({ id: "diarization" })] });
- assert.equal(on.gaps.find((g) => g.to === "backfill")?.reachable, 7);
+ assert.equal(on.gaps.find((g) => g.to === "speakers")?.reachable, 7);
});
test("deferred, blocked and missing-input never enter a gap's reachable count", () => {
@@ -235,7 +239,7 @@ test("deferred, blocked and missing-input never enter a gap's reachable count",
);
const toDigest = flow.gaps.find((g) => g.to === "digest");
- const toBackfill = flow.gaps.find((g) => g.to === "backfill");
+ const toBackfill = flow.gaps.find((g) => g.to === "speakers");
// missing + stale + partial, and nothing else.
assert.equal(toDigest?.reachable, 4);
assert.equal(toBackfill?.reachable, 3);
@@ -251,9 +255,9 @@ test("deferred, blocked and missing-input never enter a gap's reachable 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);
+ assert.equal(sidingCount("speakers", "needs media re-acquired"), 500);
+ assert.equal(sidingCount("speakers", "deferred"), 11);
+ assert.equal(sidingCount("speakers", "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.
@@ -286,7 +290,7 @@ test("the transcode station appears only when the channel transcodes", () => {
);
assert.deepEqual(
withOut.gaps.map((g) => g.to),
- ["download", "transcribe", "digest", "backfill"],
+ ["download", "transcribe", "digest", "speakers"],
);
const withIt = flowOf(
@@ -297,7 +301,7 @@ test("the transcode station appears only when the channel transcodes", () => {
);
assert.deepEqual(
withIt.gaps.map((g) => g.to),
- ["download", "transcode", "transcribe", "digest", "backfill"],
+ ["download", "transcode", "transcribe", "digest", "speakers"],
);
assert.equal(withIt.gaps.find((g) => g.to === "transcode")?.reachable, 2);
assert.equal(station(withIt, "transcode").through, 58);
diff --git a/editor/app/channels/[slug]/lib/channelFlow.ts b/editor/app/channels/[slug]/lib/channelFlow.ts
@@ -66,7 +66,10 @@ export type FlowStationId =
| "transcode"
| "transcribe"
| "digest"
- | "backfill";
+ // Renamed with StageId — see stageStatus.ts. The station and the stage card it
+ // links to must carry the same id or `?stage=${station.stage}` opens the wrong
+ // panel.
+ | "speakers";
// One pipeline drawn under a station. The band is the same instrument the
// /channels strip and the /operations rail draw, at station scale — which is
@@ -351,8 +354,8 @@ export function computeChannelFlow(
stage: "digest",
operations: opsFor("digest"),
},
- backfill: {
- id: "backfill",
+ speakers: {
+ id: "speakers",
// NAMES THE WORK, NOT THE QUEUE. "Backfill" is a scheduler key — three
// operations happen to share it — and an operator cannot control, arm or
// pause "a backfill". They can pause speaker work. The name is derived
@@ -368,12 +371,12 @@ export function computeChannelFlow(
coverage: laneLead
? ratio(laneLead.band.present, laneLead.band.eligible)
: null,
- running: stages.backfill.running,
+ running: stages.speakers.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",
+ tone: laneOff ? "neutral" : stages.speakers.tone,
+ stage: "speakers",
operations: laneOps,
},
};
@@ -384,7 +387,7 @@ export function computeChannelFlow(
...(transcodeApplies ? (["transcode"] as const) : []),
"transcribe",
"digest",
- "backfill",
+ "speakers",
];
const stations = order.map((id) => stationById[id]);
@@ -473,25 +476,25 @@ export function computeChannelFlow(
"The digest pass recorded something a human should look at.",
),
],
- backfill: laneOff
+ speakers: laneOff
? []
: [
...siding(
"needs media re-acquired",
laneMissingInput,
- "backfill",
+ "speakers",
"The source audio is gone; re-acquiring it is an opt-in re-download.",
),
...siding(
"deferred",
laneDeferred,
- "backfill",
+ "speakers",
deferredHintFor(backfillKinds),
),
...siding(
"waiting on an earlier backfill",
laneBlocked,
- "backfill",
+ "speakers",
dependsOnHint(backfillKinds),
),
],
@@ -505,7 +508,7 @@ export function computeChannelFlow(
transcode: transcodeApplies ? buckets.untranscoded.length : 0,
transcribe: actionableDownloadedNoTranscript.length,
digest: digestWork.reachable,
- backfill: laneReachable,
+ speakers: laneReachable,
};
const GAP_LABEL: Record<FlowStationId, string> = {
@@ -514,7 +517,9 @@ export function computeChannelFlow(
transcode: "to transcode",
transcribe: "to transcribe",
digest: "to digest",
- backfill: "to backfill",
+ // A VALUE tsc keys but does not spell, so it is hand-checked:
+ // channel-line.spec.ts asserts this string.
+ speakers: "to speakers",
};
const gaps: FlowGap[] = [];
@@ -561,7 +566,7 @@ function pickNext(
// A verb and its object, derived from the group — "Run speaker work". The
// button used to read "Backfill", which is a queue key with no object and
// nothing an operator recognises as a thing they wanted done.
- backfill: `Run ${operationsActionLabel(laneKindIds)}`,
+ speakers: `Run ${operationsActionLabel(laneKindIds)}`,
};
for (const gap of gaps) {
if (gap.reachable <= 0) continue;
diff --git a/editor/app/channels/[slug]/lib/stageOrder.test.ts b/editor/app/channels/[slug]/lib/stageOrder.test.ts
@@ -0,0 +1,90 @@
+// Run with:
+// node_modules/.bin/tsx --test "editor/app/channels/[slug]/lib/stageOrder.test.ts"
+//
+// page.tsx builds its stage list from the registry rather than from a literal:
+//
+// ["configure", "playlist",
+// ...OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g])
+// .filter((id) => id !== "transcode" || transcodeApplies),
+// "cleanup", "diagnostics", "danger"]
+//
+// That array is what `?stage=` is resolved against and what the switcher renders
+// in order, so a change to it is a change to every stage link on the page. tsc
+// checks MEMBERSHIP (GROUP_STAGES is an exhaustive Record<OperationGroup, …>)
+// but not ORDER and not CARDINALITY — a group that gained a second stage, or an
+// OPERATION_GROUP_ORDER someone resorted, would compile and silently reorder the
+// page. This pins both against the literal list that shipped before the
+// derivation, for each value of the one per-channel input.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { OPERATION_GROUP_ORDER } from "yt-dlp-transcript-common/lib/backfillKinds";
+import { GROUP_STAGES, type StageId } from "./stageStatus";
+
+// The expression from page.tsx, verbatim. Duplicated rather than exported and
+// imported because page.tsx is a server component that reads the filesystem at
+// module scope; what is worth pinning is the SHAPE, and a copy that drifted from
+// the page would fail this test by construction on the next edit to either.
+function stageOrder(transcodeApplies: boolean): StageId[] {
+ return [
+ "configure",
+ "playlist",
+ ...OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]).filter(
+ (id) => id !== "transcode" || transcodeApplies,
+ ),
+ "cleanup",
+ "diagnostics",
+ "danger",
+ ];
+}
+
+test("the derived stage order is the list that shipped, transcode included", () => {
+ assert.deepEqual(stageOrder(true), [
+ "configure",
+ "playlist",
+ "download",
+ "transcode",
+ "transcribe",
+ "digest",
+ "speakers",
+ "cleanup",
+ "diagnostics",
+ "danger",
+ ]);
+});
+
+test("…and the same list without transcode where it does not apply", () => {
+ // Per-CHANNEL, not per-install: whether a platform needs a transcode pass is a
+ // fact about the channel, which is why it is filtered here rather than left
+ // out of GROUP_STAGES. `media` owns both stages either way.
+ assert.deepEqual(stageOrder(false), [
+ "configure",
+ "playlist",
+ "download",
+ "transcribe",
+ "digest",
+ "speakers",
+ "cleanup",
+ "diagnostics",
+ "danger",
+ ]);
+});
+
+test("every operation group names at least one stage, and none is orphaned", () => {
+ // The exhaustiveness tsc gives is on the KEYS. This is the other half: a group
+ // mapped to an empty array would compile and would mean an operation group
+ // with nowhere to appear on the channel page.
+ for (const group of OPERATION_GROUP_ORDER) {
+ assert.ok(
+ GROUP_STAGES[group].length > 0,
+ `${group} names no stage, so its operations have no card`,
+ );
+ }
+ // And no stage is claimed by two groups, which would render it twice.
+ const claimed = OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]);
+ assert.equal(
+ claimed.length,
+ new Set(claimed).size,
+ `a stage is claimed by more than one group: ${claimed.join(",")}`,
+ );
+});
diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts
@@ -9,6 +9,7 @@ import {
laneEntriesOf,
operationsGroupLabel,
reachableBackfillWork,
+ type OperationGroup,
} from "yt-dlp-transcript-common/lib/backfillKinds";
export type SnapshotBuckets = ChannelSnapshot["buckets"];
@@ -51,11 +52,43 @@ export type StageId =
| "transcode"
| "transcribe"
| "digest"
- | "backfill"
+ // THE OPERATIONS, NOT THE QUEUE. This card was called "backfill" — a queue key
+ // wearing a stage's name. Nobody can arm, pause or run "a backfill"; what the
+ // card actually holds is the speaker work (diarization and the two attribution
+ // kinds), which is a thing an operator recognises. The queue key BACKFILL_QUEUE
+ // is untouched: it is a scheduler key and correctly named as one.
+ //
+ // ?stage=backfill still resolves here — see STAGE_ALIASES in page.tsx.
+ | "speakers"
| "cleanup"
| "diagnostics"
| "danger";
+// THE STAGES EACH OPERATION GROUP OWNS, in the group's own order.
+//
+// Record<> is EXHAUSTIVE, which is the whole point: a new OperationGroup does
+// not compile until it names its stage(s). Before this, the channel page's stage
+// list was a hand-written literal, so a registered operation in a new group got
+// a card only if someone remembered to add one — and the failure mode was a
+// silent absence, not an error.
+//
+// NOT 1:1 with the groups, and no honest derivation makes it so: `media` owns
+// two stages (download and transcode). So this is a Record of ARRAYS, spread in
+// OPERATION_GROUP_ORDER — which yields exactly today's order and today's
+// cardinality. It is a compile-time membership check, not a re-shaping of the
+// page.
+//
+// DELIBERATELY ONLY THE MIDDLE. configure/playlist and cleanup/diagnostics/
+// danger are channel CHORES, not operations — nothing registers them and no
+// group owns them — so they stay hand-listed at the call site. Do not "finish"
+// this derivation by inventing groups for them.
+export const GROUP_STAGES: Record<OperationGroup, readonly StageId[]> = {
+ media: ["download", "transcode"],
+ transcript: ["transcribe"],
+ digest: ["digest"],
+ speakers: ["speakers"],
+};
+
export type StageTone = "neutral" | "attention" | "danger" | "running" | "ok";
export type StageStatus = {
@@ -87,14 +120,14 @@ const JOB_KIND_TO_STAGE: Record<string, StageId> = {
"digest-channel-local": "digest",
"digest-channel-remote": "digest",
"digest-share-cluster": "digest",
- "backfill-channel": "backfill",
+ "backfill-channel": "speakers",
// The sweep is corpus-wide and carries no channelSlug, so it will not normally
// appear in a channel's running jobs — mapped anyway so that if one ever is
// attributed here it lights the right card rather than none.
- "backfill-sweep": "backfill",
+ "backfill-sweep": "speakers",
// The pre-registry per-channel diarization button lands on the channel queue
// but is the same work this card is about, so it lights this card too.
- "diarize-channel": "backfill",
+ "diarize-channel": "speakers",
"clean-audio-transcribed": "cleanup",
"purge-superseded-auto-subs": "cleanup",
"clean-extra-audio-formats": "cleanup",
@@ -424,7 +457,7 @@ export function computeStageStatuses(
// measured corpus, so counting it would hold every channel permanently amber
// for work that cannot be done without an opt-in re-download — precisely the
// trap /api/widget/actionable documents for `noDigest`.
- const backfillRunning = runningByStage.has("backfill");
+ const backfillRunning = runningByStage.has("speakers");
// laneEntriesOf, not Object.values. The snapshot's per-kind map carries every
// operation in the catalog now, including digest — which runs on its own queue
// key, has its own stage card directly above, and would otherwise add ~75,000
@@ -482,8 +515,8 @@ export function computeStageStatuses(
`${backfillBlocked.toLocaleString()} waiting on an earlier backfill`,
);
}
- const backfill: StageStatus = {
- id: "backfill",
+ const speakers: StageStatus = {
+ id: "speakers",
// NAMED AFTER THE OPERATIONS, NOT THE QUEUE. "Backfill" is a scheduler key
// that on this install stands for three different operations; nobody can
// arm, pause or run "a backfill". Derived, so a lane that gains a kind from
@@ -591,7 +624,7 @@ export function computeStageStatuses(
transcode,
transcribe,
digest,
- backfill,
+ speakers,
cleanup,
diagnostics,
danger,
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -72,6 +72,7 @@ import {
laneBackfillKinds,
operationsActionLabel,
operationsGroupLabel,
+ OPERATION_GROUP_ORDER,
} from "yt-dlp-transcript-common/lib/backfillKinds";
import { ChannelLine } from "./components/flow/ChannelLine";
import { NextAction } from "./components/flow/NextAction";
@@ -83,6 +84,7 @@ import { readChannelConfigCached } from "./lib/channelConfigCache";
import {
computeStageStatuses,
normalizeBuckets,
+ GROUP_STAGES,
type StageId,
} from "./lib/stageStatus";
import {
@@ -255,14 +257,24 @@ export default async function ChannelDetailPage({
backfillKindIds: backfillKinds.map((k) => k.id),
});
+ // THE MIDDLE COMES FROM THE REGISTRY, in OPERATION_GROUP_ORDER. Same array as
+ // the literal it replaces, in the same order — this is a compile-time
+ // membership check, not a re-shaping: GROUP_STAGES is an exhaustive
+ // Record<OperationGroup, StageId[]>, so a new operation group does not compile
+ // until it says which stage(s) hold it.
+ //
+ // The ends stay hand-listed. configure/playlist and cleanup/diagnostics/danger
+ // are channel chores, not operations — see GROUP_STAGES.
+ //
+ // `transcode` is filtered rather than omitted from the Record, because whether
+ // it applies is a per-CHANNEL fact (does this platform need a transcode pass)
+ // and the Record is a per-INSTALL one.
const stageOrder: StageId[] = [
"configure",
"playlist",
- "download",
- ...(transcodeApplies ? (["transcode"] as const) : []),
- "transcribe",
- "digest",
- "backfill",
+ ...OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]).filter(
+ (id) => id !== "transcode" || transcodeApplies,
+ ),
"cleanup",
"diagnostics",
"danger",
@@ -274,8 +286,16 @@ export default async function ChannelDetailPage({
// 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;
+ // RETIRED STAGE IDS, so a bookmark does not silently land somewhere else.
+ // An unknown `?stage=` resolves to the OVERVIEW at 200 (pinned by
+ // channel-stage-selection.spec.ts), which is right for a stage that no longer
+ // applies — but wrong for one that was RENAMED: `?stage=backfill` would open
+ // the overview with no hint that the panel it named is still there under a new
+ // id. Resolve the alias first and the old link keeps working.
+ const STAGE_ALIASES: Record<string, StageId> = { backfill: "speakers" };
+ const resolvedStage = rawStage ? (STAGE_ALIASES[rawStage] ?? rawStage) : undefined;
const selectedStage: StageId | null =
- (stageOrder.find((id) => id === rawStage) as StageId | undefined) ?? null;
+ (stageOrder.find((id) => id === resolvedStage) as StageId | undefined) ?? null;
const flow = computeChannelFlow({
snapshot,
@@ -415,7 +435,7 @@ export default async function ChannelDetailPage({
/>
);
}
- case "backfill":
+ case "speakers":
return (
<BackfillStage
slug={slug}
diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts
@@ -148,7 +148,7 @@ async function seedChannel(opts: { diarization?: boolean } = {}) {
}
async function runBackfill(page: Page): Promise<void> {
- await page.goto(channelStage(CHANNEL, "backfill"));
+ await page.goto(channelStage(CHANNEL, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -227,7 +227,7 @@ test("the diarized lane upgrades a text-only record", async ({ page }) => {
// 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 page.goto(channelStage(CHANNEL, "speakers"));
await expect(
page.getByLabel("video needing a backfill attrvid0001"),
).toBeVisible();
@@ -301,8 +301,8 @@ test("the stage card and /actionable show attribution beside diarization, with t
});
await generateReport(page, CHANNEL);
- await page.goto(channelStage(CHANNEL, "backfill"));
- const section = page.getByLabel("backfill section");
+ await page.goto(channelStage(CHANNEL, "speakers"));
+ const section = page.getByLabel("speakers section");
// Per-kind lines appear because there is now more than one kind — the same
// component, unmodified, driven by a bigger registry.
diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts
@@ -151,8 +151,8 @@ test("the stage card separates reachable work from what needs its media back", a
});
await generateReport(page, SLUG);
- await page.goto(channelStage(SLUG, "backfill"));
- const section = page.getByLabel("backfill section");
+ await page.goto(channelStage(SLUG, "speakers"));
+ const section = page.getByLabel("speakers section");
await expect(section.getByLabel("backfill reachable")).toContainText(
"1 video can be worked on now",
);
@@ -181,7 +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(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -225,7 +225,7 @@ test("a sidecar from a different threshold is regenerated", async ({ page }) =>
}) + "\n",
);
- await page.goto(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -258,7 +258,7 @@ test("re-acquired media is deleted after a successful backfill", async ({
}
expect(await audioFiles("vidB")).toEqual([]);
- await page.goto(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -292,7 +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(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -328,7 +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(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -364,7 +364,7 @@ test("the disk floor refuses to re-acquire anything", async ({ page }) => {
await rm(resolvePath(dataRel("vidB", f)), { force: true });
}
- await page.goto(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -607,7 +607,7 @@ test("a backfill runs concurrently with a transcription", async ({
), { timeout: 30_000 })
.toBe(true);
- await page.goto(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -691,7 +691,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(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await page
.getByRole("button", { name: "Run speaker work", exact: true })
.click();
@@ -879,8 +879,8 @@ 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(channelStage(SLUG, "backfill"));
- const backfillSection = page.getByLabel("backfill section");
+ await page.goto(channelStage(SLUG, "speakers"));
+ const backfillSection = page.getByLabel("speakers section");
// 1 — vidA's diarization. NOT 3, which is what folding the digest entry in
// would produce here.
await expect(backfillSection.getByLabel("backfill reachable")).toContainText(
diff --git a/editor/e2e/channel-line.spec.ts b/editor/e2e/channel-line.spec.ts
@@ -231,7 +231,7 @@ test("a lane that is switched off reads neutral, not finished", async ({
await writeSettings(laneSettings(true));
await page.goto(`/channels/${SLUG}`);
await expect(
- page.getByLabel("channel line").getByLabel("to backfill shortfall"),
+ page.getByLabel("channel line").getByLabel("to speakers shortfall"),
).toContainText("7");
await expect(
page.getByLabel("channel line").getByLabel("Speakers through"),
@@ -247,9 +247,9 @@ test("a lane that is switched off reads neutral, not finished", async ({
// operationsGroupLabel over an empty set. It cannot claim "Speakers" when no
// speaker operation is on.
await expect(line.getByLabel("Derived data through")).toHaveText("—");
- await expect(line.getByLabel("to backfill shortfall")).toContainText("0");
+ await expect(line.getByLabel("to speakers shortfall")).toContainText("0");
// …and the stage says why rather than claiming completion.
- await page.goto(channelStage(SLUG, "backfill"));
+ await page.goto(channelStage(SLUG, "speakers"));
await expect(page.getByLabel("Derived data stage summary")).toContainText(
"Nothing here is enabled",
);
diff --git a/editor/e2e/channel-stage-selection.spec.ts b/editor/e2e/channel-stage-selection.spec.ts
@@ -27,7 +27,11 @@ test("?stage=download renders the Download panel and NOT the Transcribe one", as
).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 LIVE LABEL, not the pre-rename one. This assertion passed vacuously the
+ // moment the stage card's aria-label changed from "backfill section" — an
+ // absent-count check against a label nothing renders is always 0 — so it is
+ // spelled with the label the card actually carries.
+ await expect(page.getByLabel("speakers section")).toHaveCount(0);
// The selected tab says so.
await expect(page.getByLabel("Download stage tab")).toHaveAttribute(
@@ -92,6 +96,20 @@ test("an unknown ?stage= falls back to the overview rather than 404ing", async (
await expect(page.getByLabel("overview panel")).toBeVisible();
});
+test("?stage=backfill still opens the speakers panel", async ({ page }) => {
+ // A RENAMED stage is not an unknown one. The test above pins that an unknown
+ // ?stage= lands on the overview at 200, which is right for a stage that no
+ // longer applies — and would be wrong here: a bookmarked ?stage=backfill would
+ // open the overview with nothing saying the panel it named is still there. The
+ // alias in page.tsx is what makes the old link keep working.
+ await resetData("one-transcribe-channel-with-audio");
+ await generateReport(page, SLUG);
+ const resp = await page.goto(`/channels/${SLUG}?stage=backfill`);
+ expect(resp?.status()).toBe(200);
+ await expect(page.getByLabel("speakers section")).toBeVisible();
+ await expect(page.getByLabel("overview panel")).toHaveCount(0);
+});
+
test("the Videos tab is a separate route, and the stage panels are not on it", async ({
page,
}) => {
diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts
@@ -340,7 +340,7 @@ export type ChannelStage =
| "transcode"
| "transcribe"
| "digest"
- | "backfill"
+ | "speakers"
| "cleanup"
| "diagnostics"
| "danger";