Archilyzer · Source

archilyzer

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

commit 1815abeaae2cfb01b4c69b81a2caa3841b970c78
parent 60fcf2a714547f6760223da0d74de80311c4770f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  9 Aug 2026 16:48:03 -0400

Tell "waiting on a prerequisite" apart from "the media is gone"

attribution-diarized waits on diarization.json and said so by returning
missing-input. But missing-input means one specific thing to the rest of
the system: the media is gone, re-acquire it. So ~73,000 videos were
counted in the re-acquire population, and with allowRedownload on the
lane would have spent a download each fetching AUDIO -- which can never
satisfy a wait for diarization.json -- and then deleted it again.

`blocked` is now its own state, following the `deferred` precedent
exactly: counted separately, never summed into reachableBackfillWork,
never dispatched, and -- the part that fixes the defect -- entirely
unaffected by allowRedownload. Also immune to `force`, because forcing a
video whose prerequisite does not exist does not make it exist. The
`never` default on candidateAction did its job: omitting the arm was a
compile error, not a silently dispatched video.

dependsOn is declarative and buys two things. resolveBackfillKinds now
topologically sorts by it, so a video diarized during a pass is
attributed in the SAME pass rather than on whatever later pass finds the
sidecar. The sort is stable, which is load-bearing: attribution-text
must stay last so the better lane reaches a diarized video first (one
wasted classification instead of ~30 model calls). A cycle or a
dangling id degrades to declaration order rather than wedging the lane.

Two existing assertions pinned the old behaviour and both now pin the
fix -- backfillKinds.test.ts:562 and attribution.spec.ts's stage card,
where the "needs media re-acquired" line is now absent entirely because
nothing in that fixture needs media.

  common 638/638, attribution+backfill+diarization e2e 21/21,
  tsc clean both packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Diffstat:
Mcommon/controller/backfillBatch.ts | 26+++++++++++++++++++++++++-
Mcommon/lib/backfillKinds.test.ts | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcommon/lib/backfillKinds.ts | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Meditor/CHANGELOG.md | 2++
Meditor/app/channels/[slug]/backfillActions.ts | 3+++
Meditor/app/channels/[slug]/components/stages/BackfillStage.tsx | 32++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/lib/stageStatus.ts | 11+++++++++++
Meditor/app/channels/[slug]/page.tsx | 12+++++++++++-
Meditor/e2e/attribution.spec.ts | 31+++++++++++++++++++++++--------
9 files changed, 346 insertions(+), 31 deletions(-)

diff --git a/common/controller/backfillBatch.ts b/common/controller/backfillBatch.ts @@ -127,6 +127,11 @@ export type BackfillBatchResult = { // same reason missingInput is: it is neither work done nor work failed, and // folding it into either would make a capped run read as a complete one. deferred: number; + // Videos waiting on a prerequisite kind's output. Separate again, and for a + // reason the others do not share: this number is expected to fall by itself + // as the prerequisite lane runs, so an operator seeing it should wait rather + // than change a setting. + blocked: number; }; // What the candidate pull does with one classification. @@ -153,7 +158,9 @@ export type CandidateAction = // Input is gone and re-acquiring is off. | "missing-input" // The kind refuses to attempt it under the current configuration. - | "deferred"; + | "deferred" + // Waiting on a prerequisite kind's output. + | "blocked"; export function candidateAction( state: BackfillClassification, @@ -173,6 +180,18 @@ export function candidateAction( // "redo work that looks done", not "ignore the cap". Raising the cap is how // you ask for a deferred video, and it is one edit in Settings. return "deferred"; + case "blocked": + // NEVER dispatched, and — like `deferred` — deliberately immune to + // `force`. Forcing a video whose prerequisite has not been produced does + // not make the prerequisite appear; it just hands the runner an input it + // does not have. Running the kind this one dependsOn is how you unblock + // it, and the ordering in resolveBackfillKinds tries to do that for you + // within the same pass. + // + // Note what is NOT here: re-acquiring media. That is what separating this + // from `missing-input` bought — allowRedownload has no bearing on a + // blocked video, so the lane cannot spend a download on one. + return "blocked"; case "missing": case "stale": return "dispatch"; @@ -204,6 +223,7 @@ export async function runBackfillBatch( reacquireFailed: 0, diskFloorHit: false, deferred: 0, + blocked: 0, }; if (kinds.length === 0) { @@ -300,6 +320,10 @@ export async function runBackfillBatch( result.deferred++; continue; } + if (action === "blocked") { + result.blocked++; + continue; + } if (action === "missing-input") { // Counted, not attempted. This is the population the whole // reachable-vs-needs-re-acquiring split exists to keep visible. diff --git a/common/lib/backfillKinds.test.ts b/common/lib/backfillKinds.test.ts @@ -7,11 +7,14 @@ import { getBackfillKind, laneBackfillKinds, resolveBackfillKinds, + orderByDependencies, addBackfillState, emptyBackfillCounts, reachableBackfillWork, type BackfillClassification, + type BackfillKind, } from "./backfillKinds"; +import { candidateAction } from "../controller/backfillBatch"; import { readVideoFiles, CUES_JSON_FILENAME, @@ -559,14 +562,105 @@ test("attribution-text has nothing to do where a diarized record exists", async ); }); -test("attribution-diarized: no diarization.json is missing-input, not missing", async () => { - // ~73,000 videos on this corpus, against a handful reachable. The whole reason - // the four-state split exists — a single "remaining" number here would put - // every channel at the top of every list forever. - assert.equal(await classifyAttr(attrDiarized, {}), "missing-input"); +test("attribution-diarized: no diarization.json is BLOCKED, not missing-input", async () => { + // ~73,000 videos on this corpus, against a handful reachable — so this state + // has to be right or every surface is wrong. + // + // THIS ASSERTION USED TO SAY missing-input, AND THAT WAS THE DEFECT. + // missing-input means one thing to the rest of the system: the media is gone, + // re-acquire it. So these videos were counted as needing media re-fetched, + // and with allowRedownload on the lane would have spent a download per video + // fetching AUDIO — which cannot satisfy a wait for diarization.json — and + // then deleted it again. What they are waiting for is the `diarization` kind, + // which this table produces. + assert.equal(await classifyAttr(attrDiarized, {}), "blocked"); assert.equal( await classifyAttr(attrDiarized, { attribution: attrSidecar() }), - "missing-input", + "blocked", + ); +}); + +test("blocked is never dispatched, and re-download cannot change that", () => { + // The dispatch decision is the consequential one: `blocked` must not reach a + // runner whatever the flags say. Note allowRedownload — the flag that DOES + // turn missing-input into a dispatch — is deliberately inert here. + for (const force of [false, true]) { + for (const allowRedownload of [false, true]) { + assert.equal( + candidateAction("blocked", { force, allowRedownload }), + "blocked", + `force=${force} allowRedownload=${allowRedownload}`, + ); + } + } + // The contrast, so this test fails if the two ever get conflated again. + assert.equal( + candidateAction("missing-input", { force: false, allowRedownload: true }), + "dispatch", + ); +}); + +test("blocked is counted, and is NOT reachable work", () => { + const counts = emptyBackfillCounts(); + addBackfillState(counts, "blocked"); + addBackfillState(counts, "blocked"); + addBackfillState(counts, "missing"); + assert.equal(counts.blocked, 2); + assert.equal(counts.missing, 1); + // The load-bearing line. A corpus with one diarization and 73,000 waiting + // attributions must not report 73,000 jobs ready to run. + assert.equal(reachableBackfillWork(counts), 1); + // And it is its own number, not folded into the re-acquire population. + assert.equal(counts.missingInput, 0); + assert.equal(counts.deferred, 0); +}); + +test("a prerequisite is ordered before the kind that declares it", () => { + const base = settingsWithDiarization(); + const kinds = resolveBackfillKinds( + { + ...base, + attribution: { + ...base.attribution, + enabled: true, + diarizedEnabled: true, + textOnlyEnabled: true, + }, + }, + undefined, + ); + const ids = kinds.map((k) => k.id); + const diarizationAt = ids.indexOf("diarization"); + const diarizedAttrAt = ids.indexOf("attribution-diarized"); + assert.ok(diarizationAt >= 0 && diarizedAttrAt >= 0, ids.join(",")); + // Without this, a video diarized during a pass only becomes attributable on + // whatever LATER pass happens to find the sidecar on disk. + assert.ok( + diarizationAt < diarizedAttrAt, + `diarization must precede attribution-diarized, got ${ids.join(", ")}`, + ); + // AND the sort is STABLE: attribution-text stays last, which is a separate + // deliberate decision (the better lane must reach a diarized video first, or + // the text lane spends ~30 model calls to produce a record it must not write). + assert.equal(ids[ids.length - 1], "attribution-text", ids.join(",")); +}); + +test("ordering degrades safely when a prerequisite is absent or cyclic", () => { + const a = { id: "a", dependsOn: ["b"] } as unknown as BackfillKind; + const b = { id: "b", dependsOn: ["a"] } as unknown as BackfillKind; + // A cycle must not wedge the lane or silently drop a kind: every entry comes + // back, in declaration order. + assert.deepEqual( + orderByDependencies([a, b]).map((k) => k.id), + ["a", "b"], + ); + // A dependency on something not in the selection imposes no ordering, and + // does not remove the dependant from the run. + const lonely = { id: "lonely", dependsOn: ["not-here"] } as unknown as + BackfillKind; + assert.deepEqual( + orderByDependencies([lonely]).map((k) => k.id), + ["lonely"], ); }); @@ -751,6 +845,7 @@ test("counts keep reachable work and needs-re-acquiring apart", () => { stale: 1, missingInput: 3, deferred: 0, + blocked: 0, }); // 3, not 6. Measured on the real corpus the difference is 835 vs 77,105, and // reporting the larger number is what would make every surface useless. @@ -772,6 +867,7 @@ test("deferred is counted, and is NOT reachable work", async () => { stale: 1, missingInput: 0, deferred: 2, + blocked: 0, }); // 2, not 4. This is the assertion that keeps a capped corpus from ever reading // as finished, and the one that fails if someone "tidies up" by folding diff --git a/common/lib/backfillKinds.ts b/common/lib/backfillKinds.ts @@ -104,16 +104,31 @@ import type { AttributeOneOutcome } from "../controller/attributeOne"; // attempt it under the current configuration. Today that is // only the diarization duration cap. NEVER summed into // reachable work, so a capped corpus cannot read as finished. +// blocked — does not have it, and what it is waiting for is the OUTPUT OF +// ANOTHER KIND IN THIS TABLE. Not the same thing as +// missing-input, and conflating them was a real defect: see +// below. // -// `deferred` follows the house rule BackfillRunOutcome = "skipped" already sets -// on the run side: a deliberate non-action gets its own counter, and is never -// folded into the work total nor reported as a failure. +// `deferred` and `blocked` follow the house rule BackfillRunOutcome = "skipped" +// already sets on the run side: a deliberate non-action gets its own counter, +// and is never folded into the work total nor reported as a failure. +// +// WHY `blocked` IS NOT `missing-input`. `missing-input` means one specific +// thing to the rest of the system: THE MEDIA IS GONE, RE-ACQUIRE IT. It is the +// population `allowRedownload` exists for, and backfillReacquire answers it by +// fetching AUDIO. `attribution-diarized` waits on diarization.json — so +// reporting that as missing-input told the operator ~73,000 videos needed media +// re-fetched, and with re-download on, the lane would spend a download per video +// fetching audio that CANNOT satisfy the wait, then cleaning it up again. The +// prerequisite is not gone; it has not been produced yet, and this same table +// knows how to produce it. export type BackfillState = | "present" | "stale" | "missing" | "missing-input" - | "deferred"; + | "deferred" + | "blocked"; // Videos this backfill has no opinion about (not transcribed, marked // untranscribable). Kept out of BackfillState so it can never be counted. @@ -177,6 +192,21 @@ export type BackfillKind = { // One line of UI copy: what this backfill is, in the operator's terms. hint: string; tier: BackfillCostTier; + // Ids of other kinds in this table whose output this one consumes. + // + // PURELY DECLARATIVE. It does not gate anything by itself — a kind still + // decides for itself, from disk, whether its input is there, and says so by + // returning `blocked`. What declaring it buys is two things nothing else + // could: resolveBackfillKinds can order a prerequisite before its dependant + // within a single pass (so a video diarized this pass can be attributed in + // the same one, rather than waiting for whatever LATER pass happens to find + // the sidecar on disk), and a surface can say what a blocked video is waiting + // FOR rather than just that it is stuck. + // + // An id naming a kind that is absent or disabled is not an error: the + // dependency simply imposes no ordering, and the dependant keeps reporting + // `blocked` until something produces its input. + dependsOn?: readonly string[]; // The feature's OWN gate. A disabled feature reports no backfill at all — // otherwise every surface would advertise catch-up work for something the // operator has switched off. @@ -401,6 +431,10 @@ 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.", tier: "lane", + // The dependency the hint has always stated in prose. Declaring it is what + // turns "needs diarization to have run first" from a sentence an operator + // reads into something the scheduler can order by and a counter can name. + dependsOn: ["diarization"], enabled: (settings) => settings.attribution.enabled && settings.attribution.diarizedEnabled, resolveTarget: (settings) => attributionTargetFor(settings, "diarized"), @@ -414,7 +448,14 @@ const attributionDiarized: BackfillKind = { // captured during a past run is a perfectly good input after capture is // switched off again, and refusing to name it would strand exactly the work // the capture lane exists to protect. - if (!files.hasDiarization) return "missing-input"; + // + // BLOCKED, NOT MISSING-INPUT. This used to say missing-input, and that was + // wrong in a way that cost real work: it put ~73,000 videos into the + // "re-acquire the media" population, where allowRedownload would fetch + // AUDIO — which can never satisfy a wait for diarization.json — and then + // delete it again. What this video is waiting for is the `diarization` kind + // declared in dependsOn above, and that is a thing this table produces. + if (!files.hasDiarization) return "blocked"; if (!files.entries.includes(ATTRIBUTION_FILENAME)) return "missing"; const record = await loadAttribution(videoDir); if (!record) return "missing"; @@ -428,7 +469,11 @@ const attributionDiarized: BackfillKind = { // Only now is the diarization read worth paying for: it is needed solely to // ask whether the clusters these names point at are still the same clusters. const diarization = await loadDiarization(videoDir); - if (!diarization) return "missing-input"; + // Present in the listing but unreadable — a half-written or corrupt + // sidecar. Blocked for the same reason as the branch above, and note that + // the `diarization` kind reads a malformed record as ABSENT too, so it will + // regenerate this file and unblock the video without anyone intervening. + if (!diarization) return "blocked"; return isAttributionFresh(record, { ...(target as AttributionFreshnessTarget), diarizationGeneratedAt: diarization.generatedAt, @@ -517,9 +562,71 @@ export function resolveBackfillKinds( ids: readonly string[] | undefined, ): BackfillKind[] { const lane = laneBackfillKinds(settings); - if (!ids || ids.length === 0) return lane; - const wanted = new Set(ids); - return lane.filter((k) => wanted.has(k.id)); + const selected = + !ids || ids.length === 0 + ? lane + : lane.filter((k) => new Set(ids).has(k.id)); + return orderByDependencies(selected); +} + +// Order kinds so a prerequisite is attempted before anything that declares it. +// +// WHY THIS IS WORTH DOING AT ALL. backfillBatch walks the kinds in the order it +// is given, all the way through the video list, before starting the next kind. +// So with `attribution-diarized` ahead of `diarization`, a video diarized +// during a pass becomes eligible for attribution only on whatever LATER pass +// happens to find the sidecar on disk. Ordering by the declaration collapses +// that into one pass, and costs a topological sort over three entries. +// +// STABLE, and that is load-bearing rather than tidiness. BACKFILL_KINDS puts +// attribution-text LAST on purpose (see the comment there): on a video that has +// diarization, the better lane must get there first so the text lane finds a +// record it must not overwrite — one wasted classification instead of ~30 model +// calls. Kahn's algorithm with a queue seeded and drained in declaration order +// preserves every ordering the declarations do not contradict, so that decision +// survives. +export function orderByDependencies(kinds: BackfillKind[]): BackfillKind[] { + const byId = new Map(kinds.map((k) => [k.id, k])); + // Only dependencies that are actually IN this selection constrain anything. A + // kind that names a disabled or unselected prerequisite is not held back — + // it will report `blocked` per video, which is the honest answer, rather than + // being silently dropped from the run. + const remaining = new Map( + kinds.map((k) => [ + k.id, + (k.dependsOn ?? []).filter((d) => byId.has(d) && d !== k.id).length, + ]), + ); + const dependants = new Map<string, string[]>(); + for (const k of kinds) { + for (const d of k.dependsOn ?? []) { + if (!byId.has(d) || d === k.id) continue; + const list = dependants.get(d); + if (list) list.push(k.id); + else dependants.set(d, [k.id]); + } + } + const out: BackfillKind[] = []; + const emitted = new Set<string>(); + // Repeatedly take the FIRST still-unemitted kind in declaration order whose + // prerequisites are all out. Quadratic in the number of kinds, which is three. + for (;;) { + const next = kinds.find( + (k) => !emitted.has(k.id) && (remaining.get(k.id) ?? 0) === 0, + ); + if (!next) break; + emitted.add(next.id); + out.push(next); + for (const id of dependants.get(next.id) ?? []) { + remaining.set(id, (remaining.get(id) ?? 1) - 1); + } + } + // A CYCLE leaves entries unemitted. Append them in declaration order rather + // than throwing or dropping them: a mis-declared dependency should degrade to + // the old behaviour (run in table order), never wedge the lane or silently + // stop a backfill from running at all. + for (const k of kinds) if (!emitted.has(k.id)) out.push(k); + return out; } // Per-kind counts, the shape every indicator reads. `missing` and `missingInput` @@ -533,10 +640,18 @@ export type BackfillCounts = { // never summed. Snapshots written before this field existed do not carry it, // so every read site needs `?? 0` — `.toLocaleString()` on undefined throws. deferred: number; + // Waiting on a prerequisite kind's output. A FOURTH number, and the same rule + // applies: never summed with the others, and `?? 0` at every read site, + // because every snapshot currently on disk predates it. + // + // This number should FALL on its own as the prerequisite lane works, which is + // the whole difference from missingInput — that one only falls if an operator + // turns re-download on. + blocked: number; }; export function emptyBackfillCounts(): BackfillCounts { - return { missing: 0, stale: 0, missingInput: 0, deferred: 0 }; + return { missing: 0, stale: 0, missingInput: 0, deferred: 0, blocked: 0 }; } // Fold one classification into a counts record. Central so no surface invents @@ -550,16 +665,23 @@ export function addBackfillState( else if (state === "stale") counts.stale++; else if (state === "missing-input") counts.missingInput++; else if (state === "deferred") counts.deferred++; + else if (state === "blocked") counts.blocked++; } // What the lane can act on WITHOUT re-acquiring media. The number every "how // much is left?" surface should lead with. // -// DELIBERATELY UNCHANGED by the addition of `deferred`. This function is the -// guard: adding a fourth state to the union raises no TypeScript error anywhere -// (there is no exhaustiveness check over BackfillState in this repo), so the only -// thing keeping capped videos out of the work total is that they are not added -// here. If a future state belongs in the total, it goes in on purpose. +// DELIBERATELY UNCHANGED by the addition of `deferred`, and unchanged again by +// `blocked`. This function is the guard: adding a state to the union raises no +// TypeScript error here (the exhaustiveness check lives on the DISPATCH +// decision, in backfillBatch's candidateAction, which is the branch that can do +// harm), so the only thing keeping capped and blocked videos out of the work +// total is that they are not added here. If a future state belongs in the +// total, it goes in on purpose. +// +// A blocked video is emphatically not reachable work: there is nothing this +// lane can do about it this pass. Counting it would make a corpus with one +// diarization and 73,000 waiting attributions report 73,000 jobs ready to run. export function reachableBackfillWork(counts: BackfillCounts): number { return counts.missing + counts.stale; } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- **"Waiting for an earlier step" is no longer reported as "the media is gone".** Putting names to speakers from the audio needs the speaker-turn capture to have run first. When it hadn't, those videos were reported as *needing their media re-acquired* — which on this corpus is around **73,000 videos**, filed under the one heading that means "fetch the audio again". That was wrong twice over: it was the wrong number in front of the operator, and with re-downloading switched on the system would have spent one download per video fetching audio that **cannot** satisfy the wait, then deleted it again. There is now a distinct **blocked** state for work waiting on an earlier step, counted on its own, never added to the work-to-do total, never handed to a runner, and — crucially — completely unaffected by the re-download setting. The channel card names what each blocked video is waiting for and says plainly that there is nothing to do, because unlike the other numbers **this one falls by itself** as the earlier step runs. +- **Backfills now declare what they depend on, and run in that order.** A video whose speaker turns were captured during a pass used to become eligible for speaker naming only on some *later* pass that happened to notice the new file on disk. Prerequisites are now declared and ordered, so both happen in the same pass. A mis-declared or circular dependency degrades to the old ordering rather than stalling anything. - **The automatic downloader now stops when the disk is nearly full — it never did before.** Every download you start by clicking something has checked free space for a long time. The one thing that runs unattended, for days, choosing downloads by itself, did not check at all: there was no mention of disk anywhere in it. That is the process most likely to fill a disk and the least likely to have anyone watching while it does. It now consults the same floor as everything else and simply **goes idle** rather than stopping, so it picks up again on its own once space is free — no restart, nothing to remember. Four more paths that write large files were checked and gated the same way: **re-downloading a single video** (its near-identical sibling, "re-download to archive", already checked — this one had been missed), **persisting kept videos**, which now checks *before each video* instead of once at the start, since it writes full video containers in a loop and the twentieth should not be relying on the first one's headroom, **re-downloading truncated audio**, checked before the old file is deleted rather than after, and the **backfill batch**, whose "disk floor reached" flag was being set and then ignored while it kept asking for more work. Derived files — digests, speaker diarization, attribution — are deliberately **not** gated: they are kilobytes, holding them back frees nothing, and it would throw away days of a multi-week run for no gain. - **Resuming now needs a little more free space than stopping did, so the pipeline can't flap.** If work restarted at exactly the number that stopped it, the first resumed download would push free space back under the floor, stop again, and oscillate — logging a stop each time, which is precisely when the log needs to stay readable. There is a new **Resume margin** setting (default **2 GB**) that a stopped pipeline has to clear before it starts writing again, so "resumed" means you actually freed something rather than a temporary file being tidied away. Set it to 0 for the old behaviour. - **A disk stop now says it is a disk stop.** The dashboard had one red "downloads paused" marker, and it could only ever mean the manual pause button — during a real disk stop it said nothing at all, and if both were true you would un-pause and watch nothing happen. Manual pause and disk stop are now **two separate readouts** with their own wording, and the disk one shows how much is free, what the floor is, and — when it is holding for the resume margin — the number it is waiting for. A live free-space readout is now always visible whenever the floor is switched on, not only once something has gone wrong. diff --git a/editor/app/channels/[slug]/backfillActions.ts b/editor/app/channels/[slug]/backfillActions.ts @@ -55,6 +55,9 @@ export async function backfillChannelAction( (batch.deferred > 0 ? `; ${batch.deferred} deferred (over the diarization length limit)` : "") + + (batch.blocked > 0 + ? `; ${batch.blocked} waiting on a prerequisite backfill` + : "") + (batch.reacquired > 0 ? `; ${batch.reacquired} re-acquired, ${batch.reacquireCleaned} cleaned up` : "") + diff --git a/editor/app/channels/[slug]/components/stages/BackfillStage.tsx b/editor/app/channels/[slug]/components/stages/BackfillStage.tsx @@ -31,6 +31,13 @@ export type BackfillKindView = { // diarization duration cap). A THIRD number, never added to the other two: // summing it would let a capped corpus report as finished. deferred: number; + // Videos waiting on another kind's output. A FOURTH number, never added to + // the others either — but unlike the three above it needs nothing from the + // operator, because the prerequisite lane brings it down on its own. + blocked: number; + // Labels of the kinds this one waits on, resolved from the registry's + // dependsOn on the server so the copy can name them. + dependsOnLabels: string[]; stale: number; }; @@ -60,6 +67,15 @@ export function BackfillStage({ const reachable = kinds.reduce((n, k) => n + k.reachableIds.length, 0); const missingInput = kinds.reduce((n, k) => n + k.missingInput, 0); const deferred = kinds.reduce((n, k) => n + k.deferred, 0); + const blocked = kinds.reduce((n, k) => n + k.blocked, 0); + // What the blocked videos are waiting for, named. Only kinds that actually + // have blocked videos contribute, so the sentence never lists a prerequisite + // that is not holding anything up. + const blockedOn = [ + ...new Set( + kinds.filter((k) => k.blocked > 0).flatMap((k) => k.dependsOnLabels), + ), + ]; const allReachableIds = [ ...new Set(kinds.flatMap((k) => k.reachableIds)), ].sort(); @@ -100,6 +116,21 @@ export function BackfillStage({ include {deferred === 1 ? "it" : "them"}. </p> )} + {blocked > 0 && ( + <p + aria-label="backfill blocked" + className="mt-1 text-sm text-muted-foreground" + > + {blocked.toLocaleString()}{" "} + {blocked === 1 ? "video is" : "videos are"} waiting on{" "} + {blockedOn.length > 0 + ? blockedOn.join(" and ") + : "an earlier backfill"}{" "} + and will become available as{" "} + {blockedOn.length === 1 ? "it runs" : "those run"} — nothing to do + here. + </p> + )} </div> {kinds.length > 1 && @@ -114,6 +145,7 @@ export function BackfillStage({ {k.stale > 0 && ` (${k.stale} stale)`} ·{" "} {k.missingInput.toLocaleString()} needing media {k.deferred > 0 && ` · ${k.deferred.toLocaleString()} deferred`} + {k.blocked > 0 && ` · ${k.blocked.toLocaleString()} blocked`} </p> ))} diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts @@ -397,6 +397,12 @@ export function computeStageStatuses( (n, e) => n + (e.deferred ?? 0), 0, ); + // Same treatment again, and the wording matters: this number falls on its own + // as the prerequisite lane runs, so it must not read as something to fix. + const backfillBlocked = backfillEntries.reduce( + (n, e) => n + (e.blocked ?? 0), + 0, + ); const backfillParts: string[] = []; if (backfillPending > 0) { backfillParts.push( @@ -417,6 +423,11 @@ export function computeStageStatuses( `${backfillDeferred.toLocaleString()} deferred (too long to diarize)`, ); } + if (backfillBlocked > 0) { + backfillParts.push( + `${backfillBlocked.toLocaleString()} waiting on an earlier backfill`, + ); + } const backfill: StageStatus = { id: "backfill", title: "Backfill", diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -76,7 +76,10 @@ import { TranscodeStage } from "./components/stages/TranscodeStage"; import { TranscribeStage } from "./components/stages/TranscribeStage"; import { DigestStage } from "./components/stages/DigestStage"; import { BackfillStage } from "./components/stages/BackfillStage"; -import { laneBackfillKinds } from "yt-dlp-transcript-common/lib/backfillKinds"; +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"; @@ -429,6 +432,13 @@ export default async function ChannelDetailPage({ // cap existed have no `deferred` field, and .toLocaleString() on // undefined throws in the render path. deferred: entry?.deferred ?? 0, + // 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, }; })} diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts @@ -306,14 +306,25 @@ test("the stage card and /actionable show attribution beside diarization, with t // component, unmodified, driven by a bigger registry. // // The diarized lane can reach ONE video (the one with diarization.json) and - // reports the other as needing its input re-acquired. That 1-vs-1 split is the + // reports the other as BLOCKED on diarization. That 1-vs-1 split is the // corpus's handful-vs-73,000 in miniature, and the card must never show "2". + // + // This used to assert "1 needing media", and that was the defect: the second + // video is not waiting for media, it is waiting for the diarization lane — + // and re-acquiring audio for it could never have helped. await expect( section.getByLabel("backfill kind attribution-diarized"), ).toContainText("1 reachable"); await expect( section.getByLabel("backfill kind attribution-diarized"), - ).toContainText("1 needing media"); + ).toContainText("0 needing media"); + await expect( + section.getByLabel("backfill kind attribution-diarized"), + ).toContainText("1 blocked"); + // And the card names what it is waiting for, rather than just saying stuck. + await expect(section.getByLabel("backfill blocked")).toContainText( + "Speaker diarization", + ); // The text lane reaches BOTH: its input is the cue stream, which every // transcribed video has. That is exactly why running it corpus-wide is the // expensive option. @@ -328,16 +339,20 @@ test("the stage card and /actionable show attribution beside diarization, with t await expect(section.getByRole("heading")).toContainText( "Backfill derived data (3)", ); - await expect(section.getByLabel("backfill needs re-acquiring")).toContainText( - "1", - ); + // AND THE RE-ACQUIRE LINE IS GONE, which is the point of the whole change. + // Nothing in this fixture needs media fetched: the one video that cannot be + // attributed from audio is waiting for the diarization lane, and no download + // would have helped it. Before this, that video was reported here and would + // have cost a download-and-delete under allowRedownload. + await expect(section.getByLabel("backfill needs re-acquiring")).toHaveCount(0); + await expect(section.getByLabel("backfill blocked")).toContainText("1"); - // /actionable, which sums across kinds but keeps the two populations in - // separate columns. + // /actionable, which sums across kinds but keeps the populations in separate + // columns. await page.goto("/actionable"); const row = page.getByLabel(`backfill row ${CHANNEL}`); await expect(row).toBeVisible(); - // 3 reachable and 1 needing media, side by side and never added. + // 3 reachable, and still never added to anything else. await expect(row).toContainText("3"); await expect(page.getByLabel("backfill", { exact: true })).toContainText( "Needs media",