"use client"; import type { ReactNode } from "react"; import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs"; import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { useSectionFrame } from "../../widget/components/WidgetSection"; import { LaneCard, type LaneControl } from "./LaneCard"; import { deriveLaneState, formatCount } from "yt-dlp-transcript-common/views/laneState"; import { pauseLaneControl } from "./pauseControl"; import { armLaneAction, disarmLaneAction } from "../../jobs/actions"; // The four lanes, and the ONLY place lane → server-action wiring lives. Rendered // identically by the dashboard band and the monitor widget's controls section, // which is what stops the two surfaces from growing different ideas about what // a lane can do (the widget could not arm or disarm a lane at all, and had no // digest control whatsoever). // // Every lane's gate reaches the wire as `held`, computed once by isGateHeld; no // reader inverts. // // WHAT DIFFERS BETWEEN THE LANES HERE IS WHERE THEIR WORK COMES FROM, not how // they are dispatched: all four have a runner. Transcription and downloads are // fed by arrivals, so their cards carry a pause and nothing else; digest and // backfill are catch-up over a corpus that already exists, so theirs also carry // the switch that arms the lane — which since slice 1.3 writes // `autoQueue[lane].enabled` and starts the runner, where it used to arm a sweep. // // IT PASSES NO SCOPE, and that is the important half. `armLaneAction` writes a // tree when it is given one and keeps the stored tree when it is not, so a click // here cannot discard rules somebody authored on the lane's console — which is // also what the button it replaces did (`startDigestSweepAction()` with no // argument inherited `sweepChannels` off disk). The label says "the lane" rather // than "every channel" for exactly that reason: on a narrowed lane, every // channel is not what it would do. export function LaneDeck({ workers, sync, jobs, actionable, onWorkersChange, onSynced, }: { workers: WorkersPayload | null; sync: WidgetSyncPayload | null; // The two payloads the DETAIL lines read. Both are already polled by every // surface that renders this deck — the dashboard cockpit unconditionally, the // widget behind its own section flags — so no new request exists because of // this. // // NULL DROPS THE LINE, never renders a zero. Same rule as formatCount's // "—, never 0": in the widget these are null whenever their flag is off, and // "0 videos awaiting transcription" on a corpus with 12,486 of them is a // worse answer than saying nothing. jobs: ActiveJobsPayload | null; actionable: WidgetActionablePayload | null; onWorkersChange: () => void | Promise; onSynced: () => void | Promise; }) { const { dense } = useSectionFrame(); const paused = workers?.paused ?? false; const downloadsPaused = workers?.downloadsPaused ?? false; const workerList = workers?.workers ?? []; const busy = workerList.filter((w) => w.busy).length; const digest = sync?.digest ?? null; const backfill = sync?.backfill ?? null; const disk = jobs?.disk ?? null; const diskLow = disk?.low ?? false; // The two backlogs, summed over the channels that HAVE one — the widget // payload already filters to those, so the channel count is the number of channels // carrying that particular bucket rather than the array length (a channel with // downloads outstanding and nothing to transcribe is in the array, and must // not be counted in the transcription sentence). const untranscribed = sumBacklog(actionable, (c) => c.untranscribed); const undownloaded = sumBacklog(actionable, (c) => c.undownloaded); // THE DENOMINATOR IS ELIGIBLE VIDEOS, NOT EVERY VIDEO DIRECTORY. `videos` // counts every directory in the corpus, ~1,700 of which have no transcript or // are marked untranscribable and so can never carry a digest — against that, // this figure could not reach 100% however long the lane ran. `eligible` // comes from the digest operation's own work list (transcribed, not // untranscribable, not waiting on transcription), so it is the same population // the lane actually walks. // // It falls back to `videos` while null, which is the state until every // channel's snapshot has been regenerated — see the sync payload. const digestDenominator = digest === null ? 0 : (digest.eligible ?? digest.videos); // Deliberately not rounded up. At 0.13% a "1%" would be a lie of the kind that // makes an 80-day backfill look nearly begun. const digestPct = digest && digestDenominator > 0 ? (digest.digested / digestDenominator) * 100 : null; // ── Transcription ───────────────────────────────────────────────────────── // Global pause/resume for transcription workers. Pausing stops handing out new // work AND gracefully stops in-flight parakeet jobs after the current segment // (cached for resume); other engines run their in-flight file to completion. const transcription = ( {workers && ( {busy}/{workerList.length} workers busy )} } // `held` is the LIVE pool, off the workers payload — never the persisted // `autoQueue.transcription.held`, which only says what a restart would do. controls={[ pauseLaneControl({ lane: "transcription", held: paused, onChange: onWorkersChange, }), ]} /> ); // ── Downloads ───────────────────────────────────────────────────────────── // Persisted in settings.json (`autoQueue.download.held` — survives a // restart; the payload field below keeps the older name). Pausing // gates the auto-download runner on its next loop iteration and makes manual // download-bearing pipeline actions return a "Downloads are paused" notice; // store-playlist/enumeration stay allowed. // // THE GATE IS TWO SWITCHES, NOT ONE. `disk.low` stops downloads exactly as the // manual pause does — the auto-download runner idles and download-bearing // actions are refused — so a lane state derived from `downloadsPaused` alone // read "Idle" while nothing could move. The detail line then names WHICH one is // shut, because un-pausing a disk stop does nothing and an operator has to be // able to tell them apart. const downloads = ( {(downloadsPaused || disk?.enabled) && ( {/* Carried here VERBATIM from the pipeline band, aria-label and condition unchanged: disk-space.spec exact-matches both labels and asserts each disappears when its condition is false. It does not care where on the page they are. */} {downloadsPaused && ( paused manually )} {disk?.enabled && (diskLow ? ( stopped · disk {formatBytes(disk.freeBytes)} free, floor{" "} {formatBytes(disk.thresholdBytes)} {disk.reason === "below-resume-margin" && ( {" "} · resumes at {formatBytes(disk.resumeBytes)} )} ) : ( disk: {formatBytes(disk.freeBytes)} free, floor{" "} {formatBytes(disk.thresholdBytes)} ))} )} } controls={[ pauseLaneControl({ lane: "download", held: downloadsPaused, onChange: onWorkersChange, }), ]} /> ); // ── Digest ──────────────────────────────────────────────────────────────── // TWO CONTROLS, AND THEY ARE NOT THE SAME CONTROL — conflating them is how an // operator loses a week of GPU time: // // Sweep on/off — is there a corpus-wide backfill at all. Persisted, so a // server restart resumes it (editor/instrumentation.ts). // Pause/resume — hold a running lane at zero throughput without ending it. // The batch's limit() returns 0, which makes the pool idle-WAIT rather // than finish, so resuming costs nothing and re-derives nothing. // // Stopping the lane drains rather than cancels: the video in flight finishes // instead of being thrown away part-generated. const digestArmed = digest?.armed ?? false; const digestPaused = digest?.held ?? false; const digestControls: LaneControl[] = [ digestArmed ? { key: "digest-arm", label: "Stop the lane", glyph: "■", ariaLabel: "disarm digest lane", title: "Switch the digest lane off and let its runner finish what it is holding. The video in flight completes; nothing already generated is lost, and the lane's rules are left alone.", variant: "active", action: () => disarmLaneAction("digest"), onChange: onSynced, } : { key: "digest-arm", label: "Run the lane", glyph: "⟳", ariaLabel: "arm digest lane", title: "Switch the digest lane on and start its runner, on whatever its rules claim — every channel unless somebody has narrowed them. Survives a restart. The rules are on the digest operation page.", variant: "idle", action: () => armLaneAction("digest"), onChange: onSynced, }, ]; // The pause only appears once there is something to hold — or once it is // already holding, which is the state you have to be able to get out of. if (digestArmed || digestPaused) { digestControls.push( pauseLaneControl({ lane: "digest", held: digestPaused, onChange: onSynced, }), ); } const digestLane = ( {formatCount(digest.digested)} digested {digestPct !== null && ( <> {" · "} {digestPct < 1 ? digestPct.toFixed(2) : digestPct.toFixed(1)}% of{" "} {digestDenominator.toLocaleString()} )} ) } detail={ digest === null ? undefined : ( <> {/* The coverage percentage stays on the FIGURE rather than being restated here — a card that says a thing twice is the accessory to remove. What the detail adds is the reach (a lane can be a third of the way through the corpus and have touched every channel, or the reverse) and the two reasons a video is not in the numerator at all. */} {digest.channelsWithAny.toLocaleString()} of{" "} {digest.channels.toLocaleString()}{" "} {digest.channels === 1 ? "channel" : "channels"} reached {(digest.blocked > 0 || digest.deferred > 0) && ( {/* NEVER summed — with each other or with `digested`. One is waiting on another lane, the other needs Normalize transcripts run by hand. */} {digest.blocked > 0 && ( <> {digest.blocked.toLocaleString()} waiting on a transcript )} {digest.blocked > 0 && digest.deferred > 0 && " · "} {digest.deferred > 0 && ( <>{digest.deferred.toLocaleString()} deferred )} )} ) } controls={digestControls} /> ); // ── Backfill ────────────────────────────────────────────────────────────── // Arm / disarm the backfill lane, and hold it without switching it off. // // TWO CONTROLS, AND THIS REVISED AN EARLIER DECISION. The lane used to carry // only the arm switch, on the reasoning that it stands aside whenever // transcription works anyway — so a manual pause looked redundant. // // That covered the wrong hazard, and the yield is narrower than it looked: it // applies to a run that would CONTEND for the GPU (the operation's declared // `contendsFor`, since slice 1.3), so a network-bound attribution run does not // stand aside at all. Even where it does, standing aside handles "get out of // the transcription lane's way"; it does nothing for "this is a desktop // someone is sitting at, and diarization pins four cores for hours." Backfill // work is CPU-bound and long — a diarization pass over the corpus runs for days — so // the operator needs a hold for reasons the scheduler cannot see. // // The pause writes THE SAME FIELD the Settings checkbox writes // (`autoQueue.backfill.held`, through withGateHeld — it was the inverted // `settings.backfill.enabled` until slice 1.4 and S0-pause deleted that // field) rather than a new `backfillPaused` flag. One // field, several places to set it, and they cannot drift — which is why // backfill.spec asserts the settings field through this button's label rather // than just watching the label flip. // // Stopping the LANE, by contrast, drains rather than cancels: the video in // flight finishes instead of being thrown away. Pause when you want it back; // stop when you don't. // // THE TWO USE DIFFERENT VERBS, which is the cheapest fix for the hazard this // comment has been describing in prose. "Run the lane" and "Pause Backfill" // would otherwise be the same shape of phrase for two acts whose costs to undo // differ by a week of GPU time. The switch RUNS; the gate HOLDS. The // aria-labels are untouched — they are internal addressing that confuses // nobody, and the e2e suite finds these buttons by them. const backfillArmed = backfill?.armed ?? false; const backfillHeld = backfill?.held ?? false; const backfillAvailable = backfill?.anyKind ?? false; // Empty when the payload has not arrived, or when it predates `kinds` — which // drops the breakdown rather than rendering a row of zeros. const laneOperations = backfillAvailable ? (backfill?.kinds ?? []) : []; // The lane's name in the operator's terms, from the server. "Backfill" is a // queue key; it survives below only on the two controls that genuinely act on // the shared queue, and the card now says what is in it. const backfillGroup = backfill?.groupLabel ?? "Derived data"; const backfillControls: LaneControl[] = backfillAvailable ? [ backfillArmed ? { key: "backfill-arm", label: "Stop the lane", glyph: "■", ariaLabel: "disarm backfill lane", title: "Switch the backfill lane off and let its runner finish what it is holding. The video in flight completes; nothing already written is lost, and the lane's rules are left alone.", variant: "active", action: () => disarmLaneAction("backfill"), onChange: onSynced, } : { key: "backfill-arm", label: "Run the lane", glyph: "⟳", ariaLabel: "arm backfill lane", title: "Switch the backfill lane on and start its runner, on whatever its rules claim — every channel and every operation unless somebody has narrowed them. Survives a restart. The rules are on any of its operation pages.", variant: "idle", action: () => armLaneAction("backfill"), onChange: onSynced, }, pauseLaneControl({ lane: "backfill", held: backfillHeld, onChange: onSynced, }), ] : []; const backfillLane = ( {formatCount(backfill.reachable)} reachable {/* A SEPARATE FIGURE, never summed into the one beside it: on the measured corpus these are 835 and ~76,270, and one total would report the work as untouched forever. */} {backfill.needsMedia > 0 && ( <> · {formatCount(backfill.needsMedia)} need media )} ) } // THE PER-KIND BREAKDOWN, and it REPLACES both detail lines rather than // joining them: this lane's figure is the one that cannot survive being // summed, so the detail's whole job is to take it apart. // // Only when there is more than one kind — the rule SpeakersStage already // applies per channel. A single-kind corpus would otherwise be shown a // breakdown of itself, restating the figure one line lower. detail={ laneOperations.length > 1 ? ( <> {laneOperations.map((k) => ( {k.label}{" "} {/* WHAT AN ARMED OPERATION COSTS. The sentence that was missing: this lane can be armed on ~194,000 model calls and look exactly like one armed on 329 audio passes, because "11,337 reachable" is the same shape of number either way. Stated flat, beside the backlog, with no threshold — a "this is a lot" cutoff would be a magic number the next operation gets wrong. */} {k.costBasis && ( {" "} — {k.costBasis} )} ))} ) : undefined } // TWO NOTES, NOT ONE SENTENCE. "Here is what this lane holds" and "the // lane is armed but held" are different conditions, and // folding them together loses the one that is a problem. note={ <> {backfillAvailable && laneOperations.length > 1 && ( // THE ONE PLACE THE LANE IS STILL NAMED AS A LANE, and it earns it: // that these operations share a queue and a pause is a true, // load-bearing fact — the arm switch and the pause are two controls, and // conflating them is how an operator loses a week of GPU time. So // it is said once, here, where both controls are, and the members // are LISTED rather than hidden behind the queue key. Everywhere an // operator reads a figure, they read an operation name instead. {laneOperations.length} operations share one backfill queue and one pause: {laneOperations.map((k) => k.label).join(", ")}. )} {backfillArmed && backfillHeld && ( // Kept in words as well as in the rail. An armed lane behind a shut // gate holds at a zero limit rather than doing work, and "wedged" is // what that looks like to anyone who does not already know. the lane is holding )} } controls={backfillControls} /> ); return (
{transcription} {downloads} {digestLane} {backfillLane}
); } // One backlog: how many videos, across how many channels that actually have any. // Null when the payload is absent, which is what drops the line. type Backlog = { videos: number; channels: number } | null; function sumBacklog( actionable: WidgetActionablePayload | null, pick: (c: WidgetActionablePayload["channels"][number]) => number, ): Backlog { if (!actionable) return null; let videos = 0; let channels = 0; for (const c of actionable.channels) { const n = pick(c); if (n <= 0) continue; videos += n; channels++; } return { videos, channels }; } // "12,486 videos awaiting transcription across 31 channels". // // A backlog of zero is a real, useful answer here — unlike formatCount's dash, // which stands for a measurement nobody took. The distinction is that the // widget payload reported and found nothing, versus not having reported at all. // // `empty` is its own phrase rather than "nothing " + verb, because the verbs are // not all positive: the downloads lane's is "not downloaded", and the composed // form reads "nothing not downloaded". function BacklogLine({ backlog, verb, empty, }: { backlog: Backlog; verb: ReactNode; empty: string; }): ReactNode { if (!backlog) return null; if (backlog.videos === 0) return {empty}; return ( {backlog.videos.toLocaleString()}{" "} {backlog.videos === 1 ? "video" : "videos"} {verb} across{" "} {backlog.channels.toLocaleString()}{" "} {backlog.channels === 1 ? "channel" : "channels"} ); } // One kind's numbers: "329 · 77,134 need media". // // THESE ARE NEVER SUMMED — not with each other, and not across kinds. Reachable // diarization work is 329 audio passes; reachable attribution-text work is // ~77,539 videos at about one model call per transcript CHUNK; `needsMedia` is // gated behind an opt-in re-download and `blocked` behind another kind finishing. // Four different units and four different things an operator would have to do, so // a total of any two of them means nothing. This is the same rule // operations.ts states for `missing` vs `missing-input`, one level down. // // Each clause is omitted at 0, so a kind with nothing outstanding says so in // words rather than showing a row of zeros. function KindCounts({ reachable, needsMedia, blocked, deferred, }: { reachable: number; needsMedia: number; blocked: number; deferred: number; }): ReactNode { const clauses: ReactNode[] = []; // The bare number is the reachable one — what the lane can do today, which is // the figure this whole card leads with. if (reachable > 0) clauses.push(reachable.toLocaleString()); if (needsMedia > 0) { clauses.push(`${needsMedia.toLocaleString()} need media`); } if (blocked > 0) clauses.push(`${blocked.toLocaleString()} blocked`); if (deferred > 0) clauses.push(`${deferred.toLocaleString()} deferred`); if (clauses.length === 0) return <>nothing outstanding; return <>{clauses.join(" · ")}; }