commit eeaebd088141d14513df5661f83f36f6ccd9263a
parent 00f8679f25645b0fe6015379addfa2eb96476e1f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 12:06:25 -0400
Merge channel-priority/s4 — the focus banner and the read-only tree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
9 files changed, 748 insertions(+), 33 deletions(-)
diff --git a/editor/app/channels/components/FocusBanner.tsx b/editor/app/channels/components/FocusBanner.tsx
@@ -1,25 +1,31 @@
-// THE FOCUS BANNER — a STUB, filled in by slice S4.
+// THE FOCUS BANNER — what a focus is doing, said once, wherever dispatch is
+// being watched.
//
-// It exists at S0 for the same reason ChannelTierSelect.tsx does: S3 and S4
-// then each edit one file the other does not. It already honours the one rule
-// S4 is written around — RENDER NOTHING WHEN NOTHING IS FOCUSED — so it is
-// green on /channels before S3 exists and green on the lane consoles before
-// anything writes a focus.
+// It answers the one question a focus creates and nothing else on the page can:
+// "why is only jeralyzer moving?" A lane console can show a runner running, a
+// ladder full of rungs and a pending count of thousands and still not say that
+// three quarters of those rungs are being held behind a group at the top.
//
-// What S4 makes of it: `Focus: <name> (N channels) · <units> pending in this
-// lane · M channels held`, plus an "End focus" button posting `endFocusAction()`.
-// The numbers come from `computeLeafPending`, which the status panel already
-// computes, so the banner is one sum over `counts` and no new read.
+// NOT A `role="status"`, and not a <section>. It sits immediately above
+// RunnerOperationView's `<section data-lane>`, whose structural contract
+// reserves `role="status"` for "Saved." and forbids a nested <section> — and
+// this is a persistent statement of state, not a live region announcing a
+// change. `data-focus-banner` is how a test finds it.
//
-// WHERE IT GOES on the lane consoles: OperationDetail.tsx, between
-// HowPriorityWorks and RunnerOperationView — i.e. OUTSIDE the `<section
-// data-lane>` that RunnerOperationView opens, whose contract comment reserves
-// `role="status"` and forbids a nested `<section>`.
+// NOT AN AutoRunnerIdleReason either. A lane whose focus group holds the rest is
+// not idle, it is dispatching focus work; "M channels held" is a DISPLAY fact,
+// computed from counts the status panel already had — one pass over
+// `pendingByLeaf` keyed by compiled leaf id, and no new read.
//
-// It is NOT a new AutoRunnerIdleReason. A lane whose focus group holds the rest
-// is not idle, it is dispatching focus work; "M channels held" is a display
-// fact.
+// NO WRITER OF ITS OWN. Ending a focus writes `settings.channelPriority`, and
+// that document has exactly one writer (`saveChannelPriorityAction`, S3). This
+// component therefore LINKS to /channels rather than posting an action of its
+// own — a second writer for one button is the thing the model was built to
+// avoid. `endFocus` is the slot a page that already holds that writer drops its
+// own control into; the link is what every other placement gets.
+import type { ReactNode } from "react";
+import Link from "next/link";
import type { AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes";
import type { FocusSummary } from "yt-dlp-transcript-common/lib/channelPriority";
@@ -32,12 +38,65 @@ export type FocusBannerProps = {
// The lane this banner is drawn beside, when it is on a lane console. Null on
// /channels, where the per-lane line is repeated for each ENABLED lane.
lane?: AutoQueueKind | null;
+ // An "End focus" control, supplied by a page that already owns the priority
+ // writer. Omitted everywhere else, where the link below is the way out.
+ endFocus?: ReactNode;
};
+function channelCount(n: number): string {
+ return `${n} channel${n === 1 ? "" : "s"}`;
+}
+
export default function FocusBanner({
summary,
+ name,
+ lane,
+ endFocus,
}: FocusBannerProps): React.ReactNode {
- // Nothing focused, nothing to say. S4 replaces the rest of this body.
+ // Nothing focused, nothing to say — including a focus that resolved to no
+ // channels at all, which compiles no focus group and holds no one.
if (!summary.active) return null;
- return null;
+
+ const label = name ?? summary.siteId ?? "selected channels";
+
+ return (
+ <div
+ data-focus-banner={lane ?? "all"}
+ data-focus-holding={summary.holding ? "true" : "false"}
+ className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md border border-brand/30 bg-brand-soft px-3 py-2 text-sm"
+ >
+ <span className="font-medium text-foreground">
+ Focus: {label} ({channelCount(summary.channelCount)})
+ </span>
+
+ {lane && (
+ <span className="tabular-nums text-muted-foreground">
+ · {summary.focusPending.toLocaleString()} pending in this lane ·{" "}
+ {summary.otherPending.toLocaleString()} waiting behind it
+ </span>
+ )}
+
+ <span className="text-muted-foreground">
+ {summary.holding ? (
+ <>· {channelCount(summary.heldChannels)} held</>
+ ) : (
+ // The focus has nothing left here, so strict descent has already
+ // fallen through to the groups below it. Worth saying: it is the
+ // moment the operator is waiting for, and the banner is the only
+ // thing that can see it.
+ <>· nothing left to focus here — the rest of the lane is running</>
+ )}
+ </span>
+
+ <span className="ml-auto flex items-center gap-3">
+ {endFocus}
+ <Link
+ href="/channels"
+ className="underline underline-offset-2 hover:text-brand"
+ >
+ Channel priorities
+ </Link>
+ </span>
+ </div>
+ );
}
diff --git a/editor/app/operations/channelPriorityView.ts b/editor/app/operations/channelPriorityView.ts
@@ -0,0 +1,135 @@
+import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { listSites } from "yt-dlp-transcript-common/lib/site";
+import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels";
+import type {
+ AutoQueueGroup,
+ AutoQueuePolicy,
+} from "yt-dlp-transcript-common/jobs/autoQueuePolicy";
+import type { AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes";
+import {
+ type ChannelPriority,
+ type FocusSummary,
+ type PendingByLeaf,
+ type SiteChannelIndex,
+ compileLaneRoot,
+ focusSummary,
+ resolveFocusSlugs,
+} from "yt-dlp-transcript-common/lib/channelPriority";
+
+// THE STATUS PAYLOAD'S HALF OF CHANNEL PRIORITY — the tree the lane actually
+// dispatches from, and the focus banner's numbers.
+//
+// WHY THIS EXISTS AT ALL. The payload used to ship `getSettings().autoQueue[kind]`
+// verbatim, which is the STORED policy. While a priority model exists the runner
+// does not dispatch from the stored tree — it compiles one from the model per
+// tick (S1, `laneDispatchRoot` in controller/autoRunner.ts) — so a ladder drawn
+// from the stored root would name leaves the runner does not have and attribute
+// zero pending to every one of them. The console's whole contract is that its
+// rungs ARE the dispatch order, so it has to compile the same tree.
+//
+// COMPILED FROM THE SAME CONTRACT, not from a second one: `compileLaneRoot` is
+// the one compiler (common/lib/channelPriority.ts) and both sides call it with
+// the same arguments. The duplication that remains is the three-line gate below
+// (`isDefaultChannelPriority` + the call), because S1 kept `laneDispatchRoot`
+// private to the runner. AT MERGE: export it there and delete `laneRootFor`
+// here — that is the one fold S5 owes this file.
+//
+// THE ABSENT DOCUMENT COSTS NOTHING. No focus and no channel entry means the
+// compiler never runs, the stored tree is shipped byte for byte, and neither
+// `listChannelConfigs` nor `listSites` is read — which is what keeps a corpus
+// with no priorities set on exactly today's payload and today's cost.
+
+export type PriorityView = {
+ model: ChannelPriority;
+ // The model says something, so the four trees are compiled rather than
+ // stored. The lane consoles read this to go read-only on the tree.
+ compiled: boolean;
+ // Every channel slug, the population `compileLaneRoot` filters per lane.
+ // Empty when nothing is compiled — it is never read in that case.
+ slugs: string[];
+ focusSlugs: string[];
+ // A display name for the focus: the site's title for a site focus, the slugs
+ // for a channel focus. Resolved here because the model stores a siteId and
+ // the banner does no I/O.
+ name: string | null;
+};
+
+// No focus and no per-channel entry: the document says nothing, so the compiler
+// must not run. The same predicate the runner applies, spelled the same way.
+function isDefaultChannelPriority(model: ChannelPriority): boolean {
+ return model.focus.kind === "none" && Object.keys(model.channels).length === 0;
+}
+
+// "a, b and c" for a short channel focus; "a, b and 4 more" past three, because
+// this lands mid-sentence in a banner and a 30-slug list is not a name.
+function channelFocusName(slugs: readonly string[]): string {
+ if (slugs.length <= 3) {
+ if (slugs.length <= 1) return slugs[0] ?? "";
+ return `${slugs.slice(0, -1).join(", ")} and ${slugs[slugs.length - 1]}`;
+ }
+ return `${slugs.slice(0, 2).join(", ")} and ${slugs.length - 2} more`;
+}
+
+export async function readPriorityView(
+ paths: Paths = getPaths(),
+): Promise<PriorityView> {
+ const model = getSettings().channelPriority;
+ if (isDefaultChannelPriority(model)) {
+ return { model, compiled: false, slugs: [], focusSlugs: [], name: null };
+ }
+ const slugs = (await listChannelConfigs(paths)).map((row) => row.slug);
+ // ONLY A SITE FOCUS READS THE SITES DIRECTORY, as in the runner: the other
+ // two kinds resolve from the document alone.
+ const siteChannels: Record<string, string[]> = {};
+ let name: string | null = null;
+ if (model.focus.kind === "site") {
+ for (const site of listSites(paths)) {
+ siteChannels[site.siteId] = site.channels.map((c) => c.slug);
+ if (site.siteId === model.focus.siteId) name = site.siteTitle;
+ }
+ name = name ?? model.focus.siteId;
+ }
+ const index: SiteChannelIndex = siteChannels;
+ const focusSlugs = resolveFocusSlugs(model, index, slugs);
+ if (model.focus.kind === "channels") name = channelFocusName(focusSlugs);
+ return { model, compiled: true, slugs, focusSlugs, name };
+}
+
+// THE ROOT THIS LANE DISPATCHES FROM, for the ladder to draw. Identical to the
+// runner's answer by construction: same compiler, same model, same focus set,
+// and `compileLaneRoot` re-applies the per-lane paused filter itself, so handing
+// it the whole corpus and handing it a pre-filtered list give the same tree.
+export function laneRootFor(
+ view: PriorityView,
+ lane: AutoQueueKind,
+ policy: AutoQueuePolicy,
+): AutoQueueGroup {
+ if (!view.compiled) return policy.root;
+ return compileLaneRoot(lane, view.model, view.slugs, view.focusSlugs);
+}
+
+// `focusSummary` reads nothing but each leaf's COUNT, and `computeLeafPending`
+// throws the arrays away before this layer sees them (it returns counts plus a
+// truncated head). Rather than widen that return type — which would put the
+// whole pending set of every leaf on a three-second poll's heap — the counts are
+// re-presented as arrays of the right length. `new Array(n)` allocates no
+// elements; only `.length` is ever read.
+export function pendingByLeafFromCounts(
+ counts: Record<string, number>,
+): PendingByLeaf {
+ const out: Record<string, readonly string[]> = {};
+ for (const [id, n] of Object.entries(counts)) out[id] = new Array<string>(n);
+ return out;
+}
+
+export function laneFocusSummary(
+ view: PriorityView,
+ counts: Record<string, number>,
+): FocusSummary {
+ return focusSummary(
+ view.model,
+ view.focusSlugs,
+ pendingByLeafFromCounts(counts),
+ );
+}
diff --git a/editor/app/operations/components/ClaimLadder.tsx b/editor/app/operations/components/ClaimLadder.tsx
@@ -18,6 +18,7 @@ export function ClaimLadder({
operations,
data,
ops,
+ readOnly = false,
}: {
root: AutoQueueGroup;
channels: Channel[];
@@ -26,11 +27,17 @@ export function ClaimLadder({
operations: string[];
data: RungData;
ops: RungOps;
+ // The tree is COMPILED from the channel priorities, so it is shown rather
+ // than edited. See LadderRung.
+ readOnly?: boolean;
}) {
return (
- <div className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2">
+ <div
+ className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2"
+ data-policy-compiled={readOnly ? "true" : "false"}
+ >
<p className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- Policy
+ Policy{readOnly ? " (generated)" : ""}
</p>
<LadderRung
node={root}
@@ -45,6 +52,7 @@ export function ClaimLadder({
operations={operations}
data={data}
ops={ops}
+ readOnly={readOnly}
/>
</div>
);
diff --git a/editor/app/operations/components/HowPriorityWorks.tsx b/editor/app/operations/components/HowPriorityWorks.tsx
@@ -12,8 +12,14 @@ import {
// It is reference material — true, worth having, and read once — so it was
// costing every subsequent visit the height of the answer to "what is it doing
// right now", which is the question people actually arrive with.
+//
+// `compiled` says whether the rules below are GENERATED from the channel
+// priorities rather than hand-authored here. It changes what the last paragraph
+// claims, because with a priority model set "read top to bottom" is still true
+// and "edit them here" is not: the compiler wins at dispatch, so a rule typed
+// into this tree could not change what the lane does.
-export function HowPriorityWorks() {
+export function HowPriorityWorks({ compiled = false }: { compiled?: boolean }) {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
@@ -35,6 +41,37 @@ export function HowPriorityWorks() {
claims. Rule order still wins — for a pure newest-first archive, use
one catch-all rule.
</p>
+ {/* THE TREE IS GENERATED, and this is where that is said in prose —
+ the read-only ladder says it as a state, this says it as a rule.
+ Drawn in both cases because "where do the rules come from" has an
+ answer either way, and the two answers are different. */}
+ <p>
+ {compiled ? (
+ <>
+ <strong className="text-foreground">
+ These rules are generated
+ </strong>{" "}
+ from the channel priorities — one tier per channel plus one
+ focus — set on{" "}
+ <Link href="/channels" className="underline">
+ the channels page
+ </Link>
+ . All four lanes are compiled from that one document, so a
+ focus group sits at the top of every one of them and the tree
+ below is read-only here.
+ </>
+ ) : (
+ <>
+ No channel priorities are set, so these rules are hand-authored
+ here. Setting a tier or a focus on{" "}
+ <Link href="/channels" className="underline">
+ the channels page
+ </Link>{" "}
+ generates all four lanes’ rules instead, and this tree
+ becomes read-only.
+ </>
+ )}
+ </p>
<p>
A video is claimed by exactly one rule (the first that matches), so
overlapping rules never double-process it. This is independent of the{" "}
diff --git a/editor/app/operations/components/LadderRung.tsx b/editor/app/operations/components/LadderRung.tsx
@@ -38,6 +38,16 @@ import {
// 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<string, number>;
@@ -80,20 +90,39 @@ export function LadderRung(props: {
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 } =
- props;
+ 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 (
- <div className="flex gap-2">
+ // 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-<slug>`
+ // is a generated id, and an id that does not carry that prefix is
+ // hand-authored. Nothing renders it as text.
+ <div className="flex gap-2" data-node-id={node.id}>
<ClaimRail active={active} capacity={data.capacity} />
<div className="flex min-w-0 flex-1 flex-col gap-2 pb-1">
<div className="flex flex-wrap items-center gap-2">
{group ? (
- <GroupControls node={node as AutoQueueGroup} update={ops.update} />
+ <GroupControls
+ node={node as AutoQueueGroup}
+ update={ops.update}
+ readOnly={readOnly}
+ />
) : (
<LeafControls {...props} leaf={node as AutoQueueLeaf} />
)}
@@ -104,6 +133,7 @@ export function LadderRung(props: {
<input
type="number"
min={1}
+ disabled={readOnly}
value={node.weight ?? 1}
onChange={(e) =>
ops.update(node.id, (n) => ({
@@ -122,6 +152,7 @@ export function LadderRung(props: {
type="number"
min={1}
placeholder="∞"
+ disabled={readOnly}
value={node.maxWorkers ?? ""}
onChange={(e) =>
ops.update(node.id, (n) => ({
@@ -157,7 +188,7 @@ export function LadderRung(props: {
)}
<span className="ml-auto flex items-center gap-1">
- {parentId && siblingCount > 1 && (
+ {!readOnly && parentId && siblingCount > 1 && (
<>
<Button
type="button"
@@ -196,7 +227,7 @@ export function LadderRung(props: {
</Button>
</>
)}
- {parentId && (
+ {!readOnly && parentId && (
<Button
type="button"
size="xs"
@@ -231,6 +262,7 @@ export function LadderRung(props: {
</p>
)}
</div>
+ {!readOnly && (
<div className="flex flex-wrap gap-2">
<AddButton onClick={() => ops.addChild(node.id, ops.makeLeaf("channel"))}>
+ Channel rule
@@ -245,6 +277,7 @@ export function LadderRung(props: {
+ Group
</AddButton>
</div>
+ )}
</>
)}
</div>
@@ -363,9 +396,11 @@ function PendingDrilldown({
function GroupControls({
node,
update,
+ readOnly,
}: {
node: AutoQueueGroup;
update: RungOps["update"];
+ readOnly: boolean;
}) {
return (
<>
@@ -375,6 +410,7 @@ function GroupControls({
{/* Named: this select had no label at all. */}
<select
aria-label="group mode"
+ disabled={readOnly}
value={node.mode}
onChange={(e) =>
update(node.id, (n) => ({ ...n, mode: e.target.value as AutoQueueMode }))
@@ -402,8 +438,18 @@ function LeafControls(props: {
operations: string[];
data: RungData;
ops: RungOps;
+ readOnly?: boolean;
}) {
- const { leaf, channels, platforms, buckets, operations, data, ops } = props;
+ 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 (
@@ -416,6 +462,7 @@ function LeafControls(props: {
{/* Named: the match-type select had no label. */}
<select
aria-label="rule match type"
+ disabled={readOnly}
value={type}
onChange={(e) =>
ops.update(leaf.id, (n) => ({
@@ -433,6 +480,7 @@ function LeafControls(props: {
{type === "channel" && (
<select
aria-label="channel rule value"
+ disabled={readOnly}
value={leaf.match.value ?? ""}
onChange={(e) =>
ops.update(leaf.id, (n) => ({
@@ -454,6 +502,7 @@ function LeafControls(props: {
{type === "platform" && (
<select
aria-label="platform rule value"
+ disabled={readOnly}
value={leaf.match.value ?? ""}
onChange={(e) =>
ops.update(leaf.id, (n) => ({
@@ -487,6 +536,7 @@ function LeafControls(props: {
<select
value={leaf.match.operation ?? ""}
aria-label="rule operation"
+ disabled={readOnly}
onChange={(e) =>
ops.update(leaf.id, (n) => {
const match = { ...(n as AutoQueueLeaf).match };
@@ -521,6 +571,7 @@ function LeafControls(props: {
<select
value={leaf.match.bucket ?? ""}
aria-label="rule bucket"
+ disabled={readOnly}
onChange={(e) =>
ops.update(leaf.id, (n) => {
const match = { ...(n as AutoQueueLeaf).match };
diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx
@@ -5,6 +5,7 @@ import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState
import type { AutoQueueStatusPayload } from "../status";
// Type-only: syncRow.ts is a server module. See OperationRail.
import type { SyncRowView } from "../syncRow";
+import FocusBanner from "../../channels/components/FocusBanner";
import { HowPriorityWorks } from "./HowPriorityWorks";
import { OperationRail } from "./OperationRail";
import { RunnerOperationView } from "./RunnerOperationView";
@@ -124,7 +125,23 @@ export function OperationDetail({
activeJobs={activeJobs}
/>
)}
- <HowPriorityWorks />
+ <HowPriorityWorks compiled={data[runnerKind].policyCompiled} />
+ {/* THE FOCUS BANNER, OUTSIDE THE LANE SECTION. RunnerOperationView
+ opens the page's one `<section data-lane>` and its contract
+ forbids a nested <section> and reserves role="status"; the banner
+ is neither, and it sits above rather than inside so a
+ `section[data-lane]`-scoped lookup in the suite never sees it.
+
+ Its numbers are THIS LANE's: `focusPending`/`otherPending` are
+ sums over the lane's own compiled `pendingByLeaf`, so the same
+ focus reads differently on the four consoles — which is the point,
+ since a focus can be holding one lane and exhausted on another.
+ It renders nothing at all when no focus resolves. */}
+ <FocusBanner
+ summary={data[runnerKind].focus}
+ name={data[runnerKind].focusName ?? undefined}
+ lane={runnerKind}
+ />
<RunnerOperationView
kind={runnerKind}
title={RUNNER_TITLE[runnerKind]}
diff --git a/editor/app/operations/components/PolicyTreeEditor.tsx b/editor/app/operations/components/PolicyTreeEditor.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
+import Link from "next/link";
import {
type AutoQueueGroup,
type AutoQueueLeaf,
@@ -27,6 +28,26 @@ import {
// 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 {
@@ -161,6 +182,9 @@ export function PolicyTreeEditor({
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<Form>(() => 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
@@ -269,8 +293,21 @@ export function PolicyTreeEditor({
</label>
</div>
+ {compiled && (
+ <p className="text-xs text-muted-foreground">
+ These rules are generated from the channel priorities on{" "}
+ <Link href="/channels" className="underline underline-offset-2">
+ the channels page
+ </Link>{" "}
+ — 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.
+ </p>
+ )}
+
<ClaimLadder
root={form.root}
+ readOnly={compiled}
channels={channels}
platforms={platforms}
buckets={buckets}
diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts
@@ -16,7 +16,14 @@ import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes";
import type { AutoQueuePolicy } from "yt-dlp-transcript-common/jobs/autoQueuePolicy";
import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates";
+import type { FocusSummary } from "yt-dlp-transcript-common/lib/channelPriority";
import { buildAutoQueueLanes, type AutoQueueLanesPayload } from "./lanes";
+import {
+ type PriorityView,
+ laneFocusSummary,
+ laneRootFor,
+ readPriorityView,
+} from "./channelPriorityView";
// Read-only payload for the Auto-Queue panel: per-kind runner status (running?,
// what's in flight, in-flight counts per tree node), the effective policy, the
@@ -35,6 +42,11 @@ export type PlatformCooldownView = {
export type AutoQueueKindStatus = {
kind: AutoQueueKind;
+ // THE POLICY AS THE LANE DISPATCHES IT, not as it is stored. Every field is
+ // the stored one except `root`, which is the COMPILED tree whenever
+ // `settings.channelPriority` says anything (see channelPriorityView.ts). The
+ // claim ladder is drawn from this, so a rung is a rule the runner actually
+ // has — and with a priority model set the stored tree is not one.
policy: AutoQueuePolicy;
runner: AutoRunnerStatus;
pendingByLeaf: Record<string, number>;
@@ -62,6 +74,18 @@ export type AutoQueueKindStatus = {
// settings wholesale between tests while the pool keeps its pausedSnapshot.
// Download has no live counterpart: its flag IS the gate, read at dispatch.
held: boolean;
+ // THE TREE ABOVE IS GENERATED, so the editor for it is read-only and the
+ // channel priorities on /channels are where it is edited. False for a corpus
+ // with no priorities set, which is every corpus until one is.
+ policyCompiled: boolean;
+ // THE FOCUS BANNER'S NUMBERS, FOR THIS LANE. Always present and inert when
+ // `active` is false, so the banner is one component with one early return
+ // rather than a conditional on the payload. The counts are this lane's own —
+ // `focusPending`/`otherPending` differ per lane by construction.
+ focus: FocusSummary;
+ // The focus's display name, resolved on the server (the model stores a
+ // siteId, and the banner does no I/O). Null when nothing is focused.
+ focusName: string | null;
};
export type AutoQueueStatusPayload = Record<
@@ -80,9 +104,20 @@ export type AutoQueueStatusPayload = Record<
lanes: AutoQueueLanesPayload;
};
-async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> {
+async function buildKind(
+ kind: AutoQueueKind,
+ priority: PriorityView,
+): Promise<AutoQueueKindStatus> {
const paths = getPaths();
- const policy = getSettings().autoQueue[kind];
+ const stored = getSettings().autoQueue[kind];
+ // The stored policy, with the DISPATCHED root in place of the stored one.
+ // Spread rather than rebuilt, so `held`, `snoozeUntil`, `enabled`, `order`
+ // and `maxWorkers` come through untouched — the same rule every writer of a
+ // policy in this repo follows.
+ const policy: AutoQueuePolicy = {
+ ...stored,
+ root: laneRootFor(priority, kind, stored),
+ };
const runner = getAutoRunnerStatus(kind);
const state = await readAutoQueueState(paths);
const pending = await computeLeafPending(kind, paths);
@@ -114,6 +149,12 @@ async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> {
kind === "transcription"
? getWorkerPool().isPaused()
: isGateHeld(getSettings(), kind),
+ policyCompiled: priority.compiled,
+ // Keyed by COMPILED leaf id (`prio-focus-<slug>`), which is why this is
+ // computed here and not on the client: it is only meaningful against the
+ // counts of the tree the lane dispatches from.
+ focus: laneFocusSummary(priority, pending.counts),
+ focusName: priority.name,
};
}
@@ -121,8 +162,12 @@ async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> {
// lane added to the model appears on this payload with no edit here, which is
// the whole point of the widened type.
export async function buildAutoQueueStatusPayload(): Promise<AutoQueueStatusPayload> {
+ // ONE RESOLUTION FOR ALL FOUR LANES. The focus set costs a channel listing
+ // and, for a site focus, a sites read; the four lanes compile from the same
+ // one, so resolving per lane would pay for it four times on a 3 s poll.
+ const priority = await readPriorityView();
const [kinds, lanes] = await Promise.all([
- Promise.all(LANES.map((lane) => buildKind(lane))),
+ Promise.all(LANES.map((lane) => buildKind(lane, priority))),
buildAutoQueueLanes(),
]);
return {
diff --git a/editor/e2e/focus-banner.spec.ts b/editor/e2e/focus-banner.spec.ts
@@ -0,0 +1,326 @@
+import { test, expect } from "@playwright/test";
+import type { Page } from "@playwright/test";
+import {
+ generateReport,
+ resetData,
+ writeChannelConfig,
+ writeDigestVideo,
+ writeSettings,
+ writeSite,
+} from "./helpers";
+
+// THE FOCUS BANNER AND THE GENERATED TREE, on a lane console.
+//
+// One document — `settings.channelPriority` — changes three things about
+// /operations/<lane> at once, and this spec is what says so:
+//
+// 1. THE BANNER. A focus is a statement nothing else on the page can make:
+// the ladder can be full and the count in the thousands while most of it is
+// held behind a group at the top. "Focus: <name> (N channels) · <focus>
+// pending in this lane · <rest> waiting behind it · M channels held".
+// 2. THE LADDER IS THE COMPILED TREE. The status payload used to ship the
+// STORED policy, and with a model set the lane does not dispatch from it —
+// so the rungs are `prio-focus-*` / `prio-normal-*` / `prio-all`, keyed by
+// the compiled ids, which is what makes the counts land on them.
+// 3. THE EDITOR IS READ-ONLY. Editing a rung could not change dispatch (the
+// compiler wins), so the controls that would rewrite the tree are disabled
+// and the ones that would add or remove a node are gone.
+//
+// And clearing the document puts all three back exactly as they were, which is
+// the half that matters most: every corpus has no priorities set until one does.
+//
+// THE SEEDED SETTINGS CARRY THE COMPILED ROOTS TOO, because that is what lands
+// on disk — S3's one writer persists `channelPriority` and the four
+// `autoQueue[lane].root` trees in a single save. Seeding the model alone would
+// be a state no writer produces.
+
+const FOCUSED = "focus-chan";
+const OTHER = "other-chan";
+const SITE = "focusite";
+const SITE_TITLE = "Focus Site";
+const SLOW = 120_000;
+
+type Leaf = {
+ id: string;
+ match: { type: string; value?: string };
+ weight: number;
+ maxWorkers: number | null;
+};
+type Group = {
+ id: string;
+ mode: string;
+ weight: number;
+ maxWorkers: number | null;
+ children: (Group | Leaf)[];
+};
+
+// `compileLaneRoot`'s output, spelled out rather than imported: the ids and the
+// group order ARE the contract this page renders, so writing them here asserts
+// them a second time instead of re-deriving them from the code under test.
+function channelLeaf(tier: string, slug: string): Leaf {
+ return {
+ id: `prio-${tier}-${slug}`,
+ match: { type: "channel", value: slug },
+ weight: 1,
+ maxWorkers: null,
+ };
+}
+function tierGroup(tier: string, slugs: string[]): Group {
+ return {
+ id: `prio-${tier}`,
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: slugs.map((slug) => channelLeaf(tier, slug)),
+ };
+}
+function compiledRoot(lane: string, focus: string[], normal: string[]): Group {
+ const children: (Group | Leaf)[] = [];
+ if (focus.length > 0) children.push(tierGroup("focus", focus));
+ if (normal.length > 0) children.push(tierGroup("normal", normal));
+ children.push({
+ id: "prio-all",
+ match: { type: "all" },
+ weight: 1,
+ maxWorkers: null,
+ });
+ return {
+ id: `${lane}-root`,
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children,
+ };
+}
+
+// One hand-authored catch-all, the shape a corpus with no priorities carries.
+const CATCH_ALL: Group = {
+ id: "root",
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: [
+ { id: "all", match: { type: "all" }, weight: 1, maxWorkers: null },
+ ],
+};
+
+// The digest lane is the one whose work list a spec can seed from a transcript
+// alone (writeDigestVideo), and the one lane-runner.spec already drives.
+function settingsDoc(over: Record<string, unknown> = {}) {
+ return {
+ adminTitle: "Test Admin",
+ maxTranscriptPageBytes: 8388608,
+ sleepBetweenDownloadsSeconds: 0,
+ minFreeDiskGB: 0,
+ verifyAvailabilityBeforeClean: false,
+ syncScheduler: { fullSweepIntervalMinutes: 0 },
+ digest: {
+ localAppId: "ollama-direct",
+ remoteAppId: "claude-code",
+ sections: ["chapters"],
+ yieldToTranscription: false,
+ },
+ ...over,
+ };
+}
+
+async function seed(page: Page) {
+ await resetData(null);
+ await writeChannelConfig(FOCUSED);
+ await writeChannelConfig(OTHER);
+ await writeDigestVideo({ channelSlug: FOCUSED, videoId: "focusvid0001" });
+ await writeDigestVideo({ channelSlug: OTHER, videoId: "othervid0001" });
+ await writeSite(SITE, {
+ siteTitle: SITE_TITLE,
+ channels: [{ slug: FOCUSED }],
+ });
+ // The snapshot is the lane's WORK LIST — nothing is pending until it exists.
+ await writeSettings(
+ settingsDoc({
+ autoQueue: { digest: { enabled: true, maxWorkers: 1, root: CATCH_ALL } },
+ }),
+ );
+ await generateReport(page, FOCUSED);
+ await generateReport(page, OTHER);
+}
+
+// The lane's console, hydrated. Every assertion below reads state React put
+// there, so waiting for the section rather than for a timeout is the difference
+// between a spec and a race.
+async function openDigestConsole(page: Page) {
+ await page.goto("/operations/digest");
+ await expect(page.locator('section[data-lane="digest"]')).toHaveAttribute(
+ "data-hydrated",
+ "true",
+ { timeout: 30_000 },
+ );
+}
+
+test("a site focus banners the lane, compiles the ladder and freezes the editor", async ({
+ page,
+}) => {
+ test.setTimeout(SLOW);
+ await seed(page);
+
+ // No focus yet: the page is the page it has always been.
+ await openDigestConsole(page);
+ await expect(page.locator("[data-focus-banner]")).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "+ Channel rule" }).first(),
+ ).toBeVisible();
+
+ // THE DOCUMENT, AND THE TREES IT COMPILES TO, in one write — which is what
+ // S3's single writer puts on disk.
+ await writeSettings(
+ settingsDoc({
+ channelPriority: {
+ focus: { kind: "site", siteId: SITE },
+ channels: {},
+ },
+ autoQueue: {
+ digest: {
+ enabled: true,
+ maxWorkers: 1,
+ root: compiledRoot("digest", [FOCUSED], [OTHER]),
+ },
+ },
+ }),
+ );
+
+ await openDigestConsole(page);
+
+ // (1) THE BANNER. The site's TITLE, not its id — the model stores a siteId and
+ // the name is resolved on the server.
+ const banner = page.locator('[data-focus-banner="digest"]');
+ await expect(banner).toBeVisible();
+ await expect(banner).toContainText(`Focus: ${SITE_TITLE} (1 channel)`);
+ await expect(banner).toContainText("1 pending in this lane");
+ await expect(banner).toContainText("1 waiting behind it");
+ // THE HELD COUNT. One non-focus channel has work it is not getting, because
+ // strict descent never reaches the group it is in while the focus group has
+ // anything. This is the display fact the banner exists for.
+ await expect(banner).toContainText("1 channel held");
+ await expect(banner).toHaveAttribute("data-focus-holding", "true");
+
+ // (2) THE LADDER IS THE COMPILED TREE, keyed by the compiled ids.
+ await expect(page.locator('[data-node-id="prio-focus"]')).toHaveCount(1);
+ await expect(
+ page.locator(`[data-node-id="prio-focus-${FOCUSED}"]`),
+ ).toHaveCount(1);
+ await expect(
+ page.locator(`[data-node-id="prio-normal-${OTHER}"]`),
+ ).toHaveCount(1);
+ await expect(page.locator('[data-node-id="prio-all"]')).toHaveCount(1);
+ // The counts landed on the compiled leaves, which is the whole reason the
+ // payload had to stop shipping the stored tree: a leaf the runner does not
+ // have would read zero.
+ await expect(
+ page
+ .locator(`[data-node-id="prio-focus-${FOCUSED}"]`)
+ .getByRole("button", { name: /Show pending videos for rule/ }),
+ ).toHaveText(/1/);
+
+ // (3) THE EDITOR IS READ-ONLY — the tree only. The rules cannot be rewritten,
+ // added to or removed from.
+ await expect(
+ page.getByRole("button", { name: "+ Channel rule" }),
+ ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Remove" })).toHaveCount(0);
+ await expect(page.getByLabel("group mode").first()).toBeDisabled();
+ await expect(page.getByLabel("rule match type").first()).toBeDisabled();
+ await expect(
+ page.locator('[data-policy-compiled="true"]'),
+ ).toHaveCount(1);
+ // And only the tree: the lane's own switches still belong to this page.
+ await expect(page.getByLabel("video order for auto-digest")).toBeEnabled();
+
+ // THE PROSE FOLLOWS THE STATE. "How priority works" is a disclosure, so it is
+ // opened rather than read through it. The phrase asserted is the one only the
+ // disclosure carries: the ladder's own note opens with the same sentence by
+ // design — it states the same fact as a state rather than as a rule — so a
+ // shorter match resolves to both and fails strict mode.
+ await page.getByText("How priority works", { exact: false }).click();
+ await expect(
+ page.getByText("All four lanes are compiled from that one document"),
+ ).toBeVisible();
+});
+
+test("clearing the priority document puts the console back exactly as it was", async ({
+ page,
+}) => {
+ test.setTimeout(SLOW);
+ await seed(page);
+
+ await writeSettings(
+ settingsDoc({
+ channelPriority: {
+ focus: { kind: "site", siteId: SITE },
+ channels: {},
+ },
+ autoQueue: {
+ digest: {
+ enabled: true,
+ maxWorkers: 1,
+ root: compiledRoot("digest", [FOCUSED], [OTHER]),
+ },
+ },
+ }),
+ );
+ await openDigestConsole(page);
+ await expect(page.locator('[data-focus-banner="digest"]')).toBeVisible();
+
+ // Focus ended, priorities cleared, the hand-authored tree back.
+ await writeSettings(
+ settingsDoc({
+ autoQueue: { digest: { enabled: true, maxWorkers: 1, root: CATCH_ALL } },
+ }),
+ );
+ await openDigestConsole(page);
+
+ await expect(page.locator("[data-focus-banner]")).toHaveCount(0);
+ await expect(page.locator('[data-node-id="prio-focus"]')).toHaveCount(0);
+ await expect(page.locator('[data-node-id="all"]')).toHaveCount(1);
+ await expect(
+ page.getByRole("button", { name: "+ Channel rule" }).first(),
+ ).toBeVisible();
+ await expect(page.getByLabel("rule match type").first()).toBeEnabled();
+ await expect(page.locator('[data-policy-compiled="false"]')).toHaveCount(1);
+});
+
+test("a focus that resolves to nothing banners nothing and compiles no group", async ({
+ page,
+}) => {
+ test.setTimeout(SLOW);
+ await seed(page);
+
+ // An unknown siteId resolves to no channels, which compiles NO focus group —
+ // deliberately, so a typo leaves the tree as it would be with no focus rather
+ // than holding the whole corpus behind a site that does not exist.
+ await writeSettings(
+ settingsDoc({
+ channelPriority: {
+ focus: { kind: "site", siteId: "not-a-site" },
+ channels: {},
+ },
+ autoQueue: {
+ digest: {
+ enabled: true,
+ maxWorkers: 1,
+ root: compiledRoot("digest", [], [FOCUSED, OTHER]),
+ },
+ },
+ }),
+ );
+ await openDigestConsole(page);
+
+ await expect(page.locator("[data-focus-banner]")).toHaveCount(0);
+ await expect(page.locator('[data-node-id="prio-focus"]')).toHaveCount(0);
+ // The document still says something, so the tree is still GENERATED and the
+ // editor still read-only — an unresolvable focus is not an absent document.
+ await expect(
+ page.locator(`[data-node-id="prio-normal-${FOCUSED}"]`),
+ ).toHaveCount(1);
+ await expect(
+ page.getByRole("button", { name: "+ Channel rule" }),
+ ).toHaveCount(0);
+});