"use server"; import { revalidatePath } from "next/cache"; import { getSettings, type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import { isGateHeld, withGateHeld, type PauseLane, } from "yt-dlp-transcript-common/lib/pauseGates"; import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; import { drainAutoRunner, startAutoRunner, stopAutoRunner, } from "yt-dlp-transcript-common/controller/autoRunner"; import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState"; import { type AutoQueueGroup, type AutoQueueOrder, } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; import { isDefaultChannelPriority } from "yt-dlp-transcript-common/lib/channelPriority"; import { prioritizeChannelDownloadPriorityAction } from "../channels/actions"; // EVERY OPERATIONS SURFACE, not just the board. The console is a board plus one // page per operation, and every action here changes something both of them // draw — the second argument makes the dynamic segment revalidate as a route // rather than as one literal path, which is the only way to reach // /operations/digest and /operations/diarization without naming them. // // The old single revalidatePath("/auto-queue") outlived its route: that path is // a redirect now, and revalidating it refreshes nothing. function revalidateOperations(): void { revalidatePath("/operations"); revalidatePath("/operations/[id]", "page"); } export type SaveResult = { ok: true } | { ok: false; error: string }; // Structural equality of two policy trees. Both sides of the comparison come // from the same place — the status payload serialized the stored root to the // client, the form round-tripped it through JSON and posted it back — so key // order is preserved and a stringify compare is exact. It is deliberately not // a deep "same meaning" test: the question is whether the operator CHANGED the // tree, and anything that is not the byte-identical round trip is a change. function sameTree(a: AutoQueueGroup, b: AutoQueueGroup): boolean { return JSON.stringify(a) === JSON.stringify(b); } // Persist one runner kind's policy and bring its runner up/down to match, // WITHOUT a server restart. writeSettings sanitizes the tree (sanitizeAutoQueue), // so a slightly-off client payload is coerced rather than trusted. Enabling // starts the runner immediately; disabling is picked up by the running loop on // its next iteration (getSettings reads from disk), so it stops on its own. export async function saveAutoQueueAction( kind: AutoQueueKind, input: { enabled: boolean; maxWorkers: number | null; // Opt in to the lowest-priority replace-auto-captions lane (default false). replaceAutoSubs: boolean; // Ordering WITHIN each rule (newest / oldest upload first, or listed order). order: AutoQueueOrder; root: AutoQueueGroup; }, ): Promise { const current = getSettings(); // ONE WRITER OF `root` WHILE A MODEL EXISTS, enforced here and not only in // the UI. S4 makes PolicyTreeEditor read-only for compiled groups, but a // read-only editor is a courtesy: this action is a server action, reachable // with any payload, and a tree written here would be a tree the runner does // not dispatch from (`laneDispatchRoot` compiles) and the next priority save // silently overwrites. So while the document says anything, the STORED root // is what gets persisted — the rest of the form (enabled, maxWorkers, order, // replaceAutoSubs) still saves normally, because those are not compiled. const compiled = !isDefaultChannelPriority(current.channelPriority); if (compiled && !sameTree(input.root, current.autoQueue[kind].root)) { return { ok: false, error: "This lane's rules are generated from the channel priorities. " + "Edit them on /channels — a tree saved here would be overwritten by " + "the next priority change and is not what the runner dispatches from.", }; } // NOTE: this object lists every persisted policy field EXPLICITLY, so a field // added to AutoQueuePolicy and forgotten here is silently dropped on every // save rather than failing loudly. `snoozeUntil` and `held` are deliberately // carried over from `current` instead of taken from the form: each is set by // its own action, and a policy save (e.g. reordering rules) must not cancel a // snooze or UNHOLD A HELD LANE — which, since the gate moved onto the policy // in slice 1.4, is exactly what "forgotten here" would now mean. `undefined` // is carried as undefined on purpose: that is what keeps a lane that has // never been written falling back to its retired field. const next: Partial = { autoQueue: { ...current.autoQueue, [kind]: { enabled: input.enabled, maxWorkers: input.maxWorkers, replaceAutoSubs: input.replaceAutoSubs === true, order: input.order, snoozeUntil: current.autoQueue[kind].snoozeUntil ?? null, held: current.autoQueue[kind].held, root: compiled ? current.autoQueue[kind].root : input.root, }, }, }; try { await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } if (input.enabled) { await startAutoRunner(kind); } revalidateOperations(); return { ok: true }; } // Explicit start/stop/drain for the lane header's Start, Stop and Drain buttons // (also reachable, behind the ops token, as /api/auto-queue/control and as // `pnpm ops lane`). Start is a no-op when the policy is disabled. export async function startAutoQueueAction( kind: AutoQueueKind, ): Promise { const jobId = await startAutoRunner(kind); if (jobId === null && !getSettings().autoQueue[kind].enabled) { return { ok: false, error: "Enable the policy before starting the runner." }; } revalidateOperations(); return { ok: true }; } export async function stopAutoQueueAction( kind: AutoQueueKind, ): Promise { stopAutoRunner(kind); revalidateOperations(); return { ok: true }; } // Drain: the unit in flight finishes, no next one starts, the runner ends. export async function drainAutoQueueAction( kind: AutoQueueKind, ): Promise { drainAutoRunner(kind); revalidateOperations(); return { ok: true }; } // Idle a runner until `untilMs` (epoch ms) without stopping it, or wake it now // with null. Deliberately NOT part of saveAutoQueueAction: snoozing is a // one-click operational act, and routing it through the policy form would mean a // pending tree edit had to be saved (or discarded) to snooze. The runner re-reads // settings every iteration, so this takes effect on the next tick — and because // it lives in settings.json rather than runner memory, it survives a restart. export async function snoozeAutoQueueAction( kind: AutoQueueKind, untilMs: number | null, ): Promise { const current = getSettings(); const next: Partial = { autoQueue: { ...current.autoQueue, [kind]: { ...current.autoQueue[kind], snoozeUntil: untilMs }, }, }; try { await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } revalidateOperations(); return { ok: true }; } // ONE PAIR OF ACTIONS FOR EVERY LANE'S GATE — transcription, download, digest // and the backfill lane. There used to be four pairs in two files, each with its // own result type, its own polarity and its own revalidation list; the surfaces // that drew a lane then had to know which pair belonged to which lane. // // KEYED BY LANE, NOT BY OPERATION, and that is the decision worth stating: the // gate is per lane, three speaker operations share one, and a per-operation page // is not a per-operation switch. `pauseLaneFor(operationId)` is how an operation // page finds the lane it is really holding. // // A HOLD, NEVER A STOP. Every gate is consulted at dispatch by a limit() that // returns 0, so the running job idle-waits, keeps its place, and resumes within // one poll with nothing re-derived. export type LanePauseResult = { ok: boolean; error?: string }; async function setLaneHeld( lane: PauseLane, held: boolean, ): Promise { // TRANSCRIPTION IS THE ONE LANE WITH A LIVE HOLD, and it goes first. The pool // is the machine; the stored `autoQueue.transcription.held` is only what // editor/instrumentation.ts re-applies at boot. Every UI surface reads the // pool, so flipping it first is what makes the button feel immediate. if (lane === "transcription") { if (held) getWorkerPool().pauseAll(); else getWorkerPool().resumeAll(); } try { const cur = getSettings(); if (isGateHeld(cur, lane) !== held) { const next = withGateHeld(cur, lane, held); // The publish lane's gate is its own block (release 18), not a policy. await saveSettings(lane === "publish" ? { publish: next.publish } : { autoQueue: next.autoQueue }); } } catch (e) { // REPORTED, not swallowed. The workers page's old best-effort persist // console.warn'd this and returned ok. For transcription the pool has // already flipped by the time we get here, so the label follows the pool and // the button shows the error inline — which says exactly the true thing: the // pause took effect, it will not survive a restart. return { ok: false, error: (e as Error).message }; } // NOT "/" — the dashboard reads its pauses from its own polls // (DashboardCockpit), so revalidating it changes nothing it displays and DOES // wipe the client router cache app-wide (see experimental.staleTimes). revalidatePath("/jobs"); revalidatePath("/workers"); revalidatePath("/operations/[id]", "page"); if (lane === "publish") { revalidatePath("/operations/publish"); revalidatePath("/sites"); } return { ok: true }; } export async function pauseLaneAction(lane: PauseLane): Promise { return setLaneHeld(lane, true); } export async function resumeLaneAction( lane: PauseLane, ): Promise { return setLaneHeld(lane, false); } // "Add to top of auto-queue": put this channel FIRST in the download lane, // enable the lane, and start the runner if it isn't up. // // IT IS A PRIORITY EDIT NOW, not a tree edit. It used to prepend a // `prioritize-` leaf straight into `autoQueue.download.root` — which, // once a priority model exists, writes a tree the runner does not dispatch // from: a second writer of `root`, and a click that silently does nothing (the // S0/S1 review, finding 2). `prioritizeChannelDownloadPriorityAction` says the // same gesture in the model's vocabulary — first rank, everything below it // shifted down, base tier normal — through the ONE writer, which compiles the // four roots in the same `writeSettings` call and enables the lane in it too. // // On a corpus that has never set a priority the writer seeds the document from // the legacy trees first, so the existing hand-made order is what the channel // is promoted to the top OF, rather than being replaced by an alphabetical one. // // The `enabled: true` side effect and the immediate start are unchanged: this // button has always meant "and go". export async function prioritizeChannelDownloadAction( slug: string, ): Promise { const result = await prioritizeChannelDownloadPriorityAction(slug); if (result?.error) return { ok: false, error: result.error }; await startAutoRunner("download"); revalidateOperations(); return { ok: true }; }