"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { type AutoQueueGroup, type AutoQueueLeaf, type AutoQueueNode, type AutoQueueOrder, isGroup, } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState"; import { saveAutoQueueAction, type SaveResult } from "../actions"; import type { AutoQueueKindStatus } from "yt-dlp-transcript-common/views/autoQueueStatus"; import { ClaimLadder } from "./ClaimLadder"; import type { RungOps } from "./LadderRung"; import { LaneOrder } from "./LaneOrder"; import { SaveBar } from "./SaveBar"; import { type Channel, LANE_ORDERS, NUM_CLASS, leafOrder, orderTradeoff, } from "./dispatch"; // Owns one runner's editable policy: the tree, the switches, and the save. The // DRAWING of the tree moved to ClaimLadder/LadderRung, which also render the // live counts — so what used to be a form sitting above a separate counts list // is now one object. // // THE TREE GOES READ-ONLY WHEN IT IS GENERATED, and only the tree. // `status.policyCompiled` is true whenever `settings.channelPriority` says // anything: the lane then dispatches from a tree compiled off that document per // tick, so a rule typed in here could not change what the lane does — the // compiler wins (controller/autoRunner.ts, S1). Rather than let the ladder // offer edits that are silently overruled, it renders as the statement of // dispatch order it is, with one line saying where the order is set. // // WHAT STAYS EDITABLE IS EVERYTHING THAT IS NOT THE TREE: the enable switch, the // worker cap, the order, and the auto-captions opt-in. None of them is derivable // from a channel priority, none of them is touched by the compiler (every writer // SPREADS the policy), and moving them to /channels would put lane settings on a // channels page. The Save button therefore still means something here; it just // writes back the same root it was given. // // It is NOT deleted for a compiled lane, either: the bucket and operation axes a // hand-authored leaf can express have no equivalent in the priority model, and a // corpus with no priorities set — which is every corpus until one is — edits its // trees here exactly as before. let idSeq = 0; function newId(): string { idSeq += 1; const rnd = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().slice(0, 8) : `${idSeq}`; return `n-${rnd}-${idSeq}`; } // --- Immutable tree ops (by node id) --------------------------------------- function mapNode( node: AutoQueueNode, id: string, fn: (n: AutoQueueNode) => AutoQueueNode, ): AutoQueueNode { if (node.id === id) return fn(node); if (!isGroup(node)) return node; return { ...node, children: node.children.map((c) => mapNode(c, id, fn)) }; } function removeFrom(node: AutoQueueGroup, id: string): AutoQueueGroup { return { ...node, children: node.children .filter((c) => c.id !== id) .map((c) => (isGroup(c) ? removeFrom(c, id) : c)), }; } function addChildTo( node: AutoQueueNode, parentId: string, child: AutoQueueNode, ): AutoQueueNode { if (!isGroup(node)) return node; if (node.id === parentId) { return { ...node, children: [...node.children, child] }; } return { ...node, children: node.children.map((c) => addChildTo(c, parentId, child)), }; } function moveChildIn( node: AutoQueueNode, parentId: string, index: number, dir: -1 | 1, ): AutoQueueNode { if (!isGroup(node)) return node; if (node.id === parentId) { const next = [...node.children]; const j = index + dir; if (j < 0 || j >= next.length) return node; [next[index], next[j]] = [next[j], next[index]]; return { ...node, children: next }; } return { ...node, children: node.children.map((c) => moveChildIn(c, parentId, index, dir)), }; } function moveChildToTop( node: AutoQueueNode, parentId: string, index: number, ): AutoQueueNode { if (!isGroup(node)) return node; if (node.id === parentId) { if (index <= 0 || index >= node.children.length) return node; const next = [...node.children]; const [item] = next.splice(index, 1); next.unshift(item); return { ...node, children: next }; } return { ...node, children: node.children.map((c) => moveChildToTop(c, parentId, index)), }; } function makeLeaf(type: "channel" | "platform" | "all"): AutoQueueLeaf { return { id: newId(), match: { type }, weight: 1, maxWorkers: null }; } function makeGroup(): AutoQueueGroup { return { id: newId(), mode: "strict", weight: 1, maxWorkers: null, children: [], }; } // --- Form state -------------------------------------------------------------- type Form = { enabled: boolean; maxWorkers: number | null; replaceAutoSubs: boolean; order: AutoQueueOrder; root: AutoQueueGroup; }; function formOf(status: AutoQueueKindStatus): Form { return { enabled: status.policy.enabled, maxWorkers: status.policy.maxWorkers, replaceAutoSubs: status.policy.replaceAutoSubs === true, order: status.policy.order ?? "listed", root: status.policy.root, }; } export function PolicyTreeEditor({ kind, status, channels, platforms, buckets, operations, }: { kind: AutoQueueKind; status: AutoQueueKindStatus; channels: Channel[]; platforms: string[]; buckets: string[]; operations: string[]; }) { // The lane's tree is compiled from settings.channelPriority, so the ladder is // a reading of dispatch rather than a control on it. const compiled = status.policyCompiled; const [form, setForm] = useState
(() => formOf(status)); // The last value we know is on disk. Everything dirty-related is a comparison // against this, so "unsaved" means genuinely unsaved rather than "different // from what the page was first rendered with". const [baseline, setBaseline] = useState(() => formOf(status)); const [saving, setSaving] = useState(false); const [result, setResult] = useState(null); const dirty = useMemo( () => JSON.stringify(form) !== JSON.stringify(baseline), [form, baseline], ); // A ref so the poll effect can read dirtiness without re-subscribing to it. const dirtyRef = useRef(dirty); dirtyRef.current = dirty; // Adopt the server's copy when it changes and we have nothing to lose. This // is what makes an off-page write visible — "Top of queue" on the channels // table rewrites this very policy — and it also picks up the ids the server // sanitizer assigned after our own save. Never runs while dirty, so an edit // in progress is never clobbered by a poll. const incoming = JSON.stringify(formOf(status)); useEffect(() => { if (dirtyRef.current) return; if (incoming === JSON.stringify(baseline)) return; const next = JSON.parse(incoming) as Form; setBaseline(next); setForm(next); }, [incoming, baseline]); const setRoot = (fn: (r: AutoQueueGroup) => AutoQueueGroup) => setForm((f) => ({ ...f, root: fn(f.root) })); const ops: RungOps = { update: (id, fn) => setRoot((r) => mapNode(r, id, fn) as AutoQueueGroup), remove: (id) => setRoot((r) => removeFrom(r, id)), addChild: (parentId, child) => setRoot((r) => addChildTo(r, parentId, child) as AutoQueueGroup), move: (parentId, index, dir) => setRoot((r) => moveChildIn(r, parentId, index, dir) as AutoQueueGroup), moveToTop: (parentId, index) => setRoot((r) => moveChildToTop(r, parentId, index) as AutoQueueGroup), makeLeaf, makeGroup, }; const save = async () => { setSaving(true); setResult(null); const attempt = form; try { const res = await saveAutoQueueAction(kind, attempt); setResult(res); // Only the values we actually persisted become the new baseline; a failed // save must stay dirty so the work is still recoverable. if (res.ok) setBaseline(attempt); } catch (e) { setResult({ ok: false, error: (e as Error).message }); } finally { setSaving(false); } }; // "auto-transcribe" / "auto-download" for the two bucket lanes; the operation // lanes' runners are named for the lane ("auto-digest"), which is the same // word their job kind and their console heading use. const kindWord = kind === "transcription" ? "transcribe" : kind; const tradeoff = orderTradeoff(kind, form.order); // What the claim rail's fill is a fraction OF: the runner's own ceiling when // it has one, else however much it is currently carrying (so a rung holding // everything in flight reads as full). const capacity = status.policy.maxWorkers ?? Math.max(status.runner.inFlight.length, 1); return (
{compiled && (

These rules are generated from the channel priorities on{" "} the channels page {" "} — one tier per channel plus one focus, compiled into all four lanes. Edit them there; the switches on this page are still this lane’s own.

)} l.id), }} /> {/* Lowest-priority lane, off by default. Appending the opt-in bucket to the TAIL of the default union is what makes it lowest priority: pending work is claimed bucket-by-bucket in list order. DRAWN ONLY FOR A LANE WITH BUCKETS. `replaceAutoSubs` names an opt-in BUCKET, and the operation lanes have none — the switch would be a control that writes a field nothing reads. `buckets` is selectableBucketsForKind for this lane, so this asks the same source the runner's projection does. */} {buckets.length > 0 && ( )} {/* ORDER AT THE FOOT OF THE LANE, in the same block the sweep lanes use, so one control is learned once for all four pipelines. It stays part of the POLICY FORM rather than saving on change: the tree above it has an unsaved-changes discipline, and a control that saved itself while sitting inside a dirty form would be the one thing on the page that did not mean what the Save button says. There is no Reach axis, and that is a statement rather than a gap — a runner's order already applies across every channel and bucket a rule claims, so there is no second axis to offer. */} setForm((f) => ({ ...f, order: next.order }))} /> { setForm(baseline); setResult(null); }} />
); }