import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels"; import { PLATFORM_VALUES } from "yt-dlp-transcript-common/lib/platform"; import { selectableBucketsForKind } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes"; import { isGateHeld, pauseLaneFor, } from "yt-dlp-transcript-common/lib/pauseGates"; import { operationCatalog, operationLabel, type OperationSettingsBlock, } from "yt-dlp-transcript-common/lib/operations"; import { getSettings, type SiteSettings } from "yt-dlp-transcript-common/lib/settings"; import { listDigestApps, type DigestAppDescriptor, } from "yt-dlp-transcript-common/lib/digestApps"; import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState"; import { liveJobRows } from "../../jobs/active/buildActiveJobs"; import { buildAutoQueueStatusPayload } from "../status"; import { buildSyncRow } from "../syncRow"; import { buildSchedulerStatusPayload } from "../../scheduler/status"; import { SyncConsole } from "../components/sync/SyncConsole"; import { SchedulerSettingsForm } from "../components/sync/SchedulerSettingsForm"; import { resolveHeartbeatSeconds } from "../../scheduler/heartbeat"; import { OperationDetail } from "../components/OperationDetail"; import { DigestSettingsForm } from "../components/settings/DigestSettingsForm"; import { DiarizationSettingsForm } from "../components/settings/DiarizationSettingsForm"; import { AttributionSettingsForm } from "../components/settings/AttributionSettingsForm"; import { LaneSettingsForm } from "../components/settings/LaneSettingsForm"; import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; import { getFreeBytes } from "yt-dlp-transcript-common/lib/diskSpace"; import { widgetSyncInputs } from "../../widget/lib/syncInputs"; import { ChannelWorkTable } from "../../components/channelWork/ChannelWorkTable"; import { sectionsFor, type SectionConfig, } from "../../components/channelWork/sections"; import { getActionableSummary } from "../../lib/actionable/loadActionable"; export const dynamic = "force-dynamic"; // Buckets each lane can draw from — derived from the policy engine's single // source of truth (selectableBucketsForKind) so the picker can't drift from the // runner. Includes the opt-in auto-caption buckets: a leaf that names one gets // per-channel opt-in without flipping the runner-wide switch. // // EMPTY IS A REAL ENTRY. The digest and backfill lanes draw from operations, // never from buckets, so their lists are `[]` and LeafControls draws no bucket // select at all — which is the honest UI for a lane where a bucket name would // mean nothing. const BUCKETS_BY_KIND = Object.fromEntries( LANES.map((lane) => [lane, [...selectableBucketsForKind(lane)]]), ) as Record; // The MANUAL per-channel jobs on this operation's lane, for the running-jobs // list. The runner lists its own in-flight units, which are in-process and make // no job record; these are the jobs a channel page's Run button starts, and // without them a digest running on a channel is invisible on the digest page. // // Keyed by LANE rather than by operation, because that is the truth: one job on // the shared queue is doing whichever operations the run was scoped to. The two // bucket lanes have none — auto-transcribe and auto-download already list their // own units, and a second list beside them would be the pile this redesign is // undoing. // // The two SWEEP orchestrator kinds are gone from it; they retired with the // sweeps in slice 1.3. const JOB_KINDS_BY_LANE: Record = { transcription: [], download: [], digest: [ "digest-channel-local", "digest-channel-remote", "digest-share-cluster", ], backfill: ["backfill-channel", "diarize-channel"], }; function descriptorFor(id: string) { return operationCatalog().find((o) => o.id === id) ?? null; } // THE OPERATION'S OWN SETTINGS FORM, chosen by the block the descriptor // DECLARES — not by a table keyed by operation id. That is the same rule // `pauseLaneFor` follows, and it is what lets both attribution // operations render one form: they name one block, so they get one switch arm. // // Exhaustive on purpose (the union is closed): a settings block added to the // registry is a type error here rather than a page that quietly renders nothing. function settingsFormFor( block: OperationSettingsBlock | undefined, settings: SiteSettings, digestApps: DigestAppDescriptor[], ) { switch (block) { // KEYED, though none of these is in a list. An element created in a SERVER // component and handed to a client component to render lands in that // component's children array already deserialized, with React's dev-only // key validation still to run over it — and it warns, naming a render // method that did not create it. A key short-circuits that check. It costs // nothing and it is the difference between a clean console and a warning // that sends the next reader to the wrong file. case "digest": return ( ); case "diarization": return ( ); case "attribution": return ( ); case "syncScheduler": return ( ); case undefined: return null; } } export async function generateMetadata({ params, }: { params: Promise<{ id: string }>; }): Promise { const { id } = await params; const op = descriptorFor(id); return { title: op ? op.label : "Operation" }; } export default async function OperationPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; // THE CATALOG IS THE ROUTE TABLE. An operation is a registry entry, so an id // the registry does not know is a 404 and not an empty console — and an // operation ADDED to the registry gets this page with no route work at all. const op = descriptorFor(id); if (!op) notFound(); // THE OPERATION'S LANE, never its id, and since slice 1.2 never `op.runner` // either. `runner` is set only on the two EXTERNAL entries (download and // transcription) and is `undefined` for sync and for every registry // operation — so reading it would have left the digest and backfill lanes, // which now have runners, without a console. `pauseLaneFor` is the one rule // that already answers "which lane dispatches this": it asks `runner` first // (so an external operation sharing a queue with no runner of its own still // gets null) and falls back to the declared queue key. const runnerKind: AutoQueueKind | null = pauseLaneFor(op.id); // THE SCHEDULER PAYLOAD, READ ONCE PER RENDER. The rail's sync row is a fold // of it and the cadence console below is the whole of it, so the sync page // would otherwise list every channel config twice for one screen. Started // here and NOT awaited: the row and the console await the same promise, and // the three reads below still run in parallel. const schedulerPayload = op.trigger === "cadence" ? buildSchedulerStatusPayload() : null; const [initial, channels, sync] = await Promise.all([ buildAutoQueueStatusPayload(), listChannelConfigs(getPaths()), // Every other page wants only the row, and builds the payload inside // buildSyncRow — which is the whole cost there, and unchanged. schedulerPayload ? schedulerPayload.then((payload) => buildSyncRow(payload)) : buildSyncRow(), ]); const channelOptions = channels.map((c) => ({ slug: c.slug, name: c.config.name ?? null, })); // The two settings slots. Both are built HERE because they need server-only // reads (getSettings, and listDigestApps which reaches process.env and // imports execa) that a client component cannot do. const settings = getSettings(); const digestApps = op.settingsBlock === "digest" || op.settingsBlock === "attribution" ? listDigestApps() : []; const operationSettings = settingsFormFor( op.settingsBlock, settings, digestApps, ); // PER LANE, not per operation: `settings.backfill` governs BACKFILL_QUEUE, // which three operations share, so this same form is drawn on all three of // their pages beside the pause they also share. The digest lane has no // equivalent — its order lives on the lane policy the console already edits. // // THE RE-ACQUIRE FIGURES, only on a backfill page: the widget-sync payload // is a corpus-wide fold and not free, so no other operation builds it. Free // space is `getFreeBytes` on the transcripts root — the same statfs behind // the jobs payload's `disk.freeBytes` — and it fails open to Infinity, which // is "unknown" here, not "unlimited". // // ONE KIND, NOT THE LANE SUM. The toggle re-acquires AUDIO, and diarization is // the operation whose missing input is audio (attribution-diarized's missing // input is diarization.json, attribution-text's is the transcript — see the // catalog's note on `missing-input`). The lane-wide `reachable` folds all // three, so on this corpus it read "78,146 videos still have media on disk" // — attribution-text's transcript count, not media at all. Absent the kind // (diarization off), the form falls back to its dated sentence. let laneSettings = null; if (runnerKind === "backfill") { const [laneSync, free] = await Promise.all([ widgetSyncInputs().then(buildWidgetSyncPayload), getFreeBytes(getPaths().transcriptsDir), ]); const audioKind = laneSync.backfill.kinds.find( (k) => k.id === "diarization", ); laneSettings = ( ); } // THE CADENCE CONSOLE, off the descriptor's trigger. A cadence-triggered // operation has no backlog to sweep and no runner to start — its console is // its schedule. `runner` cannot say this (it is typed AutoQueueKind, and the // heartbeat is not one), so `trigger` does. ONE such operation exists; a // second would need the descriptor to name its console, not this file to // branch on an id. The same promise the rail row folded, awaited a second // time and therefore already settled — one scheduler read on this page, and // none at all on any other. const cadenceConsole = schedulerPayload ? ( ) : null; const laneJobKinds = runnerKind ? JOB_KINDS_BY_LANE[runnerKind] : []; // Through the one builder, so this list has the same progress bars /jobs does // (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds). const activeJobs = laneJobKinds.length === 0 ? [] : await liveJobRows((j) => laneJobKinds.includes(j.kind)); // THE CHANNELS WITH THIS OPERATION'S WORK. The cast is safe only because an // id the module does not know returns [] — diarization and both attribution // operations get no table at all, and the summary is not even loaded for // them. const sections = sectionsFor(op.id as SectionConfig["operation"]); const channelWork = sections.length > 0 ? ( // The census reads the request-cached `getChannelBriefs`. It is the // same listing `buildAutoQueueStatusPayload` read above only when that // call missed its memo (operations/status.ts); on a hit the payload's // counts may be up to 3 s older than this table. Keyed for the same reason // settingsFormFor's elements are — a server element handed to a client // component lands in its children array with React's dev-only key check // still to run over it. ) : null; return (

{op.label}

{op.costBasis}

{op.hint}

({ id: depId, label: operationLabel(depId), }))} operationSettings={operationSettings} laneSettings={laneSettings} channelWork={channelWork} />
); }