"use client"; import Link from "next/link"; import { useState } from "react"; import { type AutoQueueGroup, type AutoQueueLeaf, type AutoQueueMode, type AutoQueueNode, AUTO_QUEUE_MODES, isGroup, } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; import { Button } from "yt-dlp-transcript-common/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "yt-dlp-transcript-common/components/ui/collapsible"; import { type Channel, MODE_GLOSS, NUM_CLASS, SELECT_CLASS, formatRecency, leafSentence, } from "./dispatch"; // One rung of the claim ladder: a rule (or a group of them) as a single line // that carries its ordinal, what it matches, how much of the runner it is // currently holding, and how much work it owns — and is simultaneously the // control that edits it. // // This replaces a tree editor that sat above a separate "Pending per rule" list // and a separate "Recent picks" list, all three cross-referenced by a leafLabel() // id lookup. Putting them on one object deletes that lookup and answers // "which rule is doing the work" by looking at it. // // DEPTH IS THE RAIL, NOT AN INDENT. Each level draws its own hairline on the // left, and that same hairline carries the claim fill — so nesting and // occupancy are one mark instead of a margin plus a chip. // // READ-ONLY IS A RENDERING MODE, NOT A SECOND COMPONENT. When the lane's tree is // COMPILED from the channel priorities (settings.channelPriority), editing a // rung here could not change what the lane dispatches — the compiler wins — so // every control that would rewrite the tree is disabled and the ones that would // ADD or REMOVE a node are not drawn at all. What stays is everything that // reads: the ordinal, the match sentence, the claim rail, the live occupancy // and the pending drill-down. The switches that are NOT part of the tree // (enable, worker cap, order, auto-captions) live in PolicyTreeEditor and are // unaffected. export type RungData = { pendingByLeaf: Record; pendingHeadByLeaf: Record; ownerByVideo: Record; recencyByVideo: Record; activeByNode: Record; // Denominator for the claim fill: the runner's live capacity. capacity: number; nextUpLeafId: string | null; // Pre-order leaf ids, so a rung knows its own ordinal without re-walking. leafIds: string[]; }; export type RungOps = { update: (id: string, fn: (n: AutoQueueNode) => AutoQueueNode) => void; remove: (id: string) => void; addChild: (parentId: string, child: AutoQueueNode) => void; move: (parentId: string, index: number, dir: -1 | 1) => void; moveToTop: (parentId: string, index: number) => void; makeLeaf: (type: "channel" | "platform" | "all") => AutoQueueLeaf; makeGroup: () => AutoQueueGroup; }; export function LadderRung(props: { node: AutoQueueNode; depth: number; parentId: string | null; parentMode: AutoQueueMode | null; index: number; siblingCount: number; channels: Channel[]; platforms: string[]; // What THIS LANE projects, and both lists can legitimately be empty. A lane // with no buckets (digest, backfill) draws no bucket select; a lane with no // operations, or exactly one, draws no operation select — with one there is // nothing to choose, and naming it claims exactly what a bucket-less leaf // already claims. buckets: string[]; operations: string[]; data: RungData; ops: RungOps; // The tree is generated, so it is shown rather than edited. See above. readOnly?: boolean; }) { const { node, depth, parentId, parentMode, index, siblingCount, data, ops, readOnly = false, } = props; const group = isGroup(node); const active = data.activeByNode[node.id] ?? 0; const isNext = !group && data.nextUpLeafId === node.id; return ( // The node id is on the DOM, which is the only way to check by eye (or from // a test) that a compiled leaf is the leaf the runner has: `prio-focus-` // is a generated id, and an id that does not carry that prefix is // hand-authored. Nothing renders it as text.
{group ? ( ) : ( )} {parentMode === "weighted-fair" && ( )} {active > 0 && ( {active} working )} {!group && ( )} {isNext && ( next up )} {!readOnly && parentId && siblingCount > 1 && ( <> {/* Named. These two were addressed by a bare glyph, so a screen reader announced the button as "up arrow, button". */} )} {!readOnly && parentId && ( )}
{group && ( <>
{(node as AutoQueueGroup).children.map((child, i) => ( ))} {(node as AutoQueueGroup).children.length === 0 && (

Empty group — add a rule or nested group below.

)}
{!readOnly && (
ops.addChild(node.id, ops.makeLeaf("channel"))}> + Channel rule ops.addChild(node.id, ops.makeLeaf("platform"))}> + Platform rule ops.addChild(node.id, ops.makeLeaf("all"))}> + Catch-all rule ops.addChild(node.id, ops.makeGroup())}> + Group
)} )}
); } function AddButton({ onClick, children, }: { onClick: () => void; children: React.ReactNode; }) { return ( ); } // The signature mark. A hairline that says depth by existing at all, and says // occupancy by how much of it is lit — so a rung holding two of the runner's // three slots reads without a number, and the number beside it is confirmation // rather than the only channel. function ClaimRail({ active, capacity }: { active: number; capacity: number }) { const pct = capacity > 0 ? Math.min(100, Math.round((active / capacity) * 100)) : 0; return ( ); } // The count, and behind it the actual videos in the exact order they would be // handed out. buildPendingByLeaf already returned these arrays and // computeLeafPendingCounts threw everything but .length away — which is also why // there was previously no way to check an ordering change by eye. function PendingDrilldown({ leafId, ordinal, data, }: { leafId: string; ordinal: number; data: RungData; }) { const [open, setOpen] = useState(false); const count = data.pendingByLeaf[leafId] ?? 0; const head = data.pendingHeadByLeaf[leafId] ?? []; if (count === 0) { return 0; } return (
    {head.map((id) => { const slug = data.ownerByVideo[id]; const recency = formatRecency(data.recencyByVideo[id]); return (
  • {slug ? ( {slug}/{id} ) : ( {id} )} {recency && ( {recency} )}
  • ); })} {count > head.length && (
  • …and {(count - head.length).toLocaleString()} more
  • )}
); } function GroupControls({ node, update, readOnly, }: { node: AutoQueueGroup; update: RungOps["update"]; readOnly: boolean; }) { return ( <> Group {/* Named: this select had no label at all. */} {MODE_GLOSS[node.mode]} ); } function LeafControls(props: { leaf: AutoQueueLeaf; channels: Channel[]; platforms: string[]; buckets: string[]; operations: string[]; data: RungData; ops: RungOps; readOnly?: boolean; }) { const { leaf, channels, platforms, buckets, operations, data, ops, readOnly = false, } = props; const type = leaf.match.type; const ordinal = data.leafIds.indexOf(leaf.id) + 1; return ( <> {/* Legitimate numbering: this ordinal IS the priority the engine reads, not decoration on an unordered list. */} {ordinal > 0 ? ordinal : "—"} {/* Named: the match-type select had no label. */} {type === "channel" && ( )} {type === "platform" && ( )} {/* THE OPERATION SELECT, for a lane that carries more than one. `operation` and `bucket` are two id spaces and a leaf may name at most one (sanitizeMatch enforces it, operation winning), so setting either one clears the other here rather than letting the sanitizer decide silently on save. Native ops.update(leaf.id, (n) => { const match = { ...(n as AutoQueueLeaf).match }; if (e.target.value) { match.operation = e.target.value; delete match.bucket; } else { delete match.operation; } return { ...n, match }; }) } className={SELECT_CLASS} > {operations.map((o) => ( ))} )} {buckets.length > 0 && ( )} {leafSentence(leaf, channels)} ); }