commit 998abd84d56b133ecfd785c3a045cc38e1efb88d
parent 136c974fe3337f03ddac1718f0f24ea790ab2ad3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 12 Sep 2026 01:15:53 -0400
merge: channel-priority/s5 into integrate/2026-09-storage-priority
Six conflicts, all of them the two branches landing in the same slot:
- common/controller/autoRunner.ts — both import blocks kept. listChannelMeta
returns s5's `{ meta, slugs }` with its per-lane paused filter AND keeps
relocate's `config` on each meta entry: the media guard calls
`inspectChannelMedia(paths, m.slug, m.config)` precisely so it does not
re-read 68 config.json files on every tick of four lanes.
- editor/app/channels/components/ChannelsTable.tsx — both import blocks, both
default params, both prop types. The bulk bars are TWO SIBLING ELEMENTS,
storage first: a union of the two hunks would have produced one element
carrying both bars' props, which tsc reports as duplicates and which would
have silently dropped a bar. Both branches had independently added the same
select column, so `colSpan` stays 10 + columns.length.
- editor/app/channels/page.tsx — both the `media:` and `priority:` row fields,
both the `defaultMediaRoot` and the `sites`/`focusLabel` props.
- editor/CHANGELOG.md, plans/FACTS.md — both entries, relocate's first.
- plans/STATE.md — one head, and ONE operator runbook rather than two that
disagree about what goes first. The channel-priority migration is step 1
because it is the only step that must happen before this code ever boots;
the platter, the saved-video store and the per-channel moves follow.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
47 files changed, 6750 insertions(+), 333 deletions(-)
diff --git a/common/bin/migrate-channel-priority.ts b/common/bin/migrate-channel-priority.ts
@@ -0,0 +1,343 @@
+#!/usr/bin/env tsx
+import path from "node:path";
+import { readdir, readFile, rename, writeFile } from "node:fs/promises";
+import { getPaths } from "../lib/paths";
+import { getSettings, writeSettings } from "../lib/settings";
+import { siteChannelIndex } from "../lib/site";
+import {
+ channelPriorityFromLegacy,
+ compileLanes,
+ hasCompiledLaneRoots,
+ isDefaultChannelPriority,
+ overridesOf,
+ rankOf,
+ resolveFocusSlugs,
+ tierOf,
+ type ChannelPriority,
+} from "../lib/channelPriority";
+import { LANES } from "../lib/autoQueueTypes";
+import { parseFlags } from "./_parseFlags";
+
+// THE ONE-SHOT CHANNEL-PRIORITY MIGRATION (plans/channel-priority.md, S5).
+//
+// Usage:
+// migrate-channel-priority.ts --dry-run # print, write nothing
+// migrate-channel-priority.ts # write
+//
+// It turns three legacy facts into one document:
+//
+// 1. `excludeFromSync: true` on a channel config -> `overrides: {sync:"paused"}`
+// 2. a bare channel leaf in autoQueue.transcription.root -> a rank
+// 3. a bare channel leaf in autoQueue.download.root -> a rank
+//
+// ...then compiles the four `autoQueue[lane].root` trees from the result and
+// clears `excludeFromSync` from every config.json, because the field is
+// deleted from the schema in the same slice.
+//
+// WHY A SCRIPT AND NOT `getSettings`. The derivation needs the 68 channel
+// configs; `getSettings` is synchronous and reads one file. Running it from
+// there would put 68 reads on every settings read in the process.
+//
+// WHY IT READS THE CONFIGS RAW, and not through `listChannelConfigs`.
+// `excludeFromSync` is DELETED from `ChannelConfig` in the same slice, and
+// `parseChannelConfig` is allow-list style — so the parser now drops the very
+// key this migration exists to read. The raw file is the only place the flag
+// still exists, which is also why the clearing pass below is a raw key delete
+// rather than a parse-and-rewrite.
+//
+// IDEMPOTENT, TWICE OVER. `channelPriorityFromLegacy` returns the STORED
+// document untouched once any lane root carries a `prio-*` id — compiled
+// channel leaves are bare channel leaves, so a second run would otherwise
+// re-derive the ranks from its own output and collapse the hand-made order
+// into the tree that order produced. And a config with no `excludeFromSync`
+// key is left alone rather than rewritten.
+//
+// RUN IT BEFORE THE FIRST BOOT OF THIS CODE, not after. `excludeFromSync` is
+// DELETED, so until this has run the 15 channels that carried it are sync
+// -eligible again: the scheduler's selection, `autoSyncEligible`, *Sync all*
+// and every group's Sync all read the priority document and find nothing said
+// about them. Stop the editor, run this, start it.
+//
+// NEVER RUN IT AGAINST A LIVE EDITOR — a running editor holds settings in
+// memory and writes them back on its own schedule, so a migration underneath
+// one is a write that gets overwritten. It takes its own backup (below), so
+// there is no `cp` for the operator to forget.
+//
+// THE DRY RUN WRITES NOTHING AT ALL — not settings.json, not a config.json,
+// not a temp file. It is safe to point at a live corpus, and is how the table
+// in this slice's record was produced:
+//
+// SETTINGS_FILE=/tmp/copy.json TRANSCRIPTS_DIR=/path/to/transcripts \
+// tsx common/bin/migrate-channel-priority.ts --dry-run
+
+const flags = parseFlags(process.argv.slice(2));
+const dryRun = flags["dry-run"] === "true";
+const paths = getPaths();
+
+// One channel, as the migration needs it: the slug and the RAW parsed JSON of
+// its config.json. A directory with no readable config.json is skipped, the
+// same channels `listChannelConfigs` would have returned.
+type RawChannel = { slug: string; raw: Record<string, unknown> };
+
+async function readRawChannels(): Promise<RawChannel[]> {
+ const entries = await readdir(paths.channelsDir, {
+ withFileTypes: true,
+ }).catch(() => []);
+ const out: RawChannel[] = [];
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const file = path.join(paths.channelsDir, entry.name, "config.json");
+ let raw: unknown;
+ try {
+ raw = JSON.parse(await readFile(file, "utf8"));
+ } catch {
+ continue;
+ }
+ if (!raw || typeof raw !== "object") continue;
+ out.push({ slug: entry.name, raw: raw as Record<string, unknown> });
+ }
+ return out.sort((a, b) => a.slug.localeCompare(b.slug));
+}
+
+// Which legacy fact produced this channel's entry, for the table. The point of
+// the column is that an operator can check the migration against the two lists
+// they wrote by hand, so it names the LANE and the position, not just "a rank".
+function provenance(
+ slug: string,
+ excluded: boolean,
+ transcription: readonly string[],
+ download: readonly string[],
+): string {
+ const parts: string[] = [];
+ const t = transcription.indexOf(slug);
+ const d = download.indexOf(slug);
+ if (t !== -1) parts.push(`transcription#${t}`);
+ if (d !== -1) parts.push(`download#${d}`);
+ if (excluded) parts.push("excludeFromSync");
+ return parts.join(" + ") || "-";
+}
+
+// The bare channel leaves of one stored root, in depth-first order — the same
+// reading `channelPriorityFromLegacy` does, repeated here only so the table can
+// say WHERE a rank came from.
+function bareLeaves(node: unknown): string[] {
+ const out: string[] = [];
+ const walk = (n: unknown): void => {
+ if (!n || typeof n !== "object") return;
+ const rec = n as Record<string, unknown>;
+ if (Array.isArray(rec.children)) {
+ for (const c of rec.children) walk(c);
+ return;
+ }
+ const match = rec.match as Record<string, unknown> | undefined;
+ if (!match || match.type !== "channel") return;
+ if (match.bucket || match.operation) return;
+ const value = typeof match.value === "string" ? match.value.trim() : "";
+ if (value && !out.includes(value)) out.push(value);
+ };
+ walk(node);
+ return out;
+}
+
+function pad(s: string, n: number): string {
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
+}
+
+function printTable(
+ model: ChannelPriority,
+ slugs: readonly string[],
+ excludedSlugs: ReadonlySet<string>,
+ transcription: readonly string[],
+ download: readonly string[],
+): void {
+ const rows = slugs.map((slug) => {
+ const rank = rankOf(model, slug);
+ const overrides = overridesOf(model, slug);
+ const pinned = Object.entries(overrides)
+ .map(([op, tier]) => `${op}=${tier}`)
+ .join(",");
+ return [
+ slug,
+ tierOf(model, slug),
+ rank === null ? "-" : String(rank),
+ pinned || "-",
+ provenance(slug, excludedSlugs.has(slug), transcription, download),
+ ];
+ });
+ const head = ["slug", "tier", "rank", "overrides", "from"];
+ const width = head.map((h, i) =>
+ Math.max(h.length, ...rows.map((r) => r[i].length)),
+ );
+ const line = (cells: string[]) =>
+ cells.map((c, i) => pad(c, width[i])).join(" ");
+ console.log(line(head));
+ console.log(width.map((w) => "-".repeat(w)).join(" "));
+ // Ranked channels first, in rank order — the migration's whole output is an
+ // ORDER, and an alphabetical table hides whether it came out right.
+ const ranked = rows.filter((r) => r[2] !== "-");
+ const rest = rows.filter((r) => r[2] === "-");
+ ranked.sort((a, b) => Number(a[2]) - Number(b[2]));
+ for (const r of [...ranked, ...rest]) console.log(line(r));
+}
+
+// Rewrite one config.json with the `excludeFromSync` key REMOVED, preserving
+// every other key exactly as it is on disk.
+//
+// Deliberately not `readChannelConfig` + `writeChannelConfig`: that round trip
+// runs the allow-list parser, which would also drop any other key a hand-edited
+// or older config carries. A migration should change the one thing it is about.
+async function clearExcludeFromSync(slug: string): Promise<boolean> {
+ const file = path.join(paths.channelsDir, slug, "config.json");
+ let raw: string;
+ try {
+ raw = await readFile(file, "utf8");
+ } catch {
+ return false;
+ }
+ let parsed: Record<string, unknown>;
+ try {
+ parsed = JSON.parse(raw) as Record<string, unknown>;
+ } catch {
+ console.warn(` ! ${slug}: config.json is not valid JSON — left alone`);
+ return false;
+ }
+ if (!("excludeFromSync" in parsed)) return false;
+ delete parsed.excludeFromSync;
+ const tmp = `${file}.tmp-${process.pid}`;
+ await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n");
+ await rename(tmp, file);
+ return true;
+}
+
+async function main(): Promise<void> {
+ const settings = getSettings();
+ const channels = await readRawChannels();
+ const slugs = channels.map((c) => c.slug);
+ // `LegacyChannelRow` is structural and names only the one key, so the raw
+ // objects satisfy it directly.
+ const configs = channels.map(({ slug, raw }) => ({
+ slug,
+ config: { excludeFromSync: raw.excludeFromSync === true },
+ }));
+ const transcription = bareLeaves(settings.autoQueue.transcription?.root);
+ const download = bareLeaves(settings.autoQueue.download?.root);
+ const excludedSlugs = new Set(
+ configs.filter((c) => c.config.excludeFromSync).map((c) => c.slug),
+ );
+
+ console.log(`settings: ${paths.settingsFile}`);
+ console.log(`channels: ${paths.channelsDir} (${slugs.length})`);
+ console.log(
+ `legacy: transcription ${transcription.length} ranked, ` +
+ `download ${download.length} ranked, ` +
+ `${excludedSlugs.size} excludeFromSync`,
+ );
+
+ const alreadyCompiled = hasCompiledLaneRoots(settings.autoQueue);
+ if (alreadyCompiled) {
+ console.log(
+ "\nThe stored lane roots already carry compiled `prio-*` ids, so this " +
+ "corpus has been migrated. The document below is the STORED one — " +
+ "nothing is re-derived from a compiled tree.",
+ );
+ }
+
+ const model = channelPriorityFromLegacy(
+ configs,
+ settings.autoQueue,
+ settings.channelPriority,
+ );
+
+ console.log("");
+ printTable(model, slugs, excludedSlugs, transcription, download);
+
+ const focusSlugs = resolveFocusSlugs(model, siteChannelIndex(paths), slugs);
+ const ranked = slugs.filter((s) => rankOf(model, s) !== null).length;
+ const pinned = slugs.filter(
+ (s) => Object.keys(overridesOf(model, s)).length > 0,
+ ).length;
+ console.log("");
+ console.log(`focus: ${JSON.stringify(model.focus)} (${focusSlugs.length} channels)`);
+ console.log(`entries: ${Object.keys(model.channels).length}`);
+ console.log(`ranked: ${ranked}`);
+ console.log(`pinned: ${pinned} channel(s) with a per-operation override`);
+ console.log(
+ `tiers: ` +
+ ["normal", "low", "paused"]
+ .map((t) => `${t} ${slugs.filter((s) => tierOf(model, s) === t).length}`)
+ .join(", "),
+ );
+
+ if (dryRun) {
+ console.log("\n--- channelPriority (would be written) ---");
+ console.log(JSON.stringify(model, null, 2));
+ const roots = isDefaultChannelPriority(model)
+ ? null
+ : compileLanes(model, slugs, focusSlugs);
+ if (roots) {
+ console.log("\n--- compiled lane roots (would be written) ---");
+ for (const lane of LANES) {
+ const groups = roots[lane].children.map((c) =>
+ "children" in c ? `${c.id}(${c.children.length})` : c.id,
+ );
+ console.log(` ${lane}: ${groups.join(" > ")}`);
+ }
+ } else {
+ console.log(
+ "\nThe document is empty, so nothing would be compiled and the " +
+ "stored trees would stand — which is also what the runner does.",
+ );
+ }
+ console.log(
+ `\nwould clear excludeFromSync from ${excludedSlugs.size} config.json file(s)`,
+ );
+ console.log("\nDRY RUN — nothing was written.");
+ return;
+ }
+
+ // THE BACKUP IS THIS SCRIPT'S JOB, not the operator's, because the operator
+ // step would be on the destructive path: a migration that says "back it up
+ // first" in a comment has already lost the file for anyone who did not.
+ // Written before the first real write, never in --dry-run, and it REFUSES to
+ // overwrite an existing name rather than clobber an earlier backup.
+ const backup = `${paths.settingsFile}.pre-priority-${new Date()
+ .toISOString()
+ .replace(/[:.]/g, "-")}`;
+ await writeFile(backup, await readFile(paths.settingsFile, "utf8"), {
+ flag: "wx",
+ });
+ console.log(`\nbacked up ${paths.settingsFile} -> ${backup}`);
+
+ const autoQueue = { ...settings.autoQueue };
+ // The same gate the one writer applies (editor/app/channels/actions.ts): a
+ // document that says nothing leaves a NEVER-COMPILED tree alone, but a tree
+ // the compiler has already written is recompiled regardless — there is no
+ // hand-made tree left to protect there, and a stale compiled tree is what
+ // the runner would dispatch from on its bypass.
+ if (
+ !isDefaultChannelPriority(model) ||
+ hasCompiledLaneRoots(settings.autoQueue)
+ ) {
+ const roots = compileLanes(model, slugs, focusSlugs);
+ for (const lane of LANES) {
+ autoQueue[lane] = { ...settings.autoQueue[lane], root: roots[lane] };
+ }
+ }
+ await writeSettings({ ...settings, channelPriority: model, autoQueue });
+ console.log(`wrote ${paths.settingsFile}`);
+
+ let cleared = 0;
+ for (const slug of slugs) {
+ if (await clearExcludeFromSync(slug)) cleared++;
+ }
+ console.log(`cleared excludeFromSync from ${cleared} config.json file(s)`);
+ console.log(
+ "\nRestart the editor: the runner reads the priority document on its " +
+ "next tick, but the worker pool and the heartbeat are armed at boot.",
+ );
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/common/controller/autoRunner.test.ts b/common/controller/autoRunner.test.ts
@@ -0,0 +1,333 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import {
+ focusHoldLine,
+ laneDispatchRoot,
+ makeFocusHoldReporter,
+ priorityContextFor,
+ resetPriorityContextForTest,
+} from "./autoRunner";
+import {
+ defaultChannelPriority,
+ isDefaultChannelPriority,
+ sanitizeChannelPriority,
+ type ChannelPriority,
+ type FocusSummary,
+} from "../lib/channelPriority";
+import { LANES } from "../lib/autoQueueTypes";
+import type {
+ AutoQueueGroup,
+ AutoQueuePolicy,
+} from "../jobs/autoQueuePolicy";
+import type { Paths } from "../lib/paths";
+import type { SiteSettings } from "../lib/settings";
+
+// THE RUNNER'S HALF OF S1 (plans/channel-priority.md), and it is here rather
+// than in `jobs/channelPriorityCompile.test.ts` for one reason:
+// `../architecture.test.ts` forbids `jobs/ -> controller/`, including from a
+// test file, and its ALLOWED list may only shrink. The engine-level proof — a
+// focus holding and releasing a real `buildPendingByLeaf` + `selectNextWork`
+// pair — lives there; what lives here is the thing that can only be said about
+// the runner: it writes ONE line per transition.
+//
+// WHY A LINE AND NOT AN IDLE REASON. A lane whose focus group holds the rest is
+// not idle, it is dispatching focus work, so `AutoRunnerIdleReason` gains
+// nothing and `no-pending` stays the true answer for an empty tree. The log
+// line is what makes "why is only jeralyzer moving?" answerable from the job
+// log, and the banner (S4) is what makes it answerable from the page.
+
+function summary(over: Partial<FocusSummary> = {}): FocusSummary {
+ return {
+ kind: "channels",
+ siteId: null,
+ slugs: ["slow-a"],
+ channelCount: 1,
+ active: true,
+ focusPending: 2,
+ otherPending: 5,
+ holding: true,
+ heldChannels: 1,
+ ...over,
+ };
+}
+
+test("the focus-hold line fires once per state change, not once per tick", () => {
+ const lines: string[] = [];
+ const report = makeFocusHoldReporter("download", (line) => lines.push(line));
+
+ // No focus at all: silence, however many ticks run.
+ report(null);
+ report(null);
+ assert.deepEqual(lines, []);
+
+ // Entering the hold: one line.
+ report(summary());
+ report(summary());
+ report(summary());
+ assert.equal(lines.length, 1);
+ assert.match(lines[0], /Auto-download: focus \(1 channel\) is holding/);
+
+ // THE COUNTS MOVE ON EVERY GRANT AND MUST NOT RE-FIRE IT. The state is the
+ // three-valued thing; the counts are in the message only, which is why the
+ // reporter keys on the state and not on the line it printed.
+ report(summary({ focusPending: 1 }));
+ report(summary({ focusPending: 1, heldChannels: 2, otherPending: 9 }));
+ assert.equal(lines.length, 1);
+
+ // The focus runs out: the release line, once.
+ report(summary({ focusPending: 0, holding: false }));
+ report(summary({ focusPending: 0, holding: false }));
+ assert.equal(lines.length, 2);
+ assert.match(lines[1], /has no work left in this lane/);
+
+ // New focus work retakes the lane: the hold line again.
+ report(summary());
+ assert.equal(lines.length, 3);
+ assert.match(lines[2], /is holding/);
+
+ // The focus ends (or resolves to nothing): back to silence, no line.
+ report(null);
+ report(null);
+ assert.equal(lines.length, 3);
+});
+
+test("an inactive focus is the same state as no focus", () => {
+ const lines: string[] = [];
+ const report = makeFocusHoldReporter("digest", (line) => lines.push(line));
+ // What a `{kind:"site"}` focus naming an unknown site resolves to: a summary
+ // exists, but nothing is focused, so the lane is not holding for anyone.
+ report(summary({ active: false, channelCount: 0, slugs: [], holding: false }));
+ assert.deepEqual(lines, []);
+ assert.equal(
+ focusHoldLine("digest", summary({ active: false })),
+ null,
+ );
+});
+
+test("the line names the lane and pluralises the focus set", () => {
+ assert.match(
+ focusHoldLine("transcription", summary({ channelCount: 30 })) ?? "",
+ /Auto-transcription: focus \(30 channels\) is holding this lane — 2 focus unit\(s\) pending, 1 channel\(s\) held\./,
+ );
+ assert.match(
+ focusHoldLine("backfill", summary({ holding: false, otherPending: 7 })) ??
+ "",
+ /Auto-backfill: focus \(1 channel\) has no work left in this lane — 7 unit\(s\) released/,
+ );
+ assert.equal(focusHoldLine("download", null), null);
+});
+
+// --- The two functions S1 shipped untested (the S0/S1 review, finding 6) -----
+//
+// `laneDispatchRoot` and `priorityContextFor` are the whole of "compile, not
+// consult" on the dispatch side, and between them they carry the one promise
+// that lets this plan ship without a corpus-wide gate: an absent document
+// changes NOTHING. S5 exported both — the first because the status payload has
+// to ask the runner which tree it dispatches from rather than compiling a
+// second one (editor/app/operations/channelPriorityView.ts), the second
+// because its cache key, its TTL and its "only a site focus reads the sites
+// directory" rule are three claims no pure function can be asked about.
+
+function policyWith(root: AutoQueueGroup): AutoQueuePolicy {
+ return {
+ enabled: true,
+ maxWorkers: null,
+ replaceAutoSubs: false,
+ order: "listed",
+ snoozeUntil: null,
+ root,
+ };
+}
+
+const STORED_ROOT: AutoQueueGroup = {
+ id: "download-root",
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: [
+ { id: "hand-made", match: { type: "channel", value: "slow-b" } },
+ { id: "download-all", match: { type: "all" } },
+ ],
+};
+
+test("a default model is the byte-identical bypass: the STORED root, by identity", () => {
+ const policy = policyWith(STORED_ROOT);
+ const model = defaultChannelPriority();
+ assert.equal(isDefaultChannelPriority(model), true);
+ for (const lane of LANES) {
+ const root = laneDispatchRoot(lane, policy, { model, focusSlugs: [] }, [
+ "slow-a",
+ "slow-b",
+ ]);
+ // NOT deepEqual. The promise is that the compiler never runs, so the object
+ // the runner dispatches from is the one `getSettings()` returned — a
+ // structural copy would mean a compile happened and merely agreed.
+ assert.equal(root, policy.root, `${lane} must bypass the compiler`);
+ }
+});
+
+test("one channel entry is enough to leave the bypass, and the tree is compiled", () => {
+ const policy = policyWith(STORED_ROOT);
+ // A `low` tier says something even though nothing is focused: the whole
+ // point of `isDefaultChannelPriority` is that it is the FULL document, not
+ // the focus alone.
+ const model = sanitizeChannelPriority({
+ focus: { kind: "none" },
+ channels: { "slow-b": { tier: "low" } },
+ });
+ assert.equal(isDefaultChannelPriority(model), false);
+ const root = laneDispatchRoot("download", policy, { model, focusSlugs: [] }, [
+ "slow-a",
+ "slow-b",
+ ]);
+ assert.notEqual(root, policy.root);
+ assert.deepEqual(
+ root.children.map((c) => c.id),
+ ["prio-normal", "prio-low", "prio-all"],
+ );
+ // And the hand-made leaf is simply not in it: the stored tree is not
+ // consulted at all while a model exists.
+ assert.equal(JSON.stringify(root).includes("hand-made"), false);
+});
+
+test("a focus alone leaves the bypass too", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "channels", slugs: ["slow-a"] },
+ channels: {},
+ });
+ assert.equal(isDefaultChannelPriority(model), false);
+});
+
+// --- priorityContextFor: the cache, the TTL, and who reads the sites dir ----
+
+function sitesFixture(): Paths {
+ const dir = mkdtempSync(path.join(tmpdir(), "prio-ctx-"));
+ const sitesDir = path.join(dir, "sites");
+ mkdirSync(path.join(sitesDir, "testsite"), { recursive: true });
+ writeFileSync(
+ path.join(sitesDir, "testsite", "site.json"),
+ JSON.stringify({
+ siteTitle: "Test site",
+ channels: [{ slug: "slow-a" }, { slug: "slow-b" }],
+ }),
+ );
+ return { sitesDir } as Paths;
+}
+
+function settingsWith(priority: ChannelPriority): SiteSettings {
+ return { channelPriority: priority } as SiteSettings;
+}
+
+test("priorityContextFor resolves a site focus against the sites directory", () => {
+ resetPriorityContextForTest();
+ const paths = sitesFixture();
+ const ctx = priorityContextFor(
+ paths,
+ settingsWith(
+ sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "testsite" },
+ channels: {},
+ }),
+ ),
+ );
+ assert.deepEqual(ctx.focusSlugs, ["slow-a", "slow-b"]);
+});
+
+test("only a site focus reads the sites directory", () => {
+ resetPriorityContextForTest();
+ // A path that does not exist: `listSites` would return [] rather than throw,
+ // so the proof is the RESULT — a channel focus resolves in full from a
+ // document alone, against a sites dir that could not have been read.
+ const paths = { sitesDir: path.join(tmpdir(), "prio-ctx-absent") } as Paths;
+ const ctx = priorityContextFor(
+ paths,
+ settingsWith(
+ sanitizeChannelPriority({
+ focus: { kind: "channels", slugs: ["slow-b", "slow-a"] },
+ channels: {},
+ }),
+ ),
+ );
+ assert.deepEqual(ctx.focusSlugs, ["slow-b", "slow-a"]);
+ // The same document with a SITE focus against the same absent directory
+ // resolves to nothing — which is the designed answer for an unknown siteId
+ // (a typo must not hold the whole corpus), and is what makes the line above
+ // a statement about the read and not about the focus kind.
+ resetPriorityContextForTest();
+ assert.deepEqual(
+ priorityContextFor(
+ paths,
+ settingsWith(
+ sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "testsite" },
+ channels: {},
+ }),
+ ),
+ ).focusSlugs,
+ [],
+ );
+});
+
+test("the context is cached on the document, and a changed document re-resolves", () => {
+ resetPriorityContextForTest();
+ const paths = sitesFixture();
+ const settings = settingsWith(
+ sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "testsite" },
+ channels: {},
+ }),
+ );
+ const first = priorityContextFor(paths, settings);
+ // Same document, same paths, inside the TTL: the SAME object, so the sites
+ // directory was not read a second time. This is the claim that lets the
+ // runner call it on every three-second tick.
+ assert.equal(priorityContextFor(paths, settings), first);
+
+ // A channel added to the focused site joins the focus WITHOUT a settings
+ // write — that is why this is a TTL and not a pure memo. Rewrite the site,
+ // expire the cache by hand, and the focus set moves.
+ writeFileSync(
+ path.join(paths.sitesDir, "testsite", "site.json"),
+ JSON.stringify({
+ siteTitle: "Test site",
+ channels: [{ slug: "slow-a" }, { slug: "slow-b" }, { slug: "slow-c" }],
+ }),
+ );
+ assert.deepEqual(priorityContextFor(paths, settings).focusSlugs, [
+ "slow-a",
+ "slow-b",
+ ]);
+ first.at = 0;
+ assert.deepEqual(priorityContextFor(paths, settings).focusSlugs, [
+ "slow-a",
+ "slow-b",
+ "slow-c",
+ ]);
+
+ // A DIFFERENT DOCUMENT is the other trigger, and it does not wait for the
+ // TTL: the key is the document plus the sites dir.
+ const ended = priorityContextFor(
+ paths,
+ settingsWith(defaultChannelPriority()),
+ );
+ assert.deepEqual(ended.focusSlugs, []);
+
+ // And the paths are in the key, so two worktrees' runners in one process
+ // cannot share a focus resolved against the other's sites directory.
+ const other = sitesFixture();
+ writeFileSync(
+ path.join(other.sitesDir, "testsite", "site.json"),
+ JSON.stringify({ siteTitle: "Other", channels: [{ slug: "only-here" }] }),
+ );
+ assert.deepEqual(priorityContextFor(paths, settings).focusSlugs, [
+ "slow-a",
+ "slow-b",
+ "slow-c",
+ ]);
+ assert.deepEqual(priorityContextFor(other, settings).focusSlugs, [
+ "only-here",
+ ]);
+});
diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts
@@ -3,7 +3,7 @@ import type { Paths } from "../lib/paths";
import type { ChannelConfig } from "../lib/channelConfig";
import { mapConcurrent } from "../lib/concurrency";
import { getPaths } from "../lib/paths";
-import { getSettings } from "../lib/settings";
+import { getSettings, type SiteSettings } from "../lib/settings";
import { diskGate } from "../lib/diskSpace";
import { formatBytes } from "../lib/format";
import { detectPlatform } from "../lib/platform";
@@ -83,6 +83,17 @@ import {
type ChannelMediaStatus,
} from "../lib/channelMedia";
import {
+ type ChannelPriority,
+ type FocusSummary,
+ type SiteChannelIndex,
+ compileLaneRoot,
+ focusSummary,
+ isChannelPaused,
+ isDefaultChannelPriority,
+ resolveFocusSlugs,
+} from "../lib/channelPriority";
+import { siteChannelIndex } from "../lib/site";
+import {
listChannelConfigs,
readChannelConfig,
readChannelSnapshotShared,
@@ -297,13 +308,239 @@ const SNAPSHOT_READ_CONCURRENCY = 64;
// Re-derived from the shared listChannelConfigs rather than repeating the
// readdir-then-serial-read here.
-async function listChannelMeta(paths: Paths): Promise<ChannelMeta[]> {
+//
+// PAUSED IS A FILTER ON THE CHANNEL LIST, not a shape in the tree, and this is
+// the one predicate that makes it so (plans/channel-priority.md, decision 3). A
+// tree cannot express exclusion — an `{type:"all"}` catch-all matches
+// everything, and first-match-wins would let a catch-all placed above the Low
+// group swallow Low's work — so a paused channel is removed from the LIST every
+// leaf draws from instead. This function is the single source of that list for
+// BOTH the runner loop and `computeLeafPending`, so a paused channel is absent
+// from the lane's draw and from the status panel's pending counts in one edit.
+//
+// PER LANE, through `isChannelPaused(model, slug, lane)` — the EFFECTIVE tier
+// for the lane being listed, so the per-operation override map decides: a
+// channel with `{tier:"normal", overrides:{sync:"paused"}}` (what all 15 live
+// `excludeFromSync` channels migrate to) is still drawn by the download lane,
+// and one with `{tier:"paused"}` or `overrides:{download:"paused"}` is not.
+// Re-evaluated on CHANNEL_LIST_TTL_MS, which is the clock for this decision.
+//
+// It returns the PRE-FILTER slug list beside the filtered meta, off the one
+// read. `known` is an EXISTENCE filter for the focus resolver (a site whose
+// membership has outrun the corpus, or a hand-edited settings.json), and the
+// paused-filtered list is the wrong answer for it twice over: it would drop a
+// focused channel that this lane happens to have paused, and it would make the
+// runner's "M channels held" line disagree with the banner, which resolves
+// against every channel (editor/app/operations/channelPriorityView.ts).
+async function listChannelMeta(
+ paths: Paths,
+ kind: AutoQueueKind,
+ priority: ChannelPriority,
+): Promise<{ meta: ChannelMeta[]; slugs: string[] }> {
const configs = await listChannelConfigs(paths);
- return configs.map(({ slug, config }) => ({
- slug,
- platform: detectPlatform(config.url),
- config,
- }));
+ return {
+ meta: configs
+ .filter(({ slug }) => !isChannelPaused(priority, slug, kind))
+ .map(({ slug, config }) => ({
+ slug,
+ platform: detectPlatform(config.url),
+ config,
+ })),
+ slugs: configs.map(({ slug }) => slug),
+ };
+}
+
+// --- The compiled priority trees -------------------------------------------
+//
+// THE MODEL COMPILES, IT IS NOT CONSULTED. `settings.channelPriority` holds one
+// tier per channel plus one focus selector; `compileLaneRoot` turns that into
+// the lane's `AutoQueueGroup` (focus > normal > low > catch-all, all strict),
+// and dispatch runs the ordinary engine over it. Nothing in
+// `buildPendingByLeaf`, `selectNextWork`, `operationBatch`, `laneLimit` or
+// `pauseGates` learns a second priority mechanism, and "a zero limit is a hold,
+// never a stop" (controller/operationBatch.ts:22-25) is preserved trivially
+// because nothing here ever returns a limit.
+//
+// A FOCUS HOLDS THE REST BECAUSE STRICT DESCENT ALREADY DOES, and no second
+// mechanism is added: `pick()` (jobs/autoQueuePolicy.ts) filters a strict
+// group's children to those WITH WORK and descends into the first of them, and
+// it is re-asked on every grant. So focus work present => nothing below it is
+// picked; focus work exhausted => the next group runs; new focus work arriving
+// => the very next pick retakes the lane.
+//
+// WHERE THE HAND-EDITED TREES GO. The compiled root REPLACES
+// `settings.autoQueue[lane].root` at `laneDispatchRoot` below — the stored tree
+// is not consulted at all while a model exists. That is what makes the compiler
+// the ONE WRITER of channel priority: a `PolicyTreeEditor` save can still put a
+// channel leaf in the stored tree, but it cannot change what this lane
+// dispatches, so the two cannot fight — the compiler simply wins. (S3's
+// `saveChannelPriorityAction` then also PERSISTS the compiled roots, so the
+// stored tree and this one agree on disk; S4 makes the editor read-only for
+// compiled groups, which is the UI catching up with this fact.)
+//
+// AN ABSENT MODEL CHANGES NOTHING, BYTE FOR BYTE. `isDefaultChannelPriority`
+// below is the gate: no focus and no channel entries means the compiler never
+// runs and the stored trees stand exactly as they are today.
+
+const PRIORITY_CONTEXT_TTL_MS = 60_000;
+
+// The resolved half of the model — the half that costs I/O. Rebuilt when the
+// stored document changes or the TTL lapses, the same two triggers the
+// operation lanes' run context uses (settings key + 60 s), and for the same
+// reason: `resolveFocusSlugs` reads `transcripts/sites/*/site.json` through
+// `siteChannelIndex`, and a `{kind:"site"}` focus tracks that file's membership rather
+// than freezing a list. The TTL is what makes a channel added to the focused
+// site join the focus without a settings write.
+// The half of it `laneDispatchRoot` needs, and the only half a caller OUTSIDE
+// this file can supply: the editor's status payload resolves the same two
+// fields its own way (editor/app/operations/channelPriorityView.ts) and then
+// asks THIS function for the tree, so the console can never draw a tree the
+// runner does not dispatch from.
+export type PriorityDispatchContext = {
+ model: ChannelPriority;
+ focusSlugs: readonly string[];
+};
+
+type PriorityContext = PriorityDispatchContext & {
+ key: string;
+ at: number;
+ focusSlugs: string[];
+};
+
+// The key carries `known` because the focus set is resolved against it: a
+// channel created or deleted since the last resolution changes the answer, and
+// a cache keyed on the document alone would keep the old one for a minute.
+function contextKey(
+ model: ChannelPriority,
+ paths: Paths,
+ known: readonly string[],
+): string {
+ return JSON.stringify([model, paths.sitesDir, known]);
+}
+
+let priorityContext: PriorityContext | null = null;
+
+// `settings` is a PARAMETER, not a read: both call sites already hold the
+// settings object for this tick (the runner's `next()` reads it to see
+// `enabled` and `snoozeUntil`; `computeLeafPending` reads it for the policy),
+// and a second `getSettings()` here made every tick and every three-second
+// status poll parse settings.json twice.
+// Exported for its own test: the cache key, the TTL and the "only a site focus
+// reads the sites directory" rule are three claims a caller cannot observe
+// through `laneDispatchRoot`, which is pure.
+export function resetPriorityContextForTest(): void {
+ priorityContext = null;
+}
+
+export function priorityContextFor(
+ paths: Paths,
+ settings: SiteSettings,
+ // Every channel slug that exists — the existence filter for the focus, the
+ // same one the editor's status payload passes. Omitted only by a caller that
+ // genuinely has no list.
+ known: readonly string[] = [],
+): PriorityContext {
+ const model = settings.channelPriority;
+ // The paths go in the key so two worktrees' runners in one process cannot
+ // share a focus resolved against the other's sites directory.
+ const key = contextKey(model, paths, known);
+ const now = Date.now();
+ if (
+ priorityContext &&
+ priorityContext.key === key &&
+ now - priorityContext.at < PRIORITY_CONTEXT_TTL_MS
+ ) {
+ return priorityContext;
+ }
+ // ONLY A SITE FOCUS READS THE SITES DIRECTORY. `{kind:"channels"}` and
+ // `{kind:"none"}` resolve from the document alone, so the common case pays
+ // nothing. `siteChannelIndex` (lib/site.ts) is the ONE spelling of that read
+ // — the sync tick, the /channels writer and the status payload ask it too.
+ const index: SiteChannelIndex =
+ model.focus.kind === "site" ? siteChannelIndex(paths) : {};
+ priorityContext = {
+ key,
+ at: now,
+ model,
+ focusSlugs: resolveFocusSlugs(
+ model,
+ index,
+ known.length > 0 ? known : undefined,
+ ),
+ };
+ return priorityContext;
+}
+
+// THE ROOT THIS LANE ACTUALLY DISPATCHES FROM. One function, called by the
+// runner loop AND by computeLeafPending, so the status panel can never name a
+// leaf the runner does not have.
+//
+// `slugs` is the lane's own (already paused-filtered) channel list;
+// `compileLaneRoot` re-applies the same per-lane predicate, so handing it the
+// filtered list and handing it every slug produce the identical tree for THIS
+// lane. Compiling is pure and O(channels) — ~69 string pushes against the
+// ~6.5 MB of snapshot JSON the same tick folds — so it happens per tick and
+// only the focus resolution above is cached.
+export function laneDispatchRoot(
+ kind: AutoQueueKind,
+ policy: AutoQueuePolicy,
+ ctx: PriorityDispatchContext,
+ slugs: readonly string[],
+): AutoQueueGroup {
+ if (isDefaultChannelPriority(ctx.model)) return policy.root;
+ return compileLaneRoot(kind, ctx.model, slugs, ctx.focusSlugs);
+}
+
+// The once-per-state-change line a lane writes while a focus is holding it.
+//
+// NOT AN IDLE REASON, and deliberately not: a lane whose focus group holds the
+// rest is not idle, it is dispatching focus work — `AutoRunnerIdleReason` stays
+// exactly as it is, and `no-pending` remains the true answer when the whole
+// tree is empty. This is the runner's LOG saying which of three states it is
+// in, so "why is only jeralyzer moving?" is answerable from the job log alone.
+//
+// The state is the three-valued thing, NOT the counts: the counts are in the
+// message but never in the key, or every completed focus unit would re-fire the
+// line. Same shape as the snooze line and the disk-gate line above.
+export function focusHoldState(summary: FocusSummary | null): string {
+ if (!summary || !summary.active) return "none";
+ return summary.holding ? "hold" : "free";
+}
+
+export function focusHoldLine(
+ kind: AutoQueueKind,
+ summary: FocusSummary | null,
+): string | null {
+ if (!summary || !summary.active) return null;
+ const channels = `${summary.channelCount} channel${summary.channelCount === 1 ? "" : "s"}`;
+ if (summary.holding) {
+ return (
+ `Auto-${kind}: focus (${channels}) is holding this lane — ` +
+ `${summary.focusPending} focus unit(s) pending, ` +
+ `${summary.heldChannels} channel(s) held.`
+ );
+ }
+ return (
+ `Auto-${kind}: focus (${channels}) has no work left in this lane — ` +
+ `${summary.otherPending} unit(s) released to the rest of the corpus.`
+ );
+}
+
+// The gate itself, as a closure so the runner keeps one line per transition and
+// the test can drive the transitions without a corpus. Logs on entering "hold"
+// and on entering "free"; says nothing while there is no active focus.
+export function makeFocusHoldReporter(
+ kind: AutoQueueKind,
+ onLog: (line: string) => void,
+): (summary: FocusSummary | null) => void {
+ let state = "none";
+ return (summary) => {
+ const next = focusHoldState(summary);
+ if (next === state) return;
+ state = next;
+ const line = focusHoldLine(kind, summary);
+ if (line) onLog(line);
+ };
}
// ONCE PER STATE CHANGE, not once per tick. buildChannelWork runs on every
@@ -639,8 +876,21 @@ export async function computeLeafPending(
kind: AutoQueueKind,
paths: Paths = getPaths(),
): Promise<LeafPending> {
- const policy = getSettings().autoQueue[kind];
- const meta = await listChannelMeta(paths);
+ const settings = getSettings();
+ const policy = settings.autoQueue[kind];
+ // THE SAME TWO PRIORITY DECISIONS THE RUNNER MAKES, in the same order: the
+ // paused filter on the channel list, then the compiled root. This function's
+ // whole contract is that its numbers are the runner's numbers, so both sides
+ // of channel priority have to be here too — otherwise the panel would count
+ // pending work for a paused channel, or attribute it to a stored leaf the
+ // runner is not dispatching from.
+ const { meta, slugs } = await listChannelMeta(
+ paths,
+ kind,
+ settings.channelPriority,
+ );
+ const ctx = priorityContextFor(paths, settings, slugs);
+ const root = laneDispatchRoot(kind, policy, ctx, meta.map((m) => m.slug));
const laneOperations = laneOperationIds(kind);
const { channels, owner } = await buildChannelWork(
paths,
@@ -660,7 +910,7 @@ export async function computeLeafPending(
// leaf naming anything else finds no list and comes back empty — the zero
// retainLeaves used to apply afterwards, reached by construction.
const pending = buildPendingByLeaf(
- policy.root,
+ root,
channels,
defaultDrawsForPolicy(kind, policy, bucketLaneOperationId(kind)),
{ ...(compare ? { compare } : {}), defaultOperations: laneOperations },
@@ -697,14 +947,14 @@ export async function computeLeafPending(
currentWeights: { ...state[kind].runtime.currentWeights },
};
const pick = selectNextWork(
- policy.root,
+ root,
pending,
runtime,
live ? { ...live.active } : {},
);
let nextUp: NextUpView | null = null;
if (pick) {
- const order = flattenLeaves(policy.root);
+ const order = flattenLeaves(root);
const at = order.findIndex((l) => l.id === pick.leafId);
nextUp = {
videoId: pick.videoId,
@@ -869,13 +1119,19 @@ async function runLoop(
const childJobIds = new Map<string, string>();
let metaCache: ChannelMeta[] = [];
+ // Every channel slug, pre-paused-filter, on the same TTL as metaCache.
+ let knownSlugs: string[] = [];
let metaAt = 0;
+ // The root the last pick was made from. next() sets it every tick;
+ // runOperationPick reads it to resolve the leaf its own pick named.
+ let dispatchRoot: AutoQueueGroup = getSettings().autoQueue[kind].root;
// Whether the last next() saw the disk gate closed. next() runs on every
// scheduling tick, so without this the log fills with one identical line per
// tick for as long as the disk is full — which is precisely the situation in
// which the log needs to stay readable. Logged on each transition instead.
let diskIdle = false;
+ const reportFocusHold = makeFocusHoldReporter(kind, onLog);
// A graceful stop (the job record is gone after an e2e reset, or the policy was
// disabled) is modeled as a soft drain: stop picking, let in-flight finish.
@@ -1145,11 +1401,36 @@ async function runLoop(
}
// Refresh the (rarely-changing) channel list/platforms on a TTL.
+ //
+ // THE PAUSED FILTER RIDES THIS CLOCK. `listChannelMeta` drops every channel
+ // whose effective tier for THIS lane is `paused`, so the 30 s TTL is also
+ // how long a pause takes to reach dispatch. `metaAt === 0` rather than
+ // `metaCache.length === 0` is the cache-miss test now: a lane on which
+ // every channel is paused has a legitimately empty list, and the old
+ // sentinel would re-read 68 configs on every three-second tick for it.
+ //
+ // THE META REFRESH COMES FIRST, because the focus resolution needs the
+ // channel list it produces: `known` is what drops a focus slug the corpus
+ // no longer has, and resolving without it makes this lane's "M channels
+ // held" line disagree with the banner by one.
const now = Date.now();
- if (now - metaAt > CHANNEL_LIST_TTL_MS || metaCache.length === 0) {
- metaCache = await listChannelMeta(paths);
+ if (metaAt === 0 || now - metaAt > CHANNEL_LIST_TTL_MS) {
+ const listed = await listChannelMeta(
+ paths,
+ kind,
+ settings.channelPriority,
+ );
+ metaCache = listed.meta;
+ knownSlugs = listed.slugs;
metaAt = now;
}
+ const ctx = priorityContextFor(paths, settings, knownSlugs);
+ // THE COMPILED ROOT ENTERS HERE, and this is the only place it does for the
+ // dispatch path: everything below — the projection, the completed filter,
+ // the pick and the leaf lookup in runOperationPick — reads `dispatchRoot`,
+ // never `policy.root`. See laneDispatchRoot.
+ const root = laneDispatchRoot(kind, policy, ctx, metaCache.map((m) => m.slug));
+ dispatchRoot = root;
const slugToPlatform = new Map(
metaCache.map((m) => [m.slug, m.platform ?? "unknown"]),
);
@@ -1175,7 +1456,7 @@ async function runLoop(
// the dispatch path, so it is the one where getting it wrong runs the wrong
// engine on the wrong video.
const pending = buildPendingByLeaf(
- policy.root,
+ root,
channels,
defaultDrawsForPolicy(kind, policy, bucketLaneOperationId(kind)),
{ ...(compare ? { compare } : {}), defaultOperations: laneOperations },
@@ -1185,8 +1466,16 @@ async function runLoop(
// also how the dependency order is kept — diarization finishes before
// attribution-diarized is offered the same video.
removeIds(pending, new Set(live.inFlight.keys()));
- dropCompleted(pending, policy.root, laneOperations);
+ dropCompleted(pending, root, laneOperations);
const pendingBeforeGates = countPending(pending);
+ // Say — ONCE per transition — whether a focus is holding this lane. Read off
+ // the `prio-*` leaf ids in the map just built, so it costs one pass over
+ // keys and no new read, and it is skipped entirely while no focus resolves.
+ reportFocusHold(
+ ctx.focusSlugs.length > 0
+ ? focusSummary(ctx.model, ctx.focusSlugs, pending)
+ : null,
+ );
// THE RUNNER JOB'S OWN BAR, on the metrics the per-channel jobs already
// use — so a lane's runner row reads like the manual verb's row rather than
// like an opaque long-lived loop. `target` moves as the corpus does (this
@@ -1237,7 +1526,7 @@ async function runLoop(
}
}
- const pick = selectNextWork(policy.root, pending, runtime, live.active);
+ const pick = selectNextWork(root, pending, runtime, live.active);
if (!pick) {
// Attribute the idleness. Nothing pending at all is a different situation
// from work that exists but is unreachable, and both differ from work the
@@ -1307,8 +1596,10 @@ async function runLoop(
const laneOperations = laneOperationIds(kind);
const opened = laneRun.run;
if (!opened) return { outcome: "skipped" };
- const policy = getSettings().autoQueue[kind];
- const leaf = flattenLeaves(policy.root).find(
+ // THE ROOT THE PICK CAME FROM, not the stored one: a compiled leaf id
+ // (`prio-<tier>-<slug>`) does not exist in the stored tree, and looking it
+ // up there would silently fall back to the lane's whole operation union.
+ const leaf = flattenLeaves(dispatchRoot).find(
(l) => l.id === picked.pick.leafId,
);
const wanted = new Set(
diff --git a/common/controller/backfillReacquire.test.ts b/common/controller/backfillReacquire.test.ts
@@ -16,6 +16,11 @@ import {
VTT_FILENAME,
} from "../lib/videoStatus";
import type { ChannelConfig } from "../lib/channelConfig";
+import {
+ compileLaneRoot,
+ defaultChannelPriority,
+ sanitizeChannelPriority,
+} from "../lib/channelPriority";
// Run with:
// pnpm --filter yt-dlp-transcript-common exec tsx --test common/controller/backfillReacquire.test.ts
@@ -175,6 +180,9 @@ function keepInput(over: Partial<KeepInput> = {}): KeepInput {
snoozeUntil: null,
root: ALL_LEAF,
},
+ // No channel priority set: the stored tree stands, byte for byte, which is
+ // every case below except the two that name a tier.
+ priority: defaultChannelPriority(),
channel: { slug: "the-channel", platform: "youtube" },
disk: { freeBytes: 100 * GB, minFreeDiskGB: 20, resumeMarginGB: 5 },
...over,
@@ -357,3 +365,100 @@ test("decideKeep never mutates its input", () => {
decideKeep(input);
assert.equal(JSON.stringify(input), before);
});
+
+// --- A HOLD IS NEVER A STOP, and this is where it would have become one -----
+//
+// The bug this closes (the S0/S1 review, finding 1): once the priority model
+// says anything, `autoQueue.transcription.root` is COMPILED from it and a
+// channel paused for transcription has no leaf in that tree. Decided on the
+// compiled tree with no pause branch, every such channel answers "no-leaf" and
+// every re-acquired audio file it produces is UNLINKED — a pause turning into
+// data loss. Decided on the STORED tree it is just as wrong the other way: the
+// stored tree is not what the runner dispatches from.
+
+test("decideKeep: a channel paused for transcription KEEPS its audio", () => {
+ const priority = sanitizeChannelPriority({
+ focus: { kind: "none" },
+ channels: { "the-channel": { tier: "paused" } },
+ });
+ assert.deepEqual(decideKeep(keepInput({ priority })), {
+ keep: true,
+ reason: "paused",
+ });
+ // The compiled tree really does omit it — this is the answer the naive
+ // reading would have produced, and it is why the pause is asked first.
+ assert.equal(
+ JSON.stringify(
+ compileLaneRoot("transcription", priority, ["the-channel"], []),
+ ).includes("the-channel"),
+ false,
+ );
+});
+
+test("decideKeep: a PER-OPERATION pause is per operation", () => {
+ // What all 15 live `excludeFromSync` channels migrate to: stop syncing, keep
+ // everything else. The transcription lane is untouched, so this is an
+ // ordinary hand-off and not a keep-for-a-hold.
+ const syncPaused = sanitizeChannelPriority({
+ focus: { kind: "none" },
+ channels: { "the-channel": { tier: "normal", overrides: { sync: "paused" } } },
+ });
+ assert.deepEqual(decideKeep(keepInput({ priority: syncPaused })), {
+ keep: true,
+ reason: "hand-off",
+ });
+ // And the inverse: paused for DOWNLOAD only leaves transcription running too.
+ const downloadPaused = sanitizeChannelPriority({
+ focus: { kind: "none" },
+ channels: {
+ "the-channel": { tier: "normal", overrides: { download: "paused" } },
+ },
+ });
+ assert.deepEqual(decideKeep(keepInput({ priority: downloadPaused })), {
+ keep: true,
+ reason: "hand-off",
+ });
+});
+
+test("decideKeep: a non-paused channel is decided on the COMPILED tree", () => {
+ // A model that says something, and a stored root that says nothing at all.
+ // The stored tree would answer "no leaf covers this channel"; the compiled
+ // one gives every non-paused channel a bare leaf, and with replaceAutoSubs
+ // on that leaf draws the opt-in bucket. The hand-off is the right answer
+ // because the compiled tree is the one the runner dispatches from.
+ const priority = sanitizeChannelPriority({
+ focus: { kind: "none" },
+ channels: { "someone-else": { tier: "paused" } },
+ });
+ const d = decideKeep(
+ keepInput({
+ priority,
+ policy: {
+ enabled: true,
+ replaceAutoSubs: true,
+ snoozeUntil: null,
+ root: { id: "root", mode: "strict", children: [] },
+ },
+ }),
+ );
+ assert.deepEqual(d, { keep: true, reason: "hand-off" });
+});
+
+test("decideKeep: the pause does not outrank a run in progress or a missing file", () => {
+ const priority = sanitizeChannelPriority({
+ focus: { kind: "none" },
+ channels: { "the-channel": { tier: "paused" } },
+ });
+ // Nothing landed: there is no audio to hold for anyone.
+ assert.deepEqual(decideKeep(keepInput({ priority, hasAudio: false })), {
+ keep: false,
+ reason: "no-audio",
+ });
+ // And a paused channel whose captions are not ASR-only is not this bucket's
+ // shape either — the pause keeps audio the lane would otherwise draw, not
+ // audio no leaf of any tier would ever touch.
+ assert.deepEqual(decideKeep(keepInput({ priority, autoSubsOnly: false })), {
+ keep: false,
+ reason: "not-auto-subs-only",
+ });
+});
diff --git a/common/controller/backfillReacquire.ts b/common/controller/backfillReacquire.ts
@@ -72,6 +72,12 @@ import type { ChannelConfig } from "../lib/channelConfig";
import { detectPlatform, type Platform } from "../lib/platform";
import { isAutoSubsOnly } from "../lib/subtitleProvenance";
import {
+ compileLaneRoot,
+ isChannelPaused,
+ isDefaultChannelPriority,
+ type ChannelPriority,
+} from "../lib/channelPriority";
+import {
policyDrawsBucket,
type AutoQueuePolicy,
} from "../jobs/autoQueuePolicy";
@@ -93,6 +99,9 @@ export type KeepReason =
| "do-not-clean"
// A transcription task is running on this video right now.
| "in-flight"
+ // The channel is PAUSED for transcription. A hold is never a stop: the audio
+ // is kept for the pause to end, not deleted because it started.
+ | "paused"
// autoQueue.transcription would draw this video from downloadedAutoSubsOnly.
| "hand-off";
@@ -335,6 +344,9 @@ export type KeepInput = {
AutoQueuePolicy,
"enabled" | "replaceAutoSubs" | "snoozeUntil" | "root"
>;
+ // The channel-priority document. Two questions are asked of it, and both are
+ // about the tree the runner would ACTUALLY dispatch from — see decideKeep.
+ priority: ChannelPriority;
channel: { slug: string; platform: Platform | null };
disk: { freeBytes: number; minFreeDiskGB: number; resumeMarginGB: number };
};
@@ -351,9 +363,28 @@ export type KeepInput = {
// !policy.enabled the runner is off; audio kept for it would sit forever
// snoozeUntil idem, temporarily (a lapsed snooze is already normalized
// to null by sanitizePolicy, so non-null means still on)
+// paused the CHANNEL is held for transcription — KEEP (below)
// !policyDrawsBucket no leaf covering this channel draws downloadedAutoSubsOnly
// disk the runner itself would refuse to fetch this much
//
+// CHANNEL PRIORITY ENTERS TWICE, AND THE FIRST ONE IS WHY. Once the priority
+// model says anything, `autoQueue.transcription.root` is COMPILED from it
+// (lib/channelPriority.ts) and a channel paused for transcription has NO LEAF
+// in that tree at all. Asked naively, `policyDrawsBucket` would then answer
+// "no leaf" for exactly the channels the operator just put on hold, and this
+// function would UNLINK their audio — a pause turning into data loss, silently,
+// one re-acquired file at a time. So the pause is asked FIRST and it KEEPS:
+// a hold is never a stop (controller/operationBatch.ts:22-25), and audio kept
+// for a paused lane is audio waiting for the pause to end.
+//
+// The second is the tree itself: while a model exists the runner does not
+// dispatch from the STORED root, so neither may this decision. The compiled
+// root for this one channel is the same answer the real compiled tree gives —
+// every non-paused channel gets one bare leaf plus the trailing catch-all, and
+// WHICH tier group holds it changes the order, never whether a leaf draws the
+// bucket — so the compile is done with this slug alone rather than by listing
+// 68 configs inside a cleanup.
+//
// The disk bar is the RESUME mark (floor + margin), not the floor: a hand-off is
// a download the backfill was about to give back, and the download runner resumes
// only at resumeBytes — so the backfill must never keep audio the runner would
@@ -370,10 +401,21 @@ export function decideKeep(input: KeepInput): KeepDecision {
if (input.policy.snoozeUntil != null) {
return { keep: false, reason: "policy-snoozed" };
}
+ if (isChannelPaused(input.priority, input.channel.slug, "transcription")) {
+ return { keep: true, reason: "paused" };
+ }
+ const root = isDefaultChannelPriority(input.priority)
+ ? input.policy.root
+ : compileLaneRoot(
+ "transcription",
+ input.priority,
+ [input.channel.slug],
+ [],
+ );
if (
!policyDrawsBucket(
"transcription",
- input.policy,
+ { ...input.policy, root },
input.channel,
"downloadedAutoSubsOnly",
)
@@ -430,6 +472,7 @@ function buildCleanup(
hasAudio: files.audioFiles.length > 0,
autoSubsOnly: await isAutoSubsOnly(videoDir, files),
policy: settings.autoQueue.transcription,
+ priority: settings.channelPriority,
channel: { slug: ctx.channelSlug, platform: ctx.platform },
disk: {
freeBytes: await getFreeBytes(ctx.paths.transcriptsDir),
@@ -447,6 +490,10 @@ function buildCleanup(
log(
`Keeping re-acquired media for ${videoId}: a transcription is running on it (${list}).`,
);
+ } else if (decision.reason === "paused") {
+ log(
+ `Keeping re-acquired media for ${videoId}: ${ctx.channelSlug} is paused for transcription, and a hold is not a stop (${list}).`,
+ );
} else {
log(
`Keeping re-acquired media for ${videoId}: handed to auto-transcribe, which will replace the auto-captions (${list}).`,
diff --git a/common/controller/fetchPosts.ts b/common/controller/fetchPosts.ts
@@ -177,7 +177,9 @@ export async function fetchPosts(
// Reuse the existing lastSyncedAt field so the scheduler
// (common/jobs/syncScheduler.ts) paces social channels with zero changes —
- // it keys only off url / excludeFromSync / syncIntervalMinutes / lastSyncedAt.
+ // it keys only off url / syncIntervalMinutes / lastSyncedAt, plus the
+ // channel-priority document's `sync` tier (`isChannelPaused(model, slug,
+ // "sync")`), which is where the retired `excludeFromSync` flag went.
await writeChannelConfig(paths, slug, {
...config,
lastSyncedAt: new Date().toISOString(),
diff --git a/common/jobs/channelPriorityCompile.test.ts b/common/jobs/channelPriorityCompile.test.ts
@@ -0,0 +1,275 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ channelsForOperation,
+ compileLaneRoot,
+ focusSummary,
+ isChannelPaused,
+ prioLeafId,
+ sanitizeChannelPriority,
+} from "../lib/channelPriority";
+import type { ChannelPriority } from "../lib/channelPriority";
+import {
+ type AutoQueueGroup,
+ type ChannelWork,
+ buildPendingByLeaf,
+ emptyAutoQueueRuntime,
+ selectNextWork,
+} from "./autoQueuePolicy";
+
+// THE DESIGN, ASSERTED THROUGH THE REAL ENGINE.
+//
+// `plans/channel-priority.md`'s decision is COMPILE, not consult: the model
+// becomes an ordinary `AutoQueueGroup` and dispatch runs the engine it always
+// ran. So the claim "a focus holds the rest until its work is done" is not a
+// claim about `channelPriority.ts` at all — it is a claim about what
+// `buildPendingByLeaf` + `selectNextWork` do to a COMPILED tree, and it can
+// only be tested by driving both.
+//
+// IN jobs/, NOT lib/, for the reason `laneMigration.test.ts` is: this file must
+// import `jobs/autoQueuePolicy`, and `../architecture.test.ts` forbids
+// `lib/ -> jobs/` from a test file like any other. It must also stay out of the
+// controller layer's reach — `jobs/ -> controller/` is forbidden too — which is
+// why the runner-side halves (the `listChannelMeta` filter as the runner calls
+// it, and the once-per-transition log line) are asserted in
+// `controller/autoRunner.test.ts` instead.
+//
+// The sanitizer round-trip half lives in `channelPrioritySanitize.test.ts` (S0).
+
+// One bucket, spelled once. Which bucket a lane draws is `defaultDrawsForPolicy`'s
+// business and autoQueuePolicy.test.ts's; nothing here depends on the name.
+const BUCKET = "downloadedNoTranscript";
+
+function work(slug: string, ids: string[]): ChannelWork {
+ return { slug, platform: "youtube", buckets: { [BUCKET]: [...ids] } };
+}
+
+function model(value: unknown): ChannelPriority {
+ return sanitizeChannelPriority(value);
+}
+
+// EXACTLY WHAT THE RUNNER DOES, in the runner's order: `listChannelMeta` filters
+// the channel list by the lane's effective tier, then `laneDispatchRoot`
+// compiles the root from what survived. Both halves are here so a test that
+// says "paused is never drawn" is testing the pair the runner actually runs,
+// catch-all included.
+function dispatch(
+ lane: "transcription" | "download" | "digest" | "backfill",
+ priority: ChannelPriority,
+ channels: ChannelWork[],
+ focusSlugs: readonly string[] = [],
+): { root: AutoQueueGroup; channels: ChannelWork[] } {
+ const slugs = channelsForOperation(
+ priority,
+ channels.map((c) => c.slug),
+ lane,
+ );
+ const live = channels.filter((c) => slugs.includes(c.slug));
+ return {
+ root: compileLaneRoot(lane, priority, slugs, focusSlugs),
+ channels: live,
+ };
+}
+
+// Drain a compiled tree the way the runner does: re-ask the policy after every
+// grant, consuming the chosen id. Returns the owning channel of each pick, in
+// served order — the focus question is "whose video came next", not "which
+// leaf".
+function drainOwners(
+ root: AutoQueueGroup,
+ pending: Record<string, string[]>,
+ max = 100,
+): string[] {
+ const runtime = emptyAutoQueueRuntime();
+ const owners: string[] = [];
+ for (let i = 0; i < max; i++) {
+ const pick = selectNextWork(root, pending, runtime);
+ if (!pick) break;
+ owners.push(pick.leafId);
+ const ids = pending[pick.leafId];
+ assert.equal(ids[0], pick.videoId, "pick should be head of leaf queue");
+ ids.shift();
+ }
+ return owners;
+}
+
+test("a focus holds a non-focus channel with work, then releases it when the focus is exhausted", () => {
+ const priority = model({ focus: { kind: "channels", slugs: ["slow-a"] } });
+ const channels = [work("slow-a", ["a1", "a2"]), work("slow-b", ["b1", "b2"])];
+ const { root, channels: live } = dispatch(
+ "transcription",
+ priority,
+ channels,
+ ["slow-a"],
+ );
+ const pending = buildPendingByLeaf(root, live, [BUCKET]);
+
+ // Both channels have work RIGHT NOW — the hold is not "b has nothing".
+ assert.deepEqual(pending[prioLeafId("focus", "slow-a")], ["a1", "a2"]);
+ assert.deepEqual(pending[prioLeafId("normal", "slow-b")], ["b1", "b2"]);
+
+ // Strict descent serves every one of A's before it ever reaches B's group.
+ assert.deepEqual(drainOwners(root, pending), [
+ prioLeafId("focus", "slow-a"),
+ prioLeafId("focus", "slow-a"),
+ prioLeafId("normal", "slow-b"),
+ prioLeafId("normal", "slow-b"),
+ ]);
+});
+
+test("new focus work retakes the lane on the very next pick", () => {
+ const priority = model({ focus: { kind: "channels", slugs: ["slow-a"] } });
+ const channels = [work("slow-a", []), work("slow-b", ["b1", "b2"])];
+ const { root, channels: live } = dispatch(
+ "transcription",
+ priority,
+ channels,
+ ["slow-a"],
+ );
+ const pending = buildPendingByLeaf(root, live, [BUCKET]);
+ const runtime = emptyAutoQueueRuntime();
+
+ // Mid-"slow-b batch": the focus group is empty, so strict descent skips it.
+ let pick = selectNextWork(root, pending, runtime);
+ assert.equal(pick?.leafId, prioLeafId("normal", "slow-b"));
+ pending[pick!.leafId].shift();
+
+ // A snapshot regen gives the focus channel a video. The runner rebuilds
+ // `pending` every tick and re-asks, so the very next pick is the focus's —
+ // no second mechanism, no preemption of the unit already running.
+ pending[prioLeafId("focus", "slow-a")].push("a1");
+ pick = selectNextWork(root, pending, runtime);
+ assert.equal(pick?.leafId, prioLeafId("focus", "slow-a"));
+});
+
+test("a focus that resolves to nothing compiles no focus group and holds no one", () => {
+ // An unknown siteId is the live case: `resolveFocusSlugs` answers [], so a
+ // typo must leave the tree exactly as it would be with no focus at all.
+ const priority = model({ focus: { kind: "site", siteId: "no-such-site" } });
+ const channels = [work("slow-a", ["a1"]), work("slow-b", ["b1"])];
+ const { root, channels: live } = dispatch(
+ "transcription",
+ priority,
+ channels,
+ [],
+ );
+ assert.equal(
+ root.children.some((c) => c.id === "prio-focus"),
+ false,
+ );
+ const pending = buildPendingByLeaf(root, live, [BUCKET]);
+ assert.deepEqual(drainOwners(root, pending).sort(), [
+ prioLeafId("normal", "slow-a"),
+ prioLeafId("normal", "slow-b"),
+ ]);
+});
+
+test("a paused channel is drawn by no leaf, the catch-all included", () => {
+ const priority = model({ channels: { gone: { tier: "paused" } } });
+ const channels = [work("kept", ["k1"]), work("gone", ["g1", "g2"])];
+ const { root, channels: live } = dispatch("download", priority, channels);
+
+ // The FILTER is what does it: a tree cannot express exclusion, so the paused
+ // channel never reaches the projection at all.
+ assert.deepEqual(
+ live.map((c) => c.slug),
+ ["kept"],
+ );
+ const pending = buildPendingByLeaf(root, live, [BUCKET]);
+ const everyId = Object.values(pending).flat();
+ assert.deepEqual(everyId, ["k1"]);
+ assert.deepEqual(pending["prio-all"], []);
+ assert.equal(
+ Object.keys(pending).includes(prioLeafId("paused", "gone")),
+ false,
+ );
+});
+
+test("sync-only pause leaves the download lane drawing the channel", () => {
+ // What all 15 live `excludeFromSync` channels migrate to. The base tier and
+ // the rank stand; only the `sync` operation loses the channel, so no lane's
+ // membership moves — which is what makes that migration lossless.
+ const priority = model({
+ channels: {
+ omnivods: { tier: "normal", rank: 7, overrides: { sync: "paused" } },
+ },
+ });
+ assert.equal(isChannelPaused(priority, "omnivods", "sync"), true);
+ assert.equal(isChannelPaused(priority, "omnivods", "download"), false);
+
+ const channels = [work("omnivods", ["o1"])];
+ const { root, channels: live } = dispatch("download", priority, channels);
+ const pending = buildPendingByLeaf(root, live, [BUCKET]);
+ assert.deepEqual(pending[prioLeafId("normal", "omnivods")], ["o1"]);
+ assert.deepEqual(channelsForOperation(priority, ["omnivods"], "sync"), []);
+});
+
+test("a per-lane override pauses one lane and leaves the others drawing", () => {
+ const priority = model({
+ channels: { noisy: { tier: "normal", overrides: { download: "paused" } } },
+ });
+ const channels = [work("noisy", ["n1"])];
+
+ const dl = dispatch("download", priority, channels);
+ assert.deepEqual(dl.channels, []);
+ assert.deepEqual(
+ Object.values(buildPendingByLeaf(dl.root, dl.channels, [BUCKET])).flat(),
+ [],
+ );
+
+ const tr = dispatch("transcription", priority, channels);
+ const pending = buildPendingByLeaf(tr.root, tr.channels, [BUCKET]);
+ assert.deepEqual(pending[prioLeafId("normal", "noisy")], ["n1"]);
+});
+
+test("a focused channel paused for that lane is not drawn by it", () => {
+ // Focus wins over the stored tier; paused wins over focus, per operation.
+ const priority = model({
+ focus: { kind: "channels", slugs: ["star"] },
+ channels: { star: { tier: "normal", overrides: { digest: "paused" } } },
+ });
+ const channels = [work("star", ["s1"]), work("other", ["o1"])];
+
+ const digest = dispatch("digest", priority, channels, ["star"]);
+ assert.deepEqual(
+ digest.channels.map((c) => c.slug),
+ ["other"],
+ );
+ assert.equal(
+ digest.root.children.some((c) => c.id === "prio-focus"),
+ false,
+ );
+
+ const backfill = dispatch("backfill", priority, channels, ["star"]);
+ const pending = buildPendingByLeaf(backfill.root, backfill.channels, [
+ BUCKET,
+ ]);
+ assert.deepEqual(pending[prioLeafId("focus", "star")], ["s1"]);
+});
+
+test("focusSummary reads the hold off the same pending map the pick used", () => {
+ const priority = model({ focus: { kind: "channels", slugs: ["slow-a"] } });
+ const channels = [work("slow-a", ["a1"]), work("slow-b", ["b1", "b2"])];
+ const { root, channels: live } = dispatch(
+ "digest",
+ priority,
+ channels,
+ ["slow-a"],
+ );
+ const pending = buildPendingByLeaf(root, live, [BUCKET]);
+
+ const holding = focusSummary(priority, ["slow-a"], pending);
+ assert.equal(holding.active, true);
+ assert.equal(holding.holding, true);
+ assert.equal(holding.focusPending, 1);
+ assert.equal(holding.otherPending, 2);
+ assert.equal(holding.heldChannels, 1);
+
+ // Drain the focus's only unit: the lane is released, and the summary says so
+ // off the same map — no second read, no new clock.
+ pending[prioLeafId("focus", "slow-a")].shift();
+ const released = focusSummary(priority, ["slow-a"], pending);
+ assert.equal(released.holding, false);
+ assert.equal(released.heldChannels, 0);
+ assert.equal(released.otherPending, 2);
+});
diff --git a/common/jobs/channelPrioritySanitize.test.ts b/common/jobs/channelPrioritySanitize.test.ts
@@ -0,0 +1,101 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ compileLaneRoot,
+ compileLanes,
+ sanitizeChannelPriority,
+} from "../lib/channelPriority";
+import { LANES, isGroup } from "../lib/autoQueueTypes";
+import type { AutoQueueNode } from "../lib/autoQueueTypes";
+import { sanitizeAutoQueue } from "./autoQueuePolicy";
+
+// IN jobs/, NOT lib/, for the reason `laneMigration.test.ts` is: the property
+// being asserted is that `sanitizeAutoQueue` is the IDENTITY on a compiled
+// tree, and ../architecture.test.ts forbids `lib/ -> jobs/` — including from a
+// test file, and its ALLOWED list may only shrink.
+//
+// WHY IT MATTERS. The compiler writes into `autoQueue[lane].root`, and every
+// read of settings.json puts that back through `sanitizeAutoQueue`. If the
+// sanitizer touched anything the compiler emits — reassigned an id (leaf ids
+// key the lane's fairness memory and its pick log), defaulted a weight, coerced
+// a mode — then the tree an operator compiled and the tree the runner walks
+// would be two different trees, and the difference would show up as a silently
+// reset ledger rather than as an error. That is why every node the compiler
+// emits already spells `weight: 1` and `maxWorkers: null`.
+//
+// The focus/hold/release proof through the real engine (buildPendingByLeaf +
+// selectNextWork) is slice S1's `channelPriorityCompile.test.ts`.
+
+function eachNode(node: AutoQueueNode, fn: (n: AutoQueueNode) => void): void {
+ fn(node);
+ if (isGroup(node)) for (const child of node.children) eachNode(child, fn);
+}
+
+test("sanitizeAutoQueue is the identity on a compiled tree", () => {
+ const model = sanitizeChannelPriority({
+ channels: {
+ lowly: { tier: "low" },
+ gone: { tier: "paused" },
+ first: { tier: "normal", rank: 0 },
+ // A per-operation override, so the four lanes are NOT all the same tree.
+ nodl: { tier: "normal", overrides: { download: "paused" } },
+ },
+ });
+ const slugs = ["first", "lowly", "gone", "other", "focused", "nodl"];
+ const roots = compileLanes(model, slugs, ["focused"]);
+ const before = {
+ transcription: { enabled: true, root: roots.transcription },
+ download: { enabled: false, root: roots.download },
+ digest: { enabled: false, root: roots.digest },
+ backfill: { enabled: false, root: roots.backfill },
+ };
+ const after = sanitizeAutoQueue(before);
+ for (const lane of LANES) {
+ assert.deepEqual(after[lane].root, roots[lane], `${lane} root changed`);
+ }
+ // And through JSON, which is what settings.json actually does to it.
+ const roundTripped = sanitizeAutoQueue(JSON.parse(JSON.stringify(before)));
+ for (const lane of LANES) {
+ assert.deepEqual(roundTripped[lane].root, roots[lane], `${lane} via JSON`);
+ }
+ // The override really did split the lanes, so the assertion above was not
+ // four copies of one comparison.
+ assert.notDeepEqual(
+ { ...after.download.root, id: "x" },
+ { ...after.transcription.root, id: "x" },
+ );
+});
+
+test("no compiled id is reassigned by the sanitizer", () => {
+ const model = sanitizeChannelPriority({
+ channels: { l: { tier: "low" }, p: { tier: "paused" } },
+ });
+ const root = compileLaneRoot("transcription", model, ["a", "b", "l", "p"], [
+ "a",
+ ]);
+ const ids: string[] = [];
+ eachNode(root, (n) => ids.push(n.id));
+ const after = sanitizeAutoQueue({ transcription: { root } }).transcription
+ .root;
+ const afterIds: string[] = [];
+ eachNode(after, (n) => afterIds.push(n.id));
+ assert.deepEqual(afterIds, ids);
+ // Every id is stable and derived — `node-<n>` would mean the sanitizer had
+ // to invent one, which is exactly the fairness-memory reset this guards.
+ assert.ok(!afterIds.some((id) => id.startsWith("node-")), afterIds.join(","));
+});
+
+test("an empty model still compiles to a tree the sanitizer accepts", () => {
+ const roots = compileLanes(sanitizeChannelPriority(undefined), [], []);
+ for (const lane of LANES) {
+ // No channels at all: just the catch-all net.
+ assert.deepEqual(
+ roots[lane].children.map((c) => c.id),
+ ["prio-all"],
+ );
+ assert.deepEqual(
+ sanitizeAutoQueue({ [lane]: { root: roots[lane] } })[lane].root,
+ roots[lane],
+ );
+ }
+});
diff --git a/common/jobs/syncScheduler.test.ts b/common/jobs/syncScheduler.test.ts
@@ -0,0 +1,262 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { parseChannelConfig, type ChannelConfig } from "../lib/channelConfig";
+import {
+ sanitizeChannelPriority,
+ type ChannelPriority,
+} from "../lib/channelPriority";
+import { defaultSyncScheduler } from "../lib/settings";
+import type { SyncSchedulerSettings } from "../lib/settings";
+import { emptySchedulerState } from "./syncSchedulerState";
+import {
+ buildScheduleView,
+ selectDueChannels,
+ type ChannelEntry,
+} from "./syncScheduler";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common exec tsx --test jobs/syncScheduler.test.ts
+//
+// THE SYNC HALF OF plans/channel-priority.md (S2). `selectDueChannels` and
+// `buildScheduleView` are the two places the model reaches the cron sync, and
+// the pair must agree: the console cannot show a channel as eligible that the
+// scheduler will not schedule.
+//
+// Everything here is pure — no server, no settings file. The model is built
+// through `sanitizeChannelPriority` rather than by hand so a test can only
+// assert over a document the writer could actually produce.
+
+const HOUR = 60 * 60_000;
+const DAY = 24 * HOUR;
+const NOW = Date.parse("2026-09-11T12:00:00.000Z");
+
+function scheduler(
+ over: Partial<SyncSchedulerSettings> = {},
+): SyncSchedulerSettings {
+ return {
+ ...defaultSyncScheduler(),
+ enabled: true,
+ defaultIntervalMinutes: 60,
+ quietHoursStart: null,
+ quietHoursEnd: null,
+ ...over,
+ };
+}
+
+// A channel that IS due: last synced `overdueMs` past its 60-minute interval.
+function channel(
+ slug: string,
+ overdueMs: number,
+ over: Partial<ChannelConfig> = {},
+): ChannelEntry {
+ return {
+ slug,
+ config: {
+ handling: "youtube",
+ name: slug,
+ url: `https://www.youtube.com/@${slug}/videos`,
+ lastSyncedAt: new Date(NOW - HOUR - overdueMs).toISOString(),
+ ...over,
+ },
+ };
+}
+
+function model(doc: unknown): ChannelPriority {
+ return sanitizeChannelPriority(doc);
+}
+
+const NO_MODEL = sanitizeChannelPriority(undefined);
+
+function due(
+ channels: ChannelEntry[],
+ priority: ChannelPriority = NO_MODEL,
+ focusSlugs: string[] = [],
+) {
+ return selectDueChannels({
+ channels,
+ scheduler: scheduler(),
+ state: emptySchedulerState(),
+ activeSlugs: new Set<string>(),
+ now: NOW,
+ priority,
+ focusSlugs,
+ });
+}
+
+test("with no priority document the order is most-overdue-first, as before", () => {
+ const r = due([
+ channel("a", 1 * 60_000),
+ channel("b", 1 * DAY),
+ channel("c", 1 * HOUR),
+ ]);
+ assert.deepEqual(r.due, ["b", "c", "a"]);
+ assert.deepEqual(r.skipped, []);
+});
+
+test("a focus channel one minute overdue outranks a low channel a day overdue", () => {
+ // Plan test 6. The concurrency cap slices this list from the front, so the
+ // ordering IS the policy: whoever is first gets the tick's slots.
+ const r = due(
+ [channel("low-one", 1 * DAY), channel("focused", 1 * 60_000)],
+ model({ channels: { "low-one": { tier: "low" } } }),
+ ["focused"],
+ );
+ assert.deepEqual(r.due, ["focused", "low-one"]);
+});
+
+test("tier orders before overdue, and rank orders inside a tier", () => {
+ const r = due(
+ [
+ channel("low-stale", 10 * DAY),
+ channel("normal-fresh", 1 * 60_000),
+ channel("focus-rank-2", 1 * 60_000),
+ channel("focus-rank-1", 1 * 60_000),
+ channel("focus-unranked", 5 * DAY),
+ ],
+ model({
+ channels: {
+ "low-stale": { tier: "low" },
+ "focus-rank-1": { tier: "normal", rank: 1 },
+ "focus-rank-2": { tier: "normal", rank: 2 },
+ },
+ }),
+ ["focus-rank-1", "focus-rank-2", "focus-unranked"],
+ );
+ // focus (rank 1, rank 2, then unranked however stale) -> normal -> low.
+ assert.deepEqual(r.due, [
+ "focus-rank-1",
+ "focus-rank-2",
+ "focus-unranked",
+ "normal-fresh",
+ "low-stale",
+ ]);
+});
+
+test("most-overdue-first survives WITHIN a tier", () => {
+ const r = due(
+ [channel("n-fresh", 1 * 60_000), channel("n-stale", 1 * DAY)],
+ model({ channels: {} }),
+ );
+ assert.deepEqual(r.due, ["n-stale", "n-fresh"]);
+});
+
+test("ties all the way down keep the input order", () => {
+ // `listChannelConfigs` hands this function slug order and the sort is stable,
+ // so the pre-model tiebreak (alphabetical) is preserved by not touching it.
+ const r = due([channel("b", 1 * HOUR), channel("a", 1 * HOUR)]);
+ assert.deepEqual(r.due, ["b", "a"]);
+});
+
+test("a sync override of paused skips while the base tier stays normal", () => {
+ // The migrated `excludeFromSync` shape: stop syncing, keep every other lane.
+ const priority = model({
+ channels: { parked: { tier: "normal", overrides: { sync: "paused" } } },
+ });
+ assert.equal(priority.channels.parked.tier, "normal");
+ const r = due([channel("parked", 1 * DAY), channel("live", 1 * HOUR)], priority);
+ assert.deepEqual(r.due, ["live"]);
+ // Silently, like every other configuration gate in the loop — the run log is
+ // for holds worth reading (backoff, already running), not for "this channel
+ // does not auto-sync".
+ assert.deepEqual(r.skipped, []);
+});
+
+test("a base-paused channel with a sync override of normal still syncs", () => {
+ // The "sync only" preset (the operator's omnibased case): paused everywhere
+ // the auto lanes look, still keeping its playlist and metadata current.
+ const priority = model({
+ channels: { omnibased: { tier: "paused", overrides: { sync: "normal" } } },
+ });
+ assert.equal(priority.channels.omnibased.overrides?.sync, "normal");
+ const r = due([channel("omnibased", 1 * HOUR)], priority);
+ assert.deepEqual(r.due, ["omnibased"]);
+});
+
+test("a base-paused channel with no sync override never becomes due", () => {
+ const r = due(
+ [channel("off", 10 * DAY)],
+ model({ channels: { off: { tier: "paused" } } }),
+ );
+ assert.deepEqual(r.due, []);
+ assert.deepEqual(r.skipped, []);
+});
+
+test("focus does not rescue a channel paused for sync", () => {
+ // Focus wins over the stored tier; paused wins over focus, per operation.
+ const r = due(
+ [channel("held", 1 * DAY), channel("other", 1 * HOUR)],
+ model({ channels: { held: { tier: "paused" } } }),
+ ["held"],
+ );
+ assert.deepEqual(r.due, ["other"]);
+});
+
+test("an un-migrated config carries nothing the scheduler can skip on", () => {
+ // `excludeFromSync` is DELETED (S5), and `parseChannelConfig` is allow-list
+ // style, so a config.json that still spells the key parses to a channel that
+ // says nothing about sync at all. That is the correct reading: the migration
+ // is what turns the flag into `overrides: {sync:"paused"}`, and until it has
+ // run the channel syncs — which is what it did before the flag was invented.
+ const parsed = parseChannelConfig({
+ handling: "youtube",
+ url: "https://example.com/c",
+ excludeFromSync: true,
+ lastSyncedAt: new Date(NOW - HOUR - 1 * DAY).toISOString(),
+ });
+ assert.ok(parsed);
+ assert.equal("excludeFromSync" in parsed, false);
+ const r = due([{ slug: "stale", config: parsed }], model({ channels: {} }));
+ assert.deepEqual(r.due, ["stale"]);
+ assert.deepEqual(r.skipped, []);
+});
+
+test("the schedule projection agrees with the scheduler about who is skipped", () => {
+ const channels = [
+ channel("normal", 1 * HOUR),
+ channel("sync-paused", 1 * DAY),
+ channel("base-paused", 1 * DAY),
+ channel("sync-only", 1 * HOUR),
+ channel("no-url", 1 * DAY, { url: undefined }),
+ channel("interval-off", 1 * DAY, { syncIntervalMinutes: 0 }),
+ ];
+ const priority = model({
+ channels: {
+ "sync-paused": { tier: "normal", overrides: { sync: "paused" } },
+ "base-paused": { tier: "paused" },
+ "sync-only": { tier: "paused", overrides: { sync: "normal" } },
+ },
+ });
+ const eligible = buildScheduleView({
+ channels,
+ scheduler: scheduler(),
+ state: emptySchedulerState(),
+ now: NOW,
+ priority,
+ })
+ .filter((v) => v.autoSyncEligible)
+ .map((v) => v.slug);
+ assert.deepEqual(eligible, ["normal", "sync-only"]);
+ // Every channel here is overdue if it is eligible at all, so the two answers
+ // are the same set — which is the invariant the console depends on.
+ assert.deepEqual([...due(channels, priority).due].sort(), [...eligible].sort());
+});
+
+test("the projection is unchanged by an empty priority document", () => {
+ const view = buildScheduleView({
+ channels: [
+ channel("a", 1 * HOUR),
+ channel("b", 1 * DAY, { syncIntervalMinutes: 0 }),
+ ],
+ scheduler: scheduler(),
+ state: emptySchedulerState(),
+ now: NOW,
+ priority: NO_MODEL,
+ });
+ assert.deepEqual(
+ view.map((v) => [v.slug, v.autoSyncEligible]),
+ [
+ ["a", true],
+ ["b", false],
+ ],
+ );
+});
diff --git a/common/jobs/syncScheduler.ts b/common/jobs/syncScheduler.ts
@@ -1,5 +1,12 @@
import type { ChannelConfig } from "../lib/channelConfig";
import type { SyncSchedulerSettings } from "../lib/settings";
+import {
+ type ChannelPriority,
+ effectiveTier,
+ isChannelPaused,
+ rankOf,
+ tierOrder,
+} from "../lib/channelPriority";
import { resolveFullSweepIntervalMinutes } from "./deepSync";
import type { SchedulerSkip, SchedulerState } from "./syncSchedulerState";
@@ -20,10 +27,23 @@ export type SelectDueInput = {
// Slugs that already have a running or queued sync job (from the registry).
activeSlugs: ReadonlySet<string>;
now: number;
+ // THE CHANNEL PRIORITY MODEL, asked for the "sync" operation and nothing
+ // else. It decides two things here and only two: which channels are skipped
+ // (effective tier `paused`) and what order the survivors come back in.
+ // Required rather than optional so a new caller cannot silently schedule a
+ // paused channel; `getSettings().channelPriority` always exists and is
+ // already sanitized (lib/settings.ts).
+ priority: ChannelPriority;
+ // The RESOLVED focus set. Focus is a compiled POSITION, never a stored tier,
+ // so it cannot come out of the model alone — a `{kind:"site"}` focus resolves
+ // against `transcripts/sites/*/site.json`, which is I/O this pure module must
+ // not do. The caller runs `resolveFocusSlugs` and hands the answer in.
+ focusSlugs?: readonly string[];
};
export type SelectDueResult = {
- // Slugs that should be synced now, ordered most-overdue first.
+ // Slugs that should be synced now, ordered focus first, then by tier, then
+ // by rank, then most-overdue first.
due: string[];
// Channels deliberately held back, with a human reason (for the run log).
// The common "not yet due" case is intentionally omitted to keep the log
@@ -79,11 +99,14 @@ export function nextEligibleAfterFailure(
return now + backoffMinutes(failures, scheduler) * 60_000;
}
-// Core selection. Evaluates each channel against the config gates, the elapsed
-// interval, the backoff window and the active-job set, then orders the winners
-// most-overdue first so a concurrency-capped tick services the stalest channels.
+// Core selection. Evaluates each channel against the config gates, the channel
+// priority model, the elapsed interval, the backoff window and the active-job
+// set, then orders the winners focus first, then by tier, then by rank, then
+// most-overdue first — so a concurrency-capped tick spends its slots on the
+// focused channels and services the stalest of them first.
export function selectDueChannels(input: SelectDueInput): SelectDueResult {
- const { channels, scheduler, state, activeSlugs, now } = input;
+ const { channels, scheduler, state, activeSlugs, now, priority } = input;
+ const focus = new Set(input.focusSlugs ?? []);
if (!scheduler.enabled) return { due: [], skipped: [] };
if (
isInQuietHours(now, scheduler.quietHoursStart, scheduler.quietHoursEnd)
@@ -92,11 +115,21 @@ export function selectDueChannels(input: SelectDueInput): SelectDueResult {
}
const skipped: SchedulerSkip[] = [];
- const due: { slug: string; overdueMs: number }[] = [];
+ const due: {
+ slug: string;
+ overdueMs: number;
+ tier: number;
+ rank: number;
+ }[] = [];
for (const { slug, config } of channels) {
if (!config.url) continue; // not auto-sync material; no noise in the log
- if (config.excludeFromSync) continue;
+ // ONE SKIP. `excludeFromSync` is gone (S5); the document's `sync` tier is
+ // what it became, and the migration turned each of the 15 channels that
+ // carried the flag into `overrides: {sync: "paused"}`. Silent `continue` —
+ // "this channel does not auto-sync" is configuration, not a hold worth a
+ // line in the run log.
+ if (isChannelPaused(priority, slug, "sync")) continue;
const interval = resolveIntervalMinutes(config, scheduler);
if (interval <= 0) continue; // per-channel disabled
@@ -119,10 +152,33 @@ export function selectDueChannels(input: SelectDueInput): SelectDueResult {
const overdueMs = overdueAmount(config.lastSyncedAt, interval, now);
if (overdueMs === null) continue; // not yet due
- due.push({ slug, overdueMs });
+ due.push({
+ slug,
+ overdueMs,
+ // Focus outranks the stored tier; paused already left the loop above, so
+ // "focus wins over the stored tier, paused wins over focus" holds here
+ // by construction.
+ tier: tierOrder(
+ focus.has(slug) ? "focus" : effectiveTier(priority, slug, "sync"),
+ ),
+ // Unranked sorts last inside its tier, which is what an absent `rank`
+ // means everywhere else in the model.
+ rank: rankOf(priority, slug) ?? Number.POSITIVE_INFINITY,
+ });
}
- due.sort((a, b) => b.overdueMs - a.overdueMs);
+ // TIER, THEN RANK, THEN MOST-OVERDUE-FIRST. Most-overdue-first survives
+ // *within* a tier, so a focus channel due by a minute outranks a low channel
+ // due by a day and the tick's `maxConcurrentSyncs` cap
+ // (editor/app/scheduler/runTick.ts) spends its slots on focus first. Ties all
+ // the way down keep the input order — `listChannelConfigs` returns slug
+ // order and Array.prototype.sort is stable — which is the order this
+ // function returned before the model existed.
+ due.sort((a, b) => {
+ if (a.tier !== b.tier) return a.tier - b.tier;
+ if (a.rank !== b.rank) return a.rank < b.rank ? -1 : 1;
+ return b.overdueMs - a.overdueMs;
+ });
return { due: due.map((d) => d.slug), skipped };
}
@@ -132,7 +188,10 @@ export type ChannelScheduleView = {
slug: string;
name: string | null;
// True when this channel is eligible for auto-sync (scheduler on, has a url,
- // not excluded, and a positive resolved interval).
+ // not excluded, not paused for sync by the channel priority model, and a
+ // positive resolved interval). THE SAME PREDICATE `selectDueChannels` skips
+ // on, so the sync console can never show a channel as eligible that the
+ // scheduler will not schedule.
autoSyncEligible: boolean;
intervalMinutes: number; // resolved; 0 = disabled
inheritsInterval: boolean; // using the global default vs a per-channel value
@@ -164,8 +223,12 @@ export function buildScheduleView(input: {
scheduler: SyncSchedulerSettings;
state: SchedulerState;
now: number;
+ // The same model `selectDueChannels` takes, for the same reason: the
+ // projection must agree with the scheduler about who is skipped. Focus is
+ // not needed — it changes the ORDER, not who is eligible.
+ priority: ChannelPriority;
}): ChannelScheduleView[] {
- const { channels, scheduler, state, now } = input;
+ const { channels, scheduler, state, now, priority } = input;
return channels.map(({ slug, config }) => {
const interval = resolveIntervalMinutes(config, scheduler);
const sweepInterval = resolveFullSweepIntervalMinutes(config, scheduler);
@@ -193,7 +256,7 @@ export function buildScheduleView(input: {
autoSyncEligible:
scheduler.enabled &&
!!config.url &&
- !config.excludeFromSync &&
+ !isChannelPaused(priority, slug, "sync") &&
interval > 0,
intervalMinutes: interval,
inheritsInterval: config.syncIntervalMinutes === undefined,
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -118,7 +118,14 @@ export type ChannelConfig = {
// the cheap newest-first paged walk.
lastFullSweepAt?: string;
excludeFromBuild?: boolean;
- excludeFromSync?: boolean;
+ // `excludeFromSync` IS GONE. It said "stop syncing, keep everything else",
+ // which is exactly what `channelPriority`'s per-operation override map says
+ // — `{tier:<base>, overrides:{sync:"paused"}}` — and the model can say it
+ // for every operation, not just this one. The sanitizer below drops the key
+ // rather than carrying it, so an un-migrated config.json still parses: the
+ // migration (common/bin/migrate-channel-priority.ts) reads it from the RAW
+ // file, not through this parser, precisely because this parser no longer
+ // knows the word.
// Opt this channel OUT of the aggregate "cleanable data" total shown on the
// /cleanup page and its sidebar badge. The per-channel cleanup sweeps remain
// fully available; this flag only removes the channel's reclaimable bytes from
@@ -131,7 +138,8 @@ export type ChannelConfig = {
// undefined -> inherit the global SiteSettings default interval
// 0 -> auto-sync disabled for this channel (still manually syncable)
// > 0 -> sync this often (clamped to [SYNC_INTERVAL_MIN/MAX_MINUTES])
- // A missing `url` or `excludeFromSync` also disables auto-sync.
+ // A missing `url` also disables auto-sync, as does a `sync` tier of `paused`
+ // in the channel-priority document (common/lib/channelPriority.ts).
syncIntervalMinutes?: number;
// Full-sweep cadence for this channel (see common/jobs/deepSync.ts). A sync
// upgrades itself to a full sweep when
@@ -286,9 +294,6 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
if (typeof r.excludeFromBuild === "boolean") {
config.excludeFromBuild = r.excludeFromBuild;
}
- if (typeof r.excludeFromSync === "boolean") {
- config.excludeFromSync = r.excludeFromSync;
- }
if (typeof r.excludeFromCleanup === "boolean") {
config.excludeFromCleanup = r.excludeFromCleanup;
}
diff --git a/common/lib/channelPriority.test.ts b/common/lib/channelPriority.test.ts
@@ -0,0 +1,1123 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ CHANNEL_TIERS,
+ PRIORITY_OPERATIONS,
+ PRIO_CATCH_ALL_ID,
+ STORED_CHANNEL_TIERS,
+ channelPriorityFromLegacy,
+ channelsForOperation,
+ compileLaneRoot,
+ compileLanes,
+ hasCompiledLaneRoots,
+ renameChannelInPriority,
+ defaultChannelPriority,
+ effectiveTier,
+ focusSummary,
+ isChannelPaused,
+ isPriorityOperation,
+ overridesOf,
+ parsePrioLeafId,
+ prioGroupId,
+ prioLeafId,
+ rankOf,
+ resolveFocusSlugs,
+ sanitizeChannelPriority,
+ tierOf,
+ tierOrder,
+ type ChannelPriority,
+} from "./channelPriority";
+import { LANES, isGroup } from "./autoQueueTypes";
+import type { AutoQueueGroup, AutoQueueNode } from "./autoQueueTypes";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// The compiler's contract in one line: a model compiles to the trees the runner
+// already walks, and `sanitizeAutoQueue` is the identity on what it produces.
+//
+// THE HALF THAT NEEDS THE ENGINE IS NOT HERE. ../architecture.test.ts forbids
+// `lib/ -> jobs/` and its ALLOWED list may only SHRINK, and it scans test files
+// exactly like any other, so anything asserted THROUGH `sanitizeAutoQueue` or
+// `buildPendingByLeaf` sits in jobs/ — the same reason `laneMigration.test.ts`
+// does. `jobs/channelPrioritySanitize.test.ts` holds the sanitizer round-trip;
+// the focus/hold/release proof through the real engine is S1's
+// `jobs/channelPriorityCompile.test.ts`.
+
+// ---------------------------------------------------------------------------
+// 1. sanitizeChannelPriority
+// ---------------------------------------------------------------------------
+
+test("an absent document is the empty one", () => {
+ const empty = { focus: { kind: "none" }, channels: {} };
+ assert.deepEqual(sanitizeChannelPriority(undefined), empty);
+ assert.deepEqual(sanitizeChannelPriority(null), empty);
+ assert.deepEqual(sanitizeChannelPriority("nonsense"), empty);
+ assert.deepEqual(sanitizeChannelPriority([1, 2]), empty);
+ assert.deepEqual(defaultChannelPriority(), empty);
+});
+
+test("an unknown tier reads as normal, and a bare normal entry is dropped", () => {
+ const out = sanitizeChannelPriority({
+ channels: {
+ a: { tier: "urgent" },
+ b: { tier: "normal" },
+ // "focus" is a compiled POSITION, never a stored tier.
+ c: { tier: "focus" },
+ d: { tier: "low" },
+ e: { tier: "paused" },
+ },
+ });
+ // a/b/c all mean "normal, unranked, no overrides", which is the default.
+ assert.deepEqual(out.channels, {
+ d: { tier: "low" },
+ e: { tier: "paused" },
+ });
+});
+
+test("an unknown tier with a rank keeps the rank at the normal tier", () => {
+ const out = sanitizeChannelPriority({
+ channels: { a: { tier: "urgent", rank: 3 } },
+ });
+ assert.deepEqual(out.channels, { a: { tier: "normal", rank: 3 } });
+});
+
+test("a junk rank is dropped and a fractional one is floored", () => {
+ const out = sanitizeChannelPriority({
+ channels: {
+ a: { tier: "low", rank: "2" },
+ b: { tier: "low", rank: Number.NaN },
+ c: { tier: "low", rank: Number.POSITIVE_INFINITY },
+ d: { tier: "low", rank: 2.9 },
+ e: { tier: "low", rank: 0 },
+ },
+ });
+ assert.deepEqual(out.channels, {
+ a: { tier: "low" },
+ b: { tier: "low" },
+ c: { tier: "low" },
+ d: { tier: "low", rank: 2 },
+ e: { tier: "low", rank: 0 },
+ });
+});
+
+test("a rank on a channel paused everywhere is dropped; a sync-only one keeps it", () => {
+ const out = sanitizeChannelPriority({
+ channels: {
+ dead: { tier: "paused", rank: 4 },
+ synconly: { tier: "paused", rank: 4, overrides: { sync: "normal" } },
+ },
+ });
+ assert.deepEqual(out.channels.dead, { tier: "paused" });
+ assert.deepEqual(out.channels.synconly, {
+ tier: "paused",
+ rank: 4,
+ overrides: { sync: "normal" },
+ });
+});
+
+test("blank slugs are dropped and keys are trimmed and sorted", () => {
+ const out = sanitizeChannelPriority({
+ channels: {
+ " ": { tier: "paused" },
+ "": { tier: "paused" },
+ " zed ": { tier: "paused" },
+ alpha: { tier: "low" },
+ },
+ });
+ assert.deepEqual(Object.keys(out.channels), ["alpha", "zed"]);
+});
+
+test("a focus is sanitized: blank/dup slugs dropped, empty collapses to none", () => {
+ assert.deepEqual(sanitizeChannelPriority({ focus: { kind: "site" } }).focus, {
+ kind: "none",
+ });
+ assert.deepEqual(
+ sanitizeChannelPriority({ focus: { kind: "site", siteId: " " } }).focus,
+ { kind: "none" },
+ );
+ assert.deepEqual(
+ sanitizeChannelPriority({ focus: { kind: "site", siteId: " jeralyzer " } })
+ .focus,
+ { kind: "site", siteId: "jeralyzer" },
+ );
+ assert.deepEqual(
+ sanitizeChannelPriority({
+ focus: { kind: "channels", slugs: [" a ", "a", "", 7, "b"] },
+ }).focus,
+ { kind: "channels", slugs: ["a", "b"] },
+ );
+ assert.deepEqual(
+ sanitizeChannelPriority({ focus: { kind: "channels", slugs: [] } }).focus,
+ { kind: "none" },
+ );
+ assert.deepEqual(
+ sanitizeChannelPriority({ focus: { kind: "elsewhere" } }).focus,
+ { kind: "none" },
+ );
+});
+
+test("sanitizeChannelPriority is idempotent over its own output", () => {
+ const once = sanitizeChannelPriority({
+ focus: { kind: "channels", slugs: [" a ", "a", "b"] },
+ channels: {
+ " zed ": { tier: "paused", overrides: { sync: "normal" } },
+ alpha: { tier: "bogus", rank: 1.7 },
+ beta: { tier: "normal" },
+ gamma: { tier: "low", rank: -2, overrides: { download: "paused" } },
+ },
+ });
+ assert.deepEqual(sanitizeChannelPriority(once), once);
+ assert.deepEqual(
+ sanitizeChannelPriority(JSON.parse(JSON.stringify(once))),
+ once,
+ );
+});
+
+// --- the override map's normalisation --------------------------------------
+
+test("an unknown override key is dropped and an unknown value falls back to the base", () => {
+ const out = sanitizeChannelPriority({
+ channels: {
+ a: {
+ tier: "paused",
+ overrides: {
+ sync: "normal",
+ // not an operation
+ publish: "normal",
+ // not a tier — DROPPED, not coerced, so the base `paused` stands
+ download: "urgent",
+ // "focus" is not storable anywhere
+ digest: "focus",
+ },
+ },
+ },
+ });
+ assert.deepEqual(out.channels.a, {
+ tier: "paused",
+ overrides: { sync: "normal" },
+ });
+ const model = out;
+ assert.equal(effectiveTier(model, "a", "download"), "paused");
+ assert.equal(effectiveTier(model, "a", "digest"), "paused");
+ assert.equal(effectiveTier(model, "a", "sync"), "normal");
+});
+
+test("an override equal to the base is normalised away, and an empty map with it", () => {
+ const out = sanitizeChannelPriority({
+ channels: {
+ a: { tier: "low", overrides: { sync: "low", download: "normal" } },
+ b: { tier: "normal", overrides: { sync: "normal" } },
+ c: { tier: "normal", overrides: {} },
+ d: { tier: "normal", overrides: [] },
+ e: { tier: "normal", overrides: "nope" },
+ },
+ });
+ assert.deepEqual(out.channels, {
+ a: { tier: "low", overrides: { download: "normal" } },
+ });
+});
+
+test("override keys are emitted in PRIORITY_OPERATIONS order", () => {
+ const out = sanitizeChannelPriority({
+ channels: {
+ a: {
+ tier: "normal",
+ overrides: { backfill: "paused", sync: "paused", download: "low" },
+ },
+ },
+ });
+ assert.deepEqual(Object.keys(out.channels.a.overrides ?? {}), [
+ "sync",
+ "download",
+ "backfill",
+ ]);
+});
+
+test("both presets are expressible", () => {
+ // "everything but sync" — the lossless reading of excludeFromSync.
+ const notSync = sanitizeChannelPriority({
+ channels: { a: { tier: "normal", overrides: { sync: "paused" } } },
+ });
+ assert.equal(isChannelPaused(notSync, "a", "sync"), true);
+ for (const lane of LANES) {
+ assert.equal(isChannelPaused(notSync, "a", lane), false, lane);
+ }
+ // "sync only" — its inverse.
+ const syncOnly = sanitizeChannelPriority({
+ channels: { a: { tier: "paused", overrides: { sync: "normal" } } },
+ });
+ assert.equal(isChannelPaused(syncOnly, "a", "sync"), false);
+ for (const lane of LANES) {
+ assert.equal(isChannelPaused(syncOnly, "a", lane), true, lane);
+ }
+});
+
+// --- readers ---------------------------------------------------------------
+
+test("tierOf / rankOf / isChannelPaused read the document, defaulting to normal", () => {
+ const model = sanitizeChannelPriority({
+ channels: { a: { tier: "paused" }, b: { tier: "low", rank: 4 } },
+ });
+ assert.equal(tierOf(model, "a"), "paused");
+ assert.equal(tierOf(model, "b"), "low");
+ assert.equal(tierOf(model, "nobody"), "normal");
+ assert.equal(rankOf(model, "b"), 4);
+ assert.equal(rankOf(model, "a"), null);
+ assert.equal(isChannelPaused(model, "a"), true);
+ assert.equal(isChannelPaused(model, "b"), false);
+ assert.equal(isChannelPaused(model, "nobody"), false);
+});
+
+test("effectiveTier is the override or the base; tierOf stays the base", () => {
+ const model = sanitizeChannelPriority({
+ channels: {
+ omnibased: { tier: "normal", rank: 2, overrides: { download: "paused" } },
+ },
+ });
+ assert.equal(tierOf(model, "omnibased"), "normal");
+ assert.equal(effectiveTier(model, "omnibased", "download"), "paused");
+ assert.equal(effectiveTier(model, "omnibased", "sync"), "normal");
+ assert.equal(effectiveTier(model, "omnibased", "transcription"), "normal");
+ assert.equal(effectiveTier(model, "nobody", "download"), "normal");
+ assert.deepEqual(overridesOf(model, "omnibased"), { download: "paused" });
+ assert.deepEqual(overridesOf(model, "nobody"), {});
+});
+
+test("channelsForOperation filters per operation, input order preserved", () => {
+ const model = sanitizeChannelPriority({
+ channels: {
+ nosync: { tier: "normal", overrides: { sync: "paused" } },
+ nodl: { tier: "normal", overrides: { download: "paused" } },
+ dead: { tier: "paused" },
+ },
+ });
+ const all = ["nosync", "nodl", "dead", "plain"];
+ assert.deepEqual(channelsForOperation(model, all, "sync"), ["nodl", "plain"]);
+ assert.deepEqual(channelsForOperation(model, all, "download"), [
+ "nosync",
+ "plain",
+ ]);
+ assert.deepEqual(channelsForOperation(model, all, "digest"), [
+ "nosync",
+ "nodl",
+ "plain",
+ ]);
+});
+
+test("the vocabularies agree and tierOrder sorts focus < normal < low < paused", () => {
+ assert.deepEqual([...CHANNEL_TIERS], ["focus", "normal", "low", "paused"]);
+ assert.deepEqual(
+ [...STORED_CHANNEL_TIERS],
+ CHANNEL_TIERS.filter((t) => t !== "focus"),
+ );
+ assert.deepEqual([...PRIORITY_OPERATIONS], ["sync", ...LANES]);
+ assert.ok(isPriorityOperation("sync"));
+ assert.ok(isPriorityOperation("backfill"));
+ assert.ok(!isPriorityOperation("publish"));
+ assert.deepEqual(
+ [...CHANNEL_TIERS].sort((a, b) => tierOrder(a) - tierOrder(b)),
+ ["focus", "normal", "low", "paused"],
+ );
+});
+
+// ---------------------------------------------------------------------------
+// 2. resolveFocusSlugs
+// ---------------------------------------------------------------------------
+
+const SITES = {
+ jeralyzer: ["the-quartering", "quartering-live", "community-notes"],
+ testsite: ["slow-a"],
+ empty: [],
+};
+
+test("a site focus resolves through that site's channels[]", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "jeralyzer" },
+ });
+ assert.deepEqual(resolveFocusSlugs(model, SITES), [
+ "the-quartering",
+ "quartering-live",
+ "community-notes",
+ ]);
+});
+
+test("an unknown siteId resolves to [] — a typo must not hold the corpus", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "jerlayzer" },
+ });
+ assert.deepEqual(resolveFocusSlugs(model, SITES), []);
+ // And therefore compiles NO focus group at all.
+ const root = compileLaneRoot("download", model, ["a", "b"], []);
+ assert.deepEqual(
+ root.children.map((c) => c.id),
+ [prioGroupId("normal"), PRIO_CATCH_ALL_ID],
+ );
+});
+
+test("a site with no channels, and a focus of none, both resolve to []", () => {
+ assert.deepEqual(
+ resolveFocusSlugs(
+ sanitizeChannelPriority({ focus: { kind: "site", siteId: "empty" } }),
+ SITES,
+ ),
+ [],
+ );
+ assert.deepEqual(resolveFocusSlugs(defaultChannelPriority(), SITES), []);
+});
+
+test("a channel focus keeps its order and drops slugs that do not exist", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "channels", slugs: ["zed", "alpha", "ghost"] },
+ });
+ assert.deepEqual(resolveFocusSlugs(model, SITES, ["alpha", "zed", "beta"]), [
+ "zed",
+ "alpha",
+ ]);
+ // No `known` list = no existence filter.
+ assert.deepEqual(resolveFocusSlugs(model, SITES), ["zed", "alpha", "ghost"]);
+});
+
+test("a site focus is filtered by the known channel list too", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "jeralyzer" },
+ });
+ assert.deepEqual(
+ resolveFocusSlugs(model, SITES, ["community-notes", "the-quartering"]),
+ ["the-quartering", "community-notes"],
+ );
+});
+
+// ---------------------------------------------------------------------------
+// 3. compileLaneRoot / compileLanes
+// ---------------------------------------------------------------------------
+
+function leafSlugs(node: AutoQueueNode): string[] {
+ if (!isGroup(node)) return node.match.value ? [node.match.value] : [];
+ return node.children.flatMap(leafSlugs);
+}
+
+function groupNamed(root: AutoQueueGroup, id: string): AutoQueueGroup {
+ const found = root.children.find((c) => c.id === id);
+ assert.ok(found && isGroup(found), `no group ${id} in ${root.id}`);
+ return found;
+}
+
+test("the compiled tree is focus, normal, low, then a catch-all last", () => {
+ const model = sanitizeChannelPriority({
+ channels: { low1: { tier: "low" }, paused1: { tier: "paused" } },
+ });
+ const root = compileLaneRoot(
+ "transcription",
+ model,
+ ["norm1", "low1", "paused1", "foc1"],
+ ["foc1"],
+ );
+ assert.equal(root.id, "transcription-root");
+ assert.equal(root.mode, "strict");
+ assert.deepEqual(
+ root.children.map((c) => c.id),
+ [
+ prioGroupId("focus"),
+ prioGroupId("normal"),
+ prioGroupId("low"),
+ PRIO_CATCH_ALL_ID,
+ ],
+ );
+ const last = root.children[root.children.length - 1];
+ assert.ok(!isGroup(last));
+ assert.deepEqual(last.match, { type: "all" });
+ // Paused never appears, at any depth.
+ assert.ok(!leafSlugs(root).includes("paused1"));
+ assert.deepEqual(leafSlugs(root), ["foc1", "norm1", "low1"]);
+});
+
+test("an empty tier emits no group, and an empty focus emits no focus group", () => {
+ const model = defaultChannelPriority();
+ const root = compileLaneRoot("download", model, ["a", "b"], []);
+ assert.deepEqual(
+ root.children.map((c) => c.id),
+ [prioGroupId("normal"), PRIO_CATCH_ALL_ID],
+ );
+ // Everything paused: no group at all, just the net.
+ const allPaused = sanitizeChannelPriority({
+ channels: { a: { tier: "paused" }, b: { tier: "paused" } },
+ });
+ assert.deepEqual(
+ compileLaneRoot("download", allPaused, ["a", "b"], ["a"]).children.map(
+ (c) => c.id,
+ ),
+ [PRIO_CATCH_ALL_ID],
+ );
+});
+
+test("focus wins over the stored tier; paused wins over focus", () => {
+ const model = sanitizeChannelPriority({
+ channels: { lowfoc: { tier: "low" }, pausedfoc: { tier: "paused" } },
+ });
+ const root = compileLaneRoot(
+ "digest",
+ model,
+ ["lowfoc", "pausedfoc", "plain"],
+ ["lowfoc", "pausedfoc"],
+ );
+ assert.deepEqual(leafSlugs(groupNamed(root, prioGroupId("focus"))), ["lowfoc"]);
+ assert.deepEqual(leafSlugs(root), ["lowfoc", "plain"]);
+});
+
+test("within a group: rank ascending, unranked last, then slug", () => {
+ const model = sanitizeChannelPriority({
+ channels: {
+ b: { tier: "normal", rank: 0 },
+ a: { tier: "normal", rank: 5 },
+ z: { tier: "normal", rank: 5 },
+ // no rank: m, c
+ low2: { tier: "low", rank: 1 },
+ low1: { tier: "low" },
+ },
+ });
+ const root = compileLaneRoot(
+ "transcription",
+ model,
+ ["m", "a", "c", "z", "b", "low1", "low2"],
+ [],
+ );
+ assert.deepEqual(leafSlugs(groupNamed(root, prioGroupId("normal"))), [
+ "b",
+ "a",
+ "z",
+ "c",
+ "m",
+ ]);
+ assert.deepEqual(leafSlugs(groupNamed(root, prioGroupId("low"))), [
+ "low2",
+ "low1",
+ ]);
+});
+
+test("ids are prio-*, and parsePrioLeafId is their inverse", () => {
+ const root = compileLaneRoot(
+ "backfill",
+ sanitizeChannelPriority({ channels: { l: { tier: "low" } } }),
+ ["n", "l", "f"],
+ ["f"],
+ );
+ const ids: string[] = [];
+ const walk = (node: AutoQueueNode): void => {
+ ids.push(node.id);
+ if (isGroup(node)) node.children.forEach(walk);
+ };
+ root.children.forEach(walk);
+ assert.deepEqual(ids, [
+ "prio-focus",
+ "prio-focus-f",
+ "prio-normal",
+ "prio-normal-n",
+ "prio-low",
+ "prio-low-l",
+ "prio-all",
+ ]);
+ assert.equal(prioLeafId("focus", "f"), "prio-focus-f");
+ assert.deepEqual(parsePrioLeafId("prio-low-l"), { tier: "low", slug: "l" });
+ assert.deepEqual(parsePrioLeafId("prio-normal-a-b"), {
+ tier: "normal",
+ slug: "a-b",
+ });
+ assert.equal(parsePrioLeafId(PRIO_CATCH_ALL_ID), null);
+ assert.equal(parsePrioLeafId("prio-normal"), null);
+ assert.equal(parsePrioLeafId("n-2b77f5b7-1"), null);
+});
+
+test("the four lanes differ ONLY where an override moves a channel", () => {
+ const noOverrides = sanitizeChannelPriority({
+ channels: { l: { tier: "low" } },
+ });
+ const same = compileLanes(noOverrides, ["a", "l"], ["a"]);
+ assert.deepEqual(Object.keys(same), [...LANES]);
+ assert.equal(same.transcription.id, "transcription-root");
+ assert.equal(same.backfill.id, "backfill-root");
+ for (const lane of LANES) {
+ assert.deepEqual(
+ { ...same[lane], id: "x" },
+ { ...same.transcription, id: "x" },
+ lane,
+ );
+ }
+ // One override, one lane moves.
+ const withOverride = sanitizeChannelPriority({
+ channels: {
+ l: { tier: "low" },
+ omnibased: { tier: "normal", overrides: { download: "paused" } },
+ },
+ });
+ const split = compileLanes(withOverride, ["a", "l", "omnibased"], ["a"]);
+ assert.deepEqual(leafSlugs(split.download), ["a", "l"]);
+ for (const lane of ["transcription", "digest", "backfill"] as const) {
+ assert.deepEqual(leafSlugs(split[lane]), ["a", "omnibased", "l"], lane);
+ }
+});
+
+test("an override can also demote rather than pause, per lane", () => {
+ const model = sanitizeChannelPriority({
+ channels: { b: { tier: "normal", overrides: { digest: "low" } } },
+ });
+ const roots = compileLanes(model, ["a", "b"], []);
+ assert.deepEqual(leafSlugs(groupNamed(roots.digest, prioGroupId("low"))), ["b"]);
+ assert.deepEqual(leafSlugs(groupNamed(roots.download, prioGroupId("normal"))), [
+ "a",
+ "b",
+ ]);
+});
+
+// ---------------------------------------------------------------------------
+// focusSummary
+// ---------------------------------------------------------------------------
+
+test("focusSummary counts the focus group, the rest, and the channels held", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "jeralyzer" },
+ channels: { l: { tier: "low" } },
+ });
+ const summary = focusSummary(model, ["f1", "f2"], {
+ "prio-focus-f1": ["v1", "v2"],
+ "prio-focus-f2": [],
+ "prio-normal-n1": ["v3"],
+ "prio-normal-n2": [],
+ "prio-low-l": ["v4", "v5"],
+ "prio-all": ["v6"],
+ });
+ assert.deepEqual(summary, {
+ kind: "site",
+ siteId: "jeralyzer",
+ slugs: ["f1", "f2"],
+ channelCount: 2,
+ active: true,
+ focusPending: 2,
+ otherPending: 4,
+ holding: true,
+ // n1 and l have work and are below the focus; n2 has none; the catch-all is
+ // not a channel.
+ heldChannels: 2,
+ });
+});
+
+test("a focus with no pending work holds nothing", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "channels", slugs: ["f1"] },
+ });
+ const summary = focusSummary(model, ["f1"], {
+ "prio-focus-f1": [],
+ "prio-normal-n1": ["v1"],
+ });
+ assert.equal(summary.holding, false);
+ assert.equal(summary.heldChannels, 0);
+ assert.equal(summary.focusPending, 0);
+ assert.equal(summary.otherPending, 1);
+ assert.equal(summary.active, true);
+});
+
+test("no focus, or a focus that resolved to nothing, is not active", () => {
+ assert.equal(focusSummary(defaultChannelPriority(), []).active, false);
+ assert.equal(focusSummary(defaultChannelPriority(), []).siteId, null);
+ const model = sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "typo" },
+ });
+ assert.equal(focusSummary(model, []).active, false);
+ assert.equal(focusSummary(model, []).siteId, "typo");
+});
+
+// ---------------------------------------------------------------------------
+// 5. channelPriorityFromLegacy
+// ---------------------------------------------------------------------------
+
+// The LIVE shape, from the repo-root settings.json: two strict roots of nine
+// bare channel leaves then `{type:"all"}`, disagreeing on six channels and on
+// the Quartering ordering.
+const LIVE_TRANSCRIPTION = [
+ "quartering-live",
+ "the-quartering-rumble",
+ "the-quartering",
+ "HasanAbiVODs3",
+ "hasanabi",
+ "rekietalaw-rumble",
+ "nux-taku",
+ "nuxanor",
+ "leaflit-rumble",
+];
+const LIVE_DOWNLOAD = [
+ "quartering-live",
+ "the-quartering",
+ "the-quartering-rumble",
+ "nuxanor",
+ "darlingstrawb",
+ "chibi-reviews",
+ "destiny",
+ "omnivods-odysee",
+ "piratesoftware",
+];
+
+function liveRoot(lane: string, slugs: readonly string[]): AutoQueueGroup {
+ return {
+ id: "root",
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: [
+ ...slugs.map((value, i) => ({
+ id: `n-${lane}-${i}`,
+ match: { type: "channel" as const, value },
+ weight: 1,
+ maxWorkers: null,
+ })),
+ {
+ id: `n-${lane}-all`,
+ match: { type: "all" as const },
+ weight: 1,
+ maxWorkers: null,
+ },
+ ],
+ };
+}
+
+const CATCH_ALL_ROOT = (id: string): AutoQueueGroup => ({
+ id: `${id}-root`,
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: [
+ {
+ id: `${id}-all`,
+ match: { type: "all" as const },
+ weight: 1,
+ maxWorkers: null,
+ },
+ ],
+});
+
+const LIVE_AUTO_QUEUE = {
+ transcription: { root: liveRoot("t", LIVE_TRANSCRIPTION) },
+ download: { root: liveRoot("d", LIVE_DOWNLOAD) },
+ digest: { root: CATCH_ALL_ROOT("digest") },
+ backfill: { root: CATCH_ALL_ROOT("backfill") },
+};
+
+// The 15 live `excludeFromSync` channels.
+const LIVE_EXCLUDED = [
+ "community-notes",
+ "angryjoeshow",
+ "cornbreadman",
+ "friendofrc",
+ "hex-headquarters",
+ "mevsme",
+ "omnivods-odysee",
+ "exclusively-games",
+ "rcflightschool",
+ "rcspotlight",
+ "teamrcn",
+ "the-incredible-salt-mine",
+ "steven-crowder",
+ "midwestly",
+ "redbar",
+];
+
+function liveConfigs() {
+ const slugs = new Set([
+ ...LIVE_TRANSCRIPTION,
+ ...LIVE_DOWNLOAD,
+ ...LIVE_EXCLUDED,
+ "unranked-one",
+ "unranked-two",
+ ]);
+ return [...slugs].sort().map((slug) => ({
+ slug,
+ config: LIVE_EXCLUDED.includes(slug) ? { excludeFromSync: true } : {},
+ }));
+}
+
+// THE MIGRATED ORDER, IN FULL — the thing the plan calls the migration's one
+// behaviour change, asserted as an ORDER and not as a set of memberships.
+//
+// Merged index first (the lane that ranked a channel higher wins), then the
+// transcription lane's own order, then the slug. Four merged indices carry two
+// channels each on the live trees, and that is exactly where a collision would
+// otherwise have been resolved alphabetically — by NEITHER lane's order.
+const EXPECTED_LIVE_ORDER = [
+ "quartering-live", // t0 d0 -> merged 0
+ "the-quartering-rumble", // t1 d2 -> merged 1, transcription first
+ "the-quartering", // t2 d1 -> merged 1, transcription second
+ "HasanAbiVODs3", // t3 -> merged 3, transcription 3
+ "nuxanor", // t7 d3 -> merged 3, transcription 7
+ "hasanabi", // t4 -> merged 4, transcription 4
+ "darlingstrawb", // d4 -> merged 4, unranked by transcription
+ "rekietalaw-rumble", // t5 -> merged 5
+ "chibi-reviews", // d5 -> merged 5, unranked by transcription
+ "nux-taku", // t6 -> merged 6
+ "destiny", // d6 -> merged 6, unranked by transcription
+ "omnivods-odysee", // d7 -> merged 7
+ "leaflit-rumble", // t8 -> merged 8
+ "piratesoftware", // d8 -> merged 8, unranked by transcription
+];
+
+test("the legacy read collapses two lane orders into one rank per channel", () => {
+ const model = channelPriorityFromLegacy(liveConfigs(), LIVE_AUTO_QUEUE);
+ // DENSE, 0..n-1, in this order. The merge produces collisions (four pairs
+ // here); leaving them would hand the tie to `orderWithin`'s slug fallback,
+ // which is neither lane's order and is not what either list said.
+ const ranked = Object.entries(model.channels)
+ .filter(([, e]) => e.rank !== undefined)
+ .sort(([, a], [, b]) => (a.rank ?? 0) - (b.rank ?? 0))
+ .map(([slug]) => slug);
+ assert.deepEqual(ranked, EXPECTED_LIVE_ORDER);
+ assert.deepEqual(
+ EXPECTED_LIVE_ORDER.map((slug) => rankOf(model, slug)),
+ EXPECTED_LIVE_ORDER.map((_, i) => i),
+ );
+ // The order the COMPILER then produces is the same one — which is the only
+ // reason the rank matters at all.
+ const slugs = liveConfigs().map((c) => c.slug);
+ const normal = compileLaneRoot("download", model, slugs, []).children.find(
+ (c) => c.id === "prio-normal",
+ ) as AutoQueueGroup;
+ assert.deepEqual(
+ normal.children.slice(0, EXPECTED_LIVE_ORDER.length).map((c) => c.id),
+ EXPECTED_LIVE_ORDER.map((slug) => `prio-normal-${slug}`),
+ );
+ // Unranked and not excluded = absent entirely.
+ assert.equal(model.channels["unranked-one"], undefined);
+ assert.equal(tierOf(model, "unranked-one"), "normal");
+ assert.deepEqual(model.focus, { kind: "none" });
+});
+
+test("the legacy read is a no-op on a tree it already compiled", () => {
+ const configs = liveConfigs();
+ const slugs = configs.map((c) => c.slug);
+ const model = channelPriorityFromLegacy(configs, LIVE_AUTO_QUEUE);
+ const lanes = compileLanes(model, slugs, []);
+ const compiled = Object.fromEntries(
+ LANES.map((lane) => [lane, { root: lanes[lane] }]),
+ );
+ assert.equal(hasCompiledLaneRoots(LIVE_AUTO_QUEUE), false);
+ assert.equal(hasCompiledLaneRoots(compiled), true);
+
+ // A SECOND RUN OVER ITS OWN OUTPUT. Compiled channel leaves are bare channel
+ // leaves, so without the detector rule 2 would read them and renumber the
+ // hand-made order into "focus group, then normal group, then low" — dense,
+ // alphabetical within each tier, and irrecoverable.
+ const second = channelPriorityFromLegacy(configs, compiled, model);
+ assert.deepEqual(second, model);
+
+ // Including when the stored document has since been EDITED: the stored
+ // document wins whole, it is not merged with a re-derivation.
+ const edited = sanitizeChannelPriority({
+ ...model,
+ focus: { kind: "channels", slugs: ["hasanabi"] },
+ channels: { ...model.channels, destiny: { tier: "paused" } },
+ });
+ assert.deepEqual(
+ channelPriorityFromLegacy(configs, compiled, edited),
+ edited,
+ );
+});
+
+test("the legacy read is LOSSLESS: excludeFromSync becomes a sync override only", () => {
+ const model = channelPriorityFromLegacy(liveConfigs(), LIVE_AUTO_QUEUE);
+ for (const slug of LIVE_EXCLUDED) {
+ // Paused for sync, exactly as today...
+ assert.equal(effectiveTier(model, slug, "sync"), "paused", slug);
+ // ...and untouched everywhere else.
+ assert.equal(tierOf(model, slug), "normal", slug);
+ for (const lane of LANES) {
+ assert.equal(effectiveTier(model, slug, lane), "normal", `${slug}/${lane}`);
+ }
+ }
+ // omnivods-odysee is the one that is BOTH: excluded from sync today AND the
+ // 8th leaf of the live download tree. It KEEPS its rank and keeps downloading.
+ assert.ok(LIVE_DOWNLOAD.includes("omnivods-odysee"));
+ assert.deepEqual(model.channels["omnivods-odysee"], {
+ tier: "normal",
+ // Dense rank 11, not the merged index 7 — see EXPECTED_LIVE_ORDER. Its
+ // POSITION among the ranked channels is what the download lane reads, and
+ // that is unchanged: still behind destiny, still ahead of piratesoftware.
+ rank: 11,
+ overrides: { sync: "paused" },
+ });
+});
+
+test("the 15-channel migration moves no lane's membership", () => {
+ const configs = liveConfigs();
+ const slugs = configs.map((c) => c.slug);
+ const model = channelPriorityFromLegacy(configs, LIVE_AUTO_QUEUE);
+ const roots = compileLanes(model, slugs, []);
+ // Every channel in the corpus is still reachable on every lane — nothing was
+ // dropped by the migration, which is what "lossless" has to mean at the tree.
+ for (const lane of LANES) {
+ assert.deepEqual([...leafSlugs(roots[lane])].sort(), [...slugs].sort(), lane);
+ }
+ // And the sync side is the only thing that lost anyone.
+ assert.deepEqual(
+ channelsForOperation(model, slugs, "sync").sort(),
+ slugs.filter((s) => !LIVE_EXCLUDED.includes(s)).sort(),
+ );
+ for (const lane of LANES) {
+ assert.deepEqual(channelsForOperation(model, slugs, lane), slugs, lane);
+ }
+});
+
+test("the legacy read is asserted through the sanitizer and is stable", () => {
+ const model = channelPriorityFromLegacy(liveConfigs(), LIVE_AUTO_QUEUE);
+ assert.deepEqual(sanitizeChannelPriority(model), model);
+ assert.deepEqual(
+ channelPriorityFromLegacy(liveConfigs(), LIVE_AUTO_QUEUE),
+ model,
+ );
+});
+
+test("an empty legacy tree and no exclusions produce the empty document", () => {
+ assert.deepEqual(
+ channelPriorityFromLegacy(
+ [
+ { slug: "a", config: {} },
+ { slug: "b", config: { excludeFromSync: false } },
+ ],
+ {},
+ ),
+ defaultChannelPriority(),
+ );
+ // A tree of catch-alls contributes no rank either.
+ assert.deepEqual(
+ channelPriorityFromLegacy([{ slug: "a", config: {} }], {
+ digest: LIVE_AUTO_QUEUE.digest,
+ backfill: LIVE_AUTO_QUEUE.backfill,
+ }),
+ defaultChannelPriority(),
+ );
+});
+
+test("a leaf narrowed by bucket or operation is not a rank", () => {
+ const root: AutoQueueGroup = {
+ id: "root",
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: [
+ {
+ id: "retry",
+ match: { type: "channel", value: "retryish", bucket: "failedListed" },
+ weight: 1,
+ maxWorkers: null,
+ },
+ {
+ id: "op",
+ match: { type: "channel", value: "opish", operation: "diarization" },
+ weight: 1,
+ maxWorkers: null,
+ },
+ {
+ id: "bare",
+ match: { type: "channel", value: "bare" },
+ weight: 1,
+ maxWorkers: null,
+ },
+ {
+ id: "plat",
+ match: { type: "platform", value: "youtube" },
+ weight: 1,
+ maxWorkers: null,
+ },
+ ],
+ };
+ const model = channelPriorityFromLegacy([], { transcription: { root } });
+ // Rank 0, not 2: the index is the position among BARE CHANNEL LEAVES, so a
+ // leaf the model cannot express leaves no hole in the order.
+ assert.deepEqual(model.channels, { bare: { tier: "normal", rank: 0 } });
+});
+
+test("nested groups still yield ranks, in depth-first leaf order", () => {
+ const root: AutoQueueGroup = {
+ id: "root",
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: [
+ {
+ id: "g",
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: [
+ {
+ id: "x",
+ match: { type: "channel", value: "x" },
+ weight: 1,
+ maxWorkers: null,
+ },
+ {
+ id: "y",
+ match: { type: "channel", value: "y" },
+ weight: 1,
+ maxWorkers: null,
+ },
+ ],
+ },
+ {
+ id: "z",
+ match: { type: "channel", value: "z" },
+ weight: 1,
+ maxWorkers: null,
+ },
+ ],
+ };
+ const model = channelPriorityFromLegacy([], { download: { root } });
+ assert.equal(rankOf(model, "x"), 0);
+ assert.equal(rankOf(model, "y"), 1);
+ assert.equal(rankOf(model, "z"), 2);
+});
+
+// ---------------------------------------------------------------------------
+// The behaviour-preservation proof: a model derived from a live-shaped tree
+// (9 bare channel leaves + a catch-all) compiles back to an equivalent tree.
+// ---------------------------------------------------------------------------
+
+test("a 9-leaf + catch-all tree round-trips through the model unchanged", () => {
+ // ONE lane's tree, so the collapse of the two live orders (which IS the one
+ // behaviour change the migration makes, and is measured in S5) is not in
+ // play: this is the property the compiler must hold on its own — derive,
+ // compile, same order.
+ const configs = LIVE_TRANSCRIPTION.map((slug) => ({ slug, config: {} }));
+ const model = channelPriorityFromLegacy(configs, {
+ transcription: { root: liveRoot("t", LIVE_TRANSCRIPTION) },
+ });
+ const compiled = compileLaneRoot(
+ "transcription",
+ model,
+ // The corpus in slug order, which is what listChannelConfigs hands over —
+ // so the compiled order comes from the MODEL, not from the input order.
+ [...LIVE_TRANSCRIPTION].sort(),
+ [],
+ );
+ // Same channels, same order, catch-all still last.
+ assert.deepEqual(leafSlugs(compiled), LIVE_TRANSCRIPTION);
+ const last = compiled.children[compiled.children.length - 1];
+ assert.ok(!isGroup(last));
+ assert.deepEqual(last.match, { type: "all" });
+ // Every leaf is still BARE — no bucket, no operation, no weight or cap moved.
+ const walkLeaves = (node: AutoQueueNode): void => {
+ if (isGroup(node)) {
+ node.children.forEach(walkLeaves);
+ return;
+ }
+ assert.equal(node.match.bucket, undefined);
+ assert.equal(node.match.operation, undefined);
+ assert.equal(node.weight, 1);
+ assert.equal(node.maxWorkers, null);
+ };
+ compiled.children.forEach(walkLeaves);
+ // Every group is strict, so strict descent means what it meant before.
+ assert.equal(compiled.mode, "strict");
+ for (const child of compiled.children) {
+ if (isGroup(child)) assert.equal(child.mode, "strict");
+ }
+ // A channel added since the last compile is still reachable — through the net,
+ // at the bottom, which is the only drift the compiler can produce.
+ const drifted = compileLaneRoot(
+ "transcription",
+ model,
+ [...LIVE_TRANSCRIPTION, "brand-new"].sort(),
+ [],
+ );
+ assert.deepEqual(leafSlugs(drifted), [...LIVE_TRANSCRIPTION, "brand-new"]);
+});
+
+test("a focus on a live-shaped tree lifts exactly its channels above the rest", () => {
+ const configs = LIVE_TRANSCRIPTION.map((slug) => ({ slug, config: {} }));
+ const model: ChannelPriority = {
+ ...channelPriorityFromLegacy(configs, {
+ transcription: { root: liveRoot("t", LIVE_TRANSCRIPTION) },
+ }),
+ focus: { kind: "channels", slugs: ["hasanabi", "HasanAbiVODs3"] },
+ };
+ const focusSlugs = resolveFocusSlugs(model, {}, LIVE_TRANSCRIPTION);
+ const root = compileLaneRoot(
+ "transcription",
+ model,
+ [...LIVE_TRANSCRIPTION].sort(),
+ focusSlugs,
+ );
+ // Ranked 3 and 4 in the legacy tree, so inside the focus group HasanAbiVODs3
+ // still precedes hasanabi: the focus lifts the pair, it does not reorder them.
+ assert.deepEqual(leafSlugs(groupNamed(root, prioGroupId("focus"))), [
+ "HasanAbiVODs3",
+ "hasanabi",
+ ]);
+ assert.deepEqual(leafSlugs(groupNamed(root, prioGroupId("normal"))), [
+ "quartering-live",
+ "the-quartering-rumble",
+ "the-quartering",
+ "rekietalaw-rumble",
+ "nux-taku",
+ "nuxanor",
+ "leaflit-rumble",
+ ]);
+});
+
+// ---------------------------------------------------------------------------
+// renameChannelInPriority — the document keys by slug, so it follows a rename
+// ---------------------------------------------------------------------------
+
+test("a rename carries the entry and rewrites a channel focus", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "channels", slugs: ["old", "other"] },
+ channels: {
+ old: { tier: "low", rank: 3, overrides: { sync: "paused" } },
+ other: { tier: "paused" },
+ },
+ });
+ const next = renameChannelInPriority(model, "old", "new");
+ // The whole entry moves — tier, rank AND the per-operation overrides. Losing
+ // any of them is silent: nothing downstream can tell a stale entry from a
+ // deliberate one.
+ assert.deepEqual(next.channels["new"], {
+ tier: "low",
+ rank: 3,
+ overrides: { sync: "paused" },
+ });
+ assert.equal(next.channels["old"], undefined);
+ assert.deepEqual(next.channels["other"], { tier: "paused" });
+ // A focus NAMING the channel keeps naming it. Order is preserved: the focus
+ // group compiles in the order the selector listed.
+ assert.deepEqual(next.focus, { kind: "channels", slugs: ["new", "other"] });
+ // And the compiled tree has no leaf under the old slug any more.
+ const root = compileLaneRoot("download", next, ["new", "other"], ["new"]);
+ assert.equal(JSON.stringify(root).includes("prio-focus-old"), false);
+ assert.equal(JSON.stringify(root).includes("prio-focus-new"), true);
+});
+
+test("a rename is a no-op when there is nothing to carry", () => {
+ const model = sanitizeChannelPriority({
+ focus: { kind: "site", siteId: "s" },
+ channels: { kept: { tier: "low" } },
+ });
+ // An unknown slug, a same-slug rename, and blanks all return the document
+ // unchanged — and a SITE focus needs no rewrite at all: it resolves through
+ // the site's own channels[], which the rename updates on disk.
+ assert.equal(renameChannelInPriority(model, "absent", "new"), model);
+ assert.equal(renameChannelInPriority(model, "kept", "kept"), model);
+ assert.equal(renameChannelInPriority(model, "", "new"), model);
+ assert.equal(renameChannelInPriority(model, "kept", " "), model);
+ assert.deepEqual(renameChannelInPriority(model, "kept", "moved").focus, {
+ kind: "site",
+ siteId: "s",
+ });
+});
+
+test("a rename onto an existing entry keeps the destination's", () => {
+ // The channel controller refuses this rename, so the only way here is a
+ // document that is already inconsistent — and the live channel's settings
+ // are the ones worth keeping.
+ const model = sanitizeChannelPriority({
+ focus: { kind: "none" },
+ channels: { old: { tier: "low" }, taken: { tier: "paused" } },
+ });
+ const next = renameChannelInPriority(model, "old", "taken");
+ assert.deepEqual(next.channels, { taken: { tier: "paused" } });
+});
diff --git a/common/lib/channelPriority.ts b/common/lib/channelPriority.ts
@@ -0,0 +1,842 @@
+// CHANNEL PRIORITY: one tier per channel, one focus, four compiled trees.
+//
+// The operator-facing model is a single ordered-tier document in settings.json:
+// every channel sits in a tier (normal / low / paused), optionally carries a
+// rank inside it, and ONE corpus-wide focus selector lifts a set of channels
+// above everything else until their work is exhausted.
+//
+// THE MODEL DOES NOT REACH DISPATCH. It COMPILES to the four
+// `autoQueue[lane].root` trees, which the runner already walks. A strict group
+// is *already* "hold the rest until the higher one is empty":
+// `jobs/autoQueuePolicy.ts`'s `pick()` filters children to those with work and
+// descends into the FIRST of them, and it is re-asked on every grant. So focus
+// work present ⇒ nothing below it is picked; focus work exhausted ⇒ the next
+// group runs; new focus work arrives ⇒ the very next pick retakes the lane.
+// Compiling means NO dispatch code changes: `buildPendingByLeaf`,
+// `selectNextWork`, `operationBatch`, `laneLimit`, `pauseGates` and every
+// `held` key are untouched, and "a zero limit is a hold, never a stop" is
+// preserved trivially because nothing here ever returns a limit.
+//
+// PAUSED IS THE ONE THING THAT IS *NOT* A TREE SHAPE. A tree cannot express
+// exclusion — a `{type:"all"}` catch-all matches everything, and first-match-
+// wins would let a catch-all above the Low group swallow Low's work. So paused
+// is a filter on the runner's CHANNEL LIST (`controller/autoRunner.ts`'s
+// `listChannelMeta`, slice S1), which makes a paused channel invisible to the
+// lane, catch-all included, in one predicate — asked per lane, because a
+// per-operation override means a channel can be paused for one and not another.
+//
+// A PAUSE IS NOT A STOP, and it is not a stop of a MANUAL run either. Paused
+// gates the sync scheduler, *Sync all* and the four auto lanes. The per-video
+// and per-channel Run buttons still work — the operator asking for one video is
+// not the automation this model governs.
+//
+// A FOCUS NEVER CHANGES A LANE'S `enabled`. Focusing must not start a stopped
+// lane, for the same reason `saveAutoQueueAction` refuses to unhold one.
+//
+// PER-OPERATION OVERRIDES ARE THE ADVANCED HALF. A channel's tier is its base,
+// and an entry may pin any single operation — `sync` plus the four lanes — to a
+// different tier. That is not a luxury: the flag this model replaces,
+// `excludeFromSync`, was exactly "stop syncing, keep everything else", and a
+// channel that must keep its playlist current while its downloads are parked is
+// the same statement the other way round. The focus set stays ONE, corpus-wide;
+// a focused channel whose override for an operation is `paused` is simply not
+// drawn for that operation. Every dispatch-side question therefore asks
+// `effectiveTier(model, slug, op)` and never the base tier directly.
+//
+// PURE, AND lib/-ONLY. It imports `./autoQueueTypes` and nothing else, so
+// ../architecture.test.ts's ALLOWED list gains no entry. It builds RAW nodes
+// that `sanitizeAutoQueue` normalizes exactly as it normalizes a hand-edited
+// file — the same discipline as lib/laneMigration.ts — except that every node
+// here already spells `weight` and `maxWorkers`, so a compiled tree survives
+// that sanitizer unchanged and a round-trip through settings.json is identity.
+
+import {
+ LANES,
+ type AutoQueueGroup,
+ type AutoQueueKind,
+ type AutoQueueLeaf,
+ type AutoQueueNode,
+ isGroup,
+} from "./autoQueueTypes";
+
+// --- The vocabulary ---------------------------------------------------------
+
+// Every tier a channel can occupy in a COMPILED tree, highest first.
+//
+// "focus" is a POSITION, not a stored value: it is produced by the focus
+// selector, and `sanitizeChannelPriority` coerces a stored `tier: "focus"` to
+// "normal". Storing it would give two ways to say the same thing and no way to
+// end a focus in one click.
+export const CHANNEL_TIERS = ["focus", "normal", "low", "paused"] as const;
+export type ChannelTier = (typeof CHANNEL_TIERS)[number];
+
+// What may be STORED against a channel. The `/channels` tier control writes one
+// of these three; "focus" is reached through the focus selector instead.
+export const STORED_CHANNEL_TIERS = ["normal", "low", "paused"] as const;
+export type StoredChannelTier = (typeof STORED_CHANNEL_TIERS)[number];
+
+export const DEFAULT_CHANNEL_TIER: StoredChannelTier = "normal";
+
+export function isChannelTier(value: unknown): value is ChannelTier {
+ return (
+ typeof value === "string" &&
+ (CHANNEL_TIERS as readonly string[]).includes(value)
+ );
+}
+
+export function isStoredChannelTier(value: unknown): value is StoredChannelTier {
+ return (
+ typeof value === "string" &&
+ (STORED_CHANNEL_TIERS as readonly string[]).includes(value)
+ );
+}
+
+// THE OPERATIONS A TIER CAN BE PINNED TO: the four dispatch lanes plus `sync`.
+//
+// `sync` is first because it is the one that is NOT a lane — it is a per-channel
+// cadence, which is the same answer `pauseLaneFor` and `laneForOperation` give
+// it — and because "sync only" is the preset that retires `excludeFromSync`.
+// The four lanes come from LANES rather than being restated, so a fifth lane is
+// overridable the day it exists.
+export const PRIORITY_OPERATIONS = ["sync", ...LANES] as const;
+export type PriorityOperation = (typeof PRIORITY_OPERATIONS)[number];
+
+export function isPriorityOperation(value: unknown): value is PriorityOperation {
+ return (
+ typeof value === "string" &&
+ (PRIORITY_OPERATIONS as readonly string[]).includes(value)
+ );
+}
+
+// --- The document -----------------------------------------------------------
+
+// The focus selector. `site` is the first-class answer to "focus = the channels
+// of site X" and is resolved at COMPILE time against that site's `channels[]`,
+// so it tracks membership rather than freezing a list; `channels` backs
+// "Focus these".
+export type ChannelFocus =
+ | { kind: "none" }
+ | { kind: "site"; siteId: string }
+ | { kind: "channels"; slugs: string[] };
+
+export type ChannelPriorityEntry = {
+ // THE BASE TIER: what every operation gets unless an override says otherwise.
+ tier: StoredChannelTier;
+ // Order WITHIN the tier, ascending. Absent = unranked, which sorts after
+ // every ranked sibling and then by slug. ONE rank per channel, not one per
+ // lane — the two hand-made lane orders collapse into this on migration.
+ rank?: number;
+ // PER-OPERATION OVERRIDES of the base tier. Only operations that DIFFER from
+ // the base appear: the sanitizer normalises an override equal to `tier` away,
+ // so the on-disk document stays a list of exceptions to a list of exceptions.
+ //
+ // `{tier:"normal", overrides:{sync:"paused"}}` is "everything but sync" — the
+ // lossless reading of the retired `excludeFromSync`. Its inverse,
+ // `{tier:"paused", overrides:{sync:"normal"}}`, is "sync only": keep the
+ // playlist and metadata current, dispatch nothing.
+ overrides?: Partial<Record<PriorityOperation, StoredChannelTier>>;
+};
+
+export type ChannelPriority = {
+ focus: ChannelFocus;
+ // ONLY channels that differ from the default appear. An absent slug is
+ // `normal`, unranked — so the default document is empty and "absent document
+ // = today's behaviour" holds byte for byte.
+ channels: Record<string, ChannelPriorityEntry>;
+};
+
+export function defaultChannelPriority(): ChannelPriority {
+ return { focus: { kind: "none" }, channels: {} };
+}
+
+// --- The sanitizer ----------------------------------------------------------
+
+function trimmedSlugs(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ const out: string[] = [];
+ for (const v of value) {
+ if (typeof v !== "string") continue;
+ const s = v.trim();
+ if (s && !out.includes(s)) out.push(s);
+ }
+ return out;
+}
+
+function sanitizeFocus(value: unknown): ChannelFocus {
+ const r = (value ?? {}) as Record<string, unknown>;
+ if (r.kind === "site") {
+ const siteId = typeof r.siteId === "string" ? r.siteId.trim() : "";
+ // A site focus with no site is not a focus. Collapsing it here means every
+ // reader can treat `kind !== "none"` as "something is focused" without
+ // repeating the emptiness check.
+ return siteId ? { kind: "site", siteId } : { kind: "none" };
+ }
+ if (r.kind === "channels") {
+ const slugs = trimmedSlugs(r.slugs);
+ return slugs.length > 0 ? { kind: "channels", slugs } : { kind: "none" };
+ }
+ return { kind: "none" };
+}
+
+// THE OVERRIDE MAP'S NORMALISATION, and each rule is a bug it avoids:
+//
+// 1. AN UNKNOWN OPERATION KEY IS DROPPED. The key space is
+// PRIORITY_OPERATIONS and nothing else; a typo must not sit in the file
+// looking like it does something.
+// 2. AN UNKNOWN TIER VALUE IS DROPPED, not coerced. Coercing it to `normal`
+// (which is what the BASE tier does) would silently UNPAUSE an operation
+// on a paused channel — an override's job is to differ from the base, so a
+// junk one must fall back to the base rather than to a tier nobody chose.
+// 3. AN OVERRIDE EQUAL TO THE BASE IS NORMALISED AWAY, and an empty map with
+// it, so the document stays small and re-sanitizing is identity.
+//
+// Keys are emitted in PRIORITY_OPERATIONS order for a stable file diff.
+function sanitizeOverrides(
+ value: unknown,
+ tier: StoredChannelTier,
+): Partial<Record<PriorityOperation, StoredChannelTier>> | undefined {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return undefined;
+ }
+ const raw = value as Record<string, unknown>;
+ const out: Partial<Record<PriorityOperation, StoredChannelTier>> = {};
+ let any = false;
+ for (const op of PRIORITY_OPERATIONS) {
+ const v = raw[op];
+ if (!isStoredChannelTier(v)) continue;
+ if (v === tier) continue;
+ out[op] = v;
+ any = true;
+ }
+ return any ? out : undefined;
+}
+
+// Coerce a raw `settings.channelPriority` into a clean document.
+//
+// Coerce-to-legal, the settings sanitizers' existing style: an unknown tier is
+// `normal`, a junk rank is dropped, a blank slug is dropped. NOTE what it does
+// NOT do — it never invents a `held`, never touches a lane, and never resolves
+// a focus: an unknown siteId survives sanitation and resolves to no focus group
+// at compile time, so a typo cannot hold the whole corpus and is still visible
+// to the operator in the UI that wrote it.
+export function sanitizeChannelPriority(value: unknown): ChannelPriority {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return defaultChannelPriority();
+ }
+ const r = value as Record<string, unknown>;
+ const rawChannels =
+ r.channels && typeof r.channels === "object" && !Array.isArray(r.channels)
+ ? (r.channels as Record<string, unknown>)
+ : {};
+ const channels: Record<string, ChannelPriorityEntry> = {};
+ // Sorted by the TRIMMED slug, so the on-disk document has a stable key order
+ // and a settings.json diff shows only what an operator changed.
+ const keys = Object.keys(rawChannels)
+ .filter((key) => key.trim())
+ .sort((a, b) => a.trim().localeCompare(b.trim()));
+ for (const key of keys) {
+ const slug = key.trim();
+ const raw = (rawChannels[key] ?? {}) as Record<string, unknown>;
+ // A stored "focus" is not a tier (see CHANNEL_TIERS) — it reads as normal.
+ const tier: StoredChannelTier = isStoredChannelTier(raw.tier)
+ ? raw.tier
+ : DEFAULT_CHANNEL_TIER;
+ const entry: ChannelPriorityEntry = { tier };
+ const overrides = sanitizeOverrides(raw.overrides, tier);
+ if (overrides) entry.overrides = overrides;
+ if (typeof raw.rank === "number" && Number.isFinite(raw.rank)) {
+ // RANK IS ONLY MEANINGFUL WHERE SOMETHING IS ORDERED. A channel that is
+ // paused for EVERY operation is in no group anywhere, so its rank is dead
+ // weight in the file; a `{tier:"paused", overrides:{sync:"normal"}}`
+ // "sync only" channel still orders inside the sync scheduler's tier, so
+ // it keeps one.
+ const orderedSomewhere = PRIORITY_OPERATIONS.some(
+ (op) => (overrides?.[op] ?? tier) !== "paused",
+ );
+ if (orderedSomewhere) entry.rank = Math.floor(raw.rank);
+ }
+ // An entry that says nothing the default does not say is DROPPED, so the
+ // document stays a list of exceptions and re-sanitizing is identity.
+ if (
+ entry.tier === DEFAULT_CHANNEL_TIER &&
+ entry.rank === undefined &&
+ entry.overrides === undefined
+ ) {
+ continue;
+ }
+ channels[slug] = entry;
+ }
+ return { focus: sanitizeFocus(r.focus), channels };
+}
+
+// --- Reading the document ---------------------------------------------------
+
+// The channel's BASE tier — what `/channels` shows in the tier column, and the
+// fallback for every operation the entry does not override. Dispatch should ask
+// `effectiveTier` instead; this is the display answer.
+export function tierOf(model: ChannelPriority, slug: string): StoredChannelTier {
+ return model.channels[slug]?.tier ?? DEFAULT_CHANNEL_TIER;
+}
+
+// THE TIER THAT ACTUALLY DECIDES, for one operation. Every dispatch-side
+// question goes through here: the compiler asks it per lane, `listChannelMeta`
+// asks it for the lane it is listing, and the sync scheduler asks it for
+// "sync". ONE definition of "what does this channel's priority mean for this
+// operation", the way `isGateHeld` is one definition of "is this lane held".
+export function effectiveTier(
+ model: ChannelPriority,
+ slug: string,
+ op: PriorityOperation,
+): StoredChannelTier {
+ const entry = model.channels[slug];
+ if (!entry) return DEFAULT_CHANNEL_TIER;
+ return entry.overrides?.[op] ?? entry.tier;
+}
+
+// The per-operation overrides an entry carries, normalised (see
+// sanitizeOverrides). Empty object when there are none, so a UI can spread it
+// without a null check.
+export function overridesOf(
+ model: ChannelPriority,
+ slug: string,
+): Partial<Record<PriorityOperation, StoredChannelTier>> {
+ return { ...(model.channels[slug]?.overrides ?? {}) };
+}
+
+export function rankOf(model: ChannelPriority, slug: string): number | null {
+ const rank = model.channels[slug]?.rank;
+ return typeof rank === "number" ? rank : null;
+}
+
+// THE ONE PAUSE PREDICATE. `listChannelMeta` (per lane), the sync scheduler's
+// due loop and *Sync all* (op "sync") all ask this and nothing else.
+//
+// `op` is optional only so a display surface can ask about the BASE tier; every
+// dispatch caller names the operation, because with overrides "paused" is not a
+// property of a channel, it is a property of a channel AND an operation.
+export function isChannelPaused(
+ model: ChannelPriority,
+ slug: string,
+ op?: PriorityOperation,
+): boolean {
+ return (op ? effectiveTier(model, slug, op) : tierOf(model, slug)) === "paused";
+}
+
+// The slugs an operation may draw from, input order preserved. S1's
+// `listChannelMeta` filter and S2's due loop are each one call to this.
+export function channelsForOperation(
+ model: ChannelPriority,
+ slugs: readonly string[],
+ op: PriorityOperation,
+): string[] {
+ return slugs.filter((slug) => !isChannelPaused(model, slug, op));
+}
+
+// Tier order as a sortable number: focus 0, normal 1, low 2, paused 3. The sync
+// scheduler sorts by this, then `rank`, then most-overdue-first — so a focus
+// channel due by a minute outranks a low channel due by a day.
+// THE GATE ON THE WHOLE COMPILER, and the plan's "absent document = today's
+// behaviour, byte for byte": no focus and no per-channel entry means the
+// document says nothing, the compiler never runs, and the stored trees stand.
+//
+// It lives HERE, in the model, because five callers in two packages ask it and
+// they must all ask it the same way: the runner (`laneDispatchRoot`), the
+// status payload, the one writer (both for its legacy seed and for the rule
+// that a default document never overwrites a stored tree), the backfill's
+// keep decision, and the two actions that may no longer write a lane's root.
+// S1 kept a copy in controller/autoRunner.ts and S4 a second in the editor;
+// S5 deleted both. A controller may not import the runner without opening an
+// import cycle, which is the other half of why the model owns it.
+export function isDefaultChannelPriority(model: ChannelPriority): boolean {
+ return model.focus.kind === "none" && Object.keys(model.channels).length === 0;
+}
+
+// A CHANNEL WAS RENAMED, so the document has to follow it.
+//
+// The model keys everything by slug — the entry, and the slugs a
+// `{kind:"channels"}` focus names — so a rename that does not pass through
+// here loses the channel's tier, its rank and its per-operation overrides
+// silently (they stay under a slug that no longer exists, and the sanitizer
+// has no way to know they are stale), and drops the channel out of a focus
+// that was explicitly naming it.
+//
+// Pure and total: an unknown `from` is a no-op, a rename ONTO an existing
+// entry keeps the destination's entry rather than clobbering it — the channel
+// controller refuses that rename anyway, so the only way to get here is a
+// document that is already inconsistent, and losing the live channel's
+// settings to a dead one is the worse of the two readings. A `{kind:"site"}`
+// focus needs nothing: it resolves through the site's own `channels[]`, which
+// the rename updates on disk.
+export function renameChannelInPriority(
+ model: ChannelPriority,
+ from: string,
+ to: string,
+): ChannelPriority {
+ const oldSlug = from.trim();
+ const newSlug = to.trim();
+ if (!oldSlug || !newSlug || oldSlug === newSlug) return model;
+ // Nothing in the document mentions the old slug: return the INPUT, not a
+ // copy of it. The writer recompiles on the result either way, so this is
+ // about being honest that nothing moved rather than about the allocation.
+ const named =
+ model.channels[oldSlug] !== undefined ||
+ (model.focus.kind === "channels" && model.focus.slugs.includes(oldSlug));
+ if (!named) return model;
+ const channels: Record<string, ChannelPriorityEntry> = {};
+ for (const [slug, entry] of Object.entries(model.channels)) {
+ if (slug === oldSlug) continue;
+ channels[slug] = entry;
+ }
+ const moved = model.channels[oldSlug];
+ if (moved && channels[newSlug] === undefined) channels[newSlug] = moved;
+ const focus: ChannelFocus =
+ model.focus.kind === "channels"
+ ? {
+ kind: "channels",
+ slugs: model.focus.slugs.map((s) => (s === oldSlug ? newSlug : s)),
+ }
+ : model.focus;
+ return { focus, channels };
+}
+
+export function tierOrder(tier: ChannelTier): number {
+ const i = CHANNEL_TIERS.indexOf(tier);
+ return i < 0 ? CHANNEL_TIERS.length : i;
+}
+
+// siteId -> the channel slugs that site exposes. Built by the caller from
+// `transcripts/sites/*/site.json`; this module does no I/O.
+export type SiteChannelIndex = Readonly<Record<string, readonly string[]>>;
+
+// The channels the focus selector currently names, in compile order.
+//
+// `known` is every channel slug that exists. When supplied, a focus naming a
+// slug that is not there drops it — a site whose membership list has outrun the
+// corpus, or a hand-edited settings.json. When omitted there is no existence
+// filter, which is what a caller that already holds a filtered list wants.
+//
+// AN UNKNOWN siteId RESOLVES TO `[]`, deliberately: an empty resolution
+// compiles NO focus group, so a typo leaves the tree exactly as it would be
+// with no focus at all rather than holding the whole corpus behind a site that
+// does not exist.
+export function resolveFocusSlugs(
+ model: ChannelPriority,
+ siteChannels: SiteChannelIndex,
+ known?: readonly string[],
+): string[] {
+ const focus = model.focus;
+ const raw =
+ focus.kind === "site"
+ ? trimmedSlugs(siteChannels[focus.siteId] ?? [])
+ : focus.kind === "channels"
+ ? trimmedSlugs(focus.slugs)
+ : [];
+ if (raw.length === 0) return [];
+ const exists = known ? new Set(known) : null;
+ return raw.filter((slug) => (exists ? exists.has(slug) : true));
+}
+
+// --- Compiled-tree ids ------------------------------------------------------
+
+// Ids are DERIVED and recognisable on sight, for the same reason
+// `laneRootFromScope`'s are: a lane's fairness memory and its pick log are
+// keyed by leaf id, so a stable id means a recompile that did not move a
+// channel keeps the ledger it had — and a hand-authored leaf is identifiable
+// precisely because it does NOT carry this prefix.
+export const PRIO_ID_PREFIX = "prio-";
+export const PRIO_CATCH_ALL_ID = "prio-all";
+
+export function prioGroupId(tier: ChannelTier): string {
+ return `${PRIO_ID_PREFIX}${tier}`;
+}
+
+export function prioLeafId(tier: ChannelTier, slug: string): string {
+ return `${PRIO_ID_PREFIX}${tier}-${slug}`;
+}
+
+// `prio-normal-foo` -> `{tier:"normal", slug:"foo"}`; anything else -> null.
+// The catch-all is not a channel leaf and answers null.
+export function parsePrioLeafId(
+ id: string,
+): { tier: ChannelTier; slug: string } | null {
+ if (!id.startsWith(PRIO_ID_PREFIX)) return null;
+ const rest = id.slice(PRIO_ID_PREFIX.length);
+ for (const tier of CHANNEL_TIERS) {
+ const head = `${tier}-`;
+ if (!rest.startsWith(head)) continue;
+ const slug = rest.slice(head.length);
+ return slug ? { tier, slug } : null;
+ }
+ return null;
+}
+
+// --- The compiler -----------------------------------------------------------
+
+// Within a group: `rank` ascending, unranked last, then slug. ONE ordering for
+// all three groups, including focus — the operator's tier document is the only
+// place order is expressed, so a site focus and a "focus these" focus cannot
+// disagree about what comes first.
+function orderWithin(model: ChannelPriority, slugs: readonly string[]): string[] {
+ return [...slugs].sort((a, b) => {
+ const ra = rankOf(model, a);
+ const rb = rankOf(model, b);
+ if (ra !== rb) {
+ if (ra === null) return 1;
+ if (rb === null) return -1;
+ return ra - rb;
+ }
+ return a.localeCompare(b);
+ });
+}
+
+function channelLeaf(tier: ChannelTier, slug: string): AutoQueueLeaf {
+ return {
+ id: prioLeafId(tier, slug),
+ match: { type: "channel", value: slug },
+ weight: 1,
+ maxWorkers: null,
+ };
+}
+
+function tierGroup(tier: ChannelTier, slugs: readonly string[]): AutoQueueGroup {
+ return {
+ id: prioGroupId(tier),
+ // STRICT at every level. The outer strict is what makes focus hold the rest;
+ // the inner strict is what the two hand-made lists already meant — they
+ // walked their channels in order and never interleaved.
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children: slugs.map((slug) => channelLeaf(tier, slug)),
+ };
+}
+
+// THE COMPILED TREE, per lane:
+//
+// root strict
+// ├─ prio-focus strict — one bare channel leaf per focus slug (omitted when empty)
+// ├─ prio-normal strict — one bare channel leaf per normal slug (omitted when empty)
+// ├─ prio-low strict — one bare channel leaf per low slug (omitted when empty)
+// └─ prio-all leaf {type:"all"} ← safety net, LAST
+//
+// `slugs` is every channel the lane may draw from; paused channels are filtered
+// here as well as out of the runner's channel list, so a caller that hands the
+// whole corpus in still gets a tree with no paused leaf.
+//
+// The trailing catch-all only ever claims a channel with NO leaf of its own —
+// i.e. one created since the last compile — so drift is always SAFE (bottom
+// priority) and self-heals on the next write. Paused channels never reach it
+// because they are gone from the runner's channel list entirely.
+//
+// `lane` is used for the ROOT id only (`<lane>-root`, the spelling
+// `laneRootFromScope` already produces). The four lanes compile to the same
+// shape by design: the model has ONE rank per channel, not one per lane.
+export function compileLaneRoot(
+ lane: AutoQueueKind,
+ model: ChannelPriority,
+ slugs: readonly string[],
+ focusSlugs: readonly string[],
+): AutoQueueGroup {
+ // PER LANE, through the effective tier: a channel paused for `download` and
+ // normal for `transcription` is absent from one tree and present in the other,
+ // which is the whole point of the override map. The four lanes are only
+ // identical when no channel overrides one of them.
+ const live = trimmedSlugs([...slugs]).filter(
+ (slug) => !isChannelPaused(model, slug, lane),
+ );
+ const focusSet = new Set(
+ trimmedSlugs([...focusSlugs]).filter((slug) => live.includes(slug)),
+ );
+ const focus: string[] = [];
+ const normal: string[] = [];
+ const low: string[] = [];
+ for (const slug of live) {
+ // FOCUS WINS OVER THE STORED TIER (a focused `low` channel is focused);
+ // paused wins over focus, and is already gone above.
+ if (focusSet.has(slug)) focus.push(slug);
+ else if (effectiveTier(model, slug, lane) === "low") low.push(slug);
+ else normal.push(slug);
+ }
+ const children: AutoQueueNode[] = [];
+ for (const [tier, members] of [
+ ["focus", focus],
+ ["normal", normal],
+ ["low", low],
+ ] as ReadonlyArray<[ChannelTier, string[]]>) {
+ if (members.length === 0) continue;
+ children.push(tierGroup(tier, orderWithin(model, members)));
+ }
+ children.push({
+ id: PRIO_CATCH_ALL_ID,
+ match: { type: "all" },
+ weight: 1,
+ maxWorkers: null,
+ });
+ return {
+ id: `${lane}-root`,
+ mode: "strict",
+ weight: 1,
+ maxWorkers: null,
+ children,
+ };
+}
+
+// Every lane's root, from one model. The writer that persists the document
+// assigns these onto `autoQueue[lane].root` while SPREADING each policy, so
+// `held`, `snoozeUntil`, `enabled`, `order` and `maxWorkers` survive.
+//
+// The four trees are identical UNLESS a channel overrides a lane — the model
+// has one rank per channel, so the only thing that can differ between lanes is
+// membership, and the only thing that changes membership is an override.
+export function compileLanes(
+ model: ChannelPriority,
+ slugs: readonly string[],
+ focusSlugs: readonly string[],
+): Record<AutoQueueKind, AutoQueueGroup> {
+ return Object.fromEntries(
+ LANES.map((lane) => [lane, compileLaneRoot(lane, model, slugs, focusSlugs)]),
+ ) as Record<AutoQueueKind, AutoQueueGroup>;
+}
+
+// --- The banner's numbers ---------------------------------------------------
+
+// `buildPendingByLeaf`'s return shape, named structurally: lib/ may not import
+// jobs/ (../architecture.test.ts), and this is the only thing of the engine's
+// this module needs to read.
+export type PendingByLeaf = Readonly<Record<string, readonly string[]>>;
+
+export type FocusSummary = {
+ kind: ChannelFocus["kind"];
+ // Set only for a site focus, so a caller can resolve the site's title.
+ siteId: string | null;
+ // The resolved focus set, in compile order.
+ slugs: readonly string[];
+ channelCount: number;
+ // A focus that resolved to nothing is not active — see resolveFocusSlugs.
+ active: boolean;
+ // Units pending under the compiled focus group, in the lane these counts came
+ // from.
+ focusPending: number;
+ // Units pending everywhere else in that lane, catch-all included.
+ otherPending: number;
+ // The focus is actually HOLDING the lane right now: it has work, so strict
+ // descent never reaches the groups below it.
+ holding: boolean;
+ // How many non-focus channels have pending work while the focus holds. This
+ // is a DISPLAY fact, not an idle reason: a lane whose focus group holds the
+ // rest is not idle, it is dispatching focus work.
+ heldChannels: number;
+};
+
+// The banner's line — "Focus: <name> (N channels) · <units> pending in this
+// lane · M channels held" — from numbers the status panel already computed.
+// Costs one pass over `pendingByLeaf`'s keys and no new read.
+export function focusSummary(
+ model: ChannelPriority,
+ focusSlugs: readonly string[],
+ pendingByLeaf: PendingByLeaf = {},
+): FocusSummary {
+ let focusPending = 0;
+ let otherPending = 0;
+ let heldChannels = 0;
+ for (const [id, ids] of Object.entries(pendingByLeaf)) {
+ const count = ids?.length ?? 0;
+ const parsed = parsePrioLeafId(id);
+ if (parsed?.tier === "focus") {
+ focusPending += count;
+ continue;
+ }
+ otherPending += count;
+ if (parsed && count > 0) heldChannels += 1;
+ }
+ const holding = focusPending > 0;
+ return {
+ kind: model.focus.kind,
+ siteId: model.focus.kind === "site" ? model.focus.siteId : null,
+ slugs: focusSlugs,
+ channelCount: focusSlugs.length,
+ active: model.focus.kind !== "none" && focusSlugs.length > 0,
+ focusPending,
+ otherPending,
+ holding,
+ heldChannels: holding ? heldChannels : 0,
+ };
+}
+
+// --- The migration's pure half ----------------------------------------------
+
+// Just enough of a channel row to migrate it, named structurally so this file
+// does not have to import `ChannelConfig`.
+//
+// NOTE the one key is gone from `ChannelConfig` (S5) and `parseChannelConfig`
+// drops it, so a PARSED config no longer satisfies this usefully — the
+// migration script reads the raw config.json for it
+// (common/bin/migrate-channel-priority.ts), and the editor's legacy seed
+// passes `{}` because it is after the lane ORDER, not the flag.
+export type LegacyChannelRow = {
+ slug: string;
+ config: { excludeFromSync?: boolean | undefined };
+};
+
+// Just enough of `settings.autoQueue`: `AutoQueueSettings` is assignable.
+export type LegacyLaneRoots = Partial<
+ Record<AutoQueueKind, { root?: AutoQueueNode | undefined } | undefined>
+>;
+
+// The two lanes that carry a hand-made channel order today. Digest and backfill
+// are one `{type:"all"}` leaf apiece and contribute no ranking.
+const LEGACY_RANKED_LANES: readonly AutoQueueKind[] = [
+ "transcription",
+ "download",
+];
+
+function bareChannelSlugs(root: AutoQueueNode | undefined): string[] {
+ if (!root) return [];
+ const out: string[] = [];
+ const walk = (node: AutoQueueNode): void => {
+ if (isGroup(node)) {
+ for (const child of node.children) walk(child);
+ return;
+ }
+ // BARE means a channel leaf and nothing else: a leaf narrowed by
+ // `match.bucket` or `match.operation` is a different statement (a retry
+ // lane, an operation subtree) and the priority model has no way to say it,
+ // so it must not be read as a rank. Zero live leaves carry either.
+ if (node.match.type !== "channel") return;
+ const slug = typeof node.match.value === "string" ? node.match.value.trim() : "";
+ if (!slug || node.match.bucket || node.match.operation) return;
+ if (!out.includes(slug)) out.push(slug);
+ };
+ walk(root);
+ return out;
+}
+
+// Does any lane's stored root already carry a COMPILED leaf?
+//
+// `prio-*` ids are produced by `compileLaneRoot` and by nothing else, which is
+// what makes them a reliable "this tree was written by the priority writer"
+// marker — and the marker two callers need before they read a tree as legacy:
+//
+// - `channelPriorityFromLegacy` (below), so a second migration run cannot
+// re-derive ranks from its own output;
+// - the one writer's legacy seed (editor/app/channels/actions.ts), so a
+// document that was cleared back to empty on a corpus whose trees are
+// already compiled is not re-seeded from those compiled trees.
+//
+// Both hazards are the same one, and it is not hypothetical: compiled channel
+// leaves ARE bare channel leaves, so `bareChannelSlugs` reads them happily and
+// would replace a hand-made 9+9 order with a reading of the tree that order
+// already produced — dense, alphabetical inside each tier, and irrecoverable.
+export function hasCompiledLaneRoots(autoQueue: LegacyLaneRoots): boolean {
+ const seen = (node: AutoQueueNode | undefined): boolean => {
+ if (!node) return false;
+ if (typeof node.id === "string" && node.id.startsWith(PRIO_ID_PREFIX)) {
+ return true;
+ }
+ return isGroup(node) ? node.children.some(seen) : false;
+ };
+ return LANES.some((lane) => seen(autoQueue[lane]?.root));
+}
+
+// THE LEGACY READ: one exclusion flag and two hand-made lane orders become one
+// document. Pure, no I/O, and asserted through `sanitizeChannelPriority` — the
+// same shape `lib/laneMigration.ts` uses, for the same reason.
+//
+// IT IS LOSSLESS, AND THAT IS THE POINT OF THE OVERRIDE MAP. `excludeFromSync`
+// meant "stop syncing", not "stop everything", and the override map says
+// exactly that: `{tier: <base>, overrides: {sync: "paused"}}`. So a channel
+// that is both excluded from sync and ranked in the download tree — on the live
+// corpus, `omnivods-odysee` — KEEPS DOWNLOADING, exactly as it does today. No
+// lane's membership moves, which is what makes the migration a settings rewrite
+// rather than a behaviour change, and which retires the plan's open question
+// about which of the 15 excluded channels should be paused outright: none of
+// them are, by construction. An operator who wants one paused everywhere sets
+// its base tier afterwards, deliberately.
+//
+// THREE RULES:
+//
+// 1. `excludeFromSync: true` BECOMES A SYNC OVERRIDE, never a base tier. The
+// base tier stays `normal` and the rank (if the channel has one) stands.
+// 2. THE TWO LANE ORDERS COLLAPSE TO ONE. A channel named by a bare channel
+// leaf in EITHER root gets the LOWER of its two indices as its rank — its
+// position among that root's BARE CHANNEL LEAVES, which is a dense order
+// and is the leaf index exactly when every leaf is bare. All 22 live
+// leaves are, so on the live tree the two readings coincide; on a
+// hand-edited tree the dense one is the one that means something, because
+// a bucket leaf the model cannot express must not leave a hole in the rank.
+// The live lists disagree on six channels and on the Quartering ordering;
+// the model has one rank per channel, so one of the two orders has to give
+// and "whichever lane ranked it higher" is the answer that loses no
+// priority. This is the one thing the migration DOES change, and it is
+// measured with plans/tools/phase1-numbers.ts.
+//
+// THE MERGED RANKS ARE THEN RENUMBERED DENSELY, 0..n-1, and that is not
+// cosmetic: the merge produces COLLISIONS (four pairs on the live trees —
+// `the-quartering` and `the-quartering-rumble` both land on 1, because
+// each lane ranked one of them second), and a collision is resolved by
+// `orderWithin`'s slug fallback, which is NEITHER lane's order. The tie
+// rule, in order: the lane that ranked the channel higher (that is the
+// merged index itself), then the TRANSCRIPTION lane's own order — the
+// longer-standing of the two hand-made lists, and the one that ranks the
+// Quartering channels the way the operator most recently arranged them —
+// then the slug. A channel the transcription lane never ranked sorts
+// after every channel it did, within the same merged index.
+//
+// 4. IT DOES NOT RE-DERIVE FROM ITS OWN OUTPUT. A compiled tree is made of
+// bare channel leaves, so rule 2 would read one perfectly happily and
+// collapse a hand-made order into a reading of the tree that order
+// produced. `hasCompiledLaneRoots` is the detector; `stored` is what is
+// returned instead, so a second migration run is a no-op rather than a
+// quiet rewrite.
+// 3. EVERYTHING ELSE IS ABSENT — normal, unranked — and the focus starts at
+// `none`. A migration does not start a focus.
+//
+// `excludeFromBuild` is NOT read: it is a different axis (publishing, not
+// scheduling) and the lowest tier must not gate export.
+export function channelPriorityFromLegacy(
+ configs: readonly LegacyChannelRow[],
+ autoQueue: LegacyLaneRoots,
+ // The document already on disk. Returned unchanged when the trees are
+ // compiled — see rule 4. Defaults to the empty document, which is what a
+ // caller with nothing stored has anyway.
+ stored: ChannelPriority = defaultChannelPriority(),
+): ChannelPriority {
+ if (hasCompiledLaneRoots(autoQueue)) return sanitizeChannelPriority(stored);
+ const syncPaused = new Set<string>();
+ for (const row of configs) {
+ const slug = typeof row?.slug === "string" ? row.slug.trim() : "";
+ if (!slug) continue;
+ if (row.config?.excludeFromSync === true) syncPaused.add(slug);
+ }
+ const merged = new Map<string, number>();
+ for (const lane of LEGACY_RANKED_LANES) {
+ const slugs = bareChannelSlugs(autoQueue[lane]?.root);
+ slugs.forEach((slug, index) => {
+ const seen = merged.get(slug);
+ if (seen === undefined || index < seen) merged.set(slug, index);
+ });
+ }
+ // The tie-break lane's own order, for the renumbering below.
+ const transcription = bareChannelSlugs(autoQueue.transcription?.root);
+ const tieIndex = (slug: string): number => {
+ const i = transcription.indexOf(slug);
+ return i === -1 ? Number.POSITIVE_INFINITY : i;
+ };
+ const ranks = new Map<string, number>();
+ [...merged.entries()]
+ .sort(
+ ([aSlug, a], [bSlug, b]) =>
+ a - b || tieIndex(aSlug) - tieIndex(bSlug) || aSlug.localeCompare(bSlug),
+ )
+ .forEach(([slug], index) => ranks.set(slug, index));
+ const channels: Record<string, ChannelPriorityEntry> = {};
+ const touch = (slug: string): ChannelPriorityEntry =>
+ (channels[slug] ??= { tier: DEFAULT_CHANNEL_TIER });
+ for (const slug of syncPaused) {
+ touch(slug).overrides = { sync: "paused" };
+ }
+ for (const [slug, rank] of ranks) {
+ touch(slug).rank = rank;
+ }
+ return sanitizeChannelPriority({ focus: { kind: "none" }, channels });
+}
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -21,6 +21,11 @@ import {
} from "./workers";
import type { AutoQueueSettings } from "./autoQueueTypes";
import {
+ defaultChannelPriority,
+ sanitizeChannelPriority,
+ type ChannelPriority,
+} from "./channelPriority";
+import {
migrateHeldToLanes,
migrateSweepsToLanes,
} from "./laneMigration";
@@ -63,6 +68,7 @@ import {
export type { Worker } from "./workers";
export type { AutoQueueSettings } from "./autoQueueTypes";
+export type { ChannelPriority } from "./channelPriority";
// Transcribe placeholder/arg helpers now live with the whisper-cpp app in
// transcriptionApps.ts. Re-exported here so existing import sites keep working.
@@ -198,6 +204,14 @@ export type SiteSettings = {
// Independent of syncScheduler (which decides staleness, not work order). See
// common/jobs/autoQueuePolicy.ts.
autoQueue: AutoQueueSettings;
+ // THE OPERATOR-FACING PRIORITY MODEL: one tier per channel plus one
+ // corpus-wide focus selector. It is the SOURCE the four `autoQueue[lane].root`
+ // trees are compiled from (common/lib/channelPriority.ts), not a second
+ // mechanism beside them — and its `paused` tier is the one part that is not a
+ // tree shape, filtering the runner's channel list instead. An empty document
+ // (the default) is today's behaviour exactly: no focus, every channel normal,
+ // the stored trees stand.
+ channelPriority: ChannelPriority;
// Default social links applied to every site that doesn't define its own.
// A site inherits these unless its site.json carries an explicit
// `socialLinks` array — see Site.socialLinks / resolveSocialLinks in
@@ -1063,6 +1077,7 @@ function defaults(): SiteSettings {
autoRefreshIntervalSeconds: AUTO_REFRESH_INTERVAL_DEFAULT_SECONDS,
syncScheduler: defaultSyncScheduler(),
autoQueue: defaultAutoQueue(),
+ channelPriority: defaultChannelPriority(),
socialLinks: [],
homepageUrl: "",
savedVideoBackup: defaultSavedVideoBackup(),
@@ -1449,6 +1464,11 @@ export function getSettings(): SiteSettings {
// no longer tell "absent" from "default". It never enables a lane the sweep
// flag did not. See lib/laneMigration.ts.
merged.autoQueue = sanitizeAutoQueue(migrateSweepsToLanes(parsed));
+ // No migration beside it: the legacy read (`channelPriorityFromLegacy`) needs
+ // 68 config.json files and getSettings is synchronous and reads one. It is a
+ // one-shot offline script instead, and an absent document sanitizes to the
+ // empty one, which means today's behaviour.
+ merged.channelPriority = sanitizeChannelPriority(merged.channelPriority);
merged.socialLinks = parseSocialLinks(merged.socialLinks);
merged.homepageUrl = normalizeHomepageUrl(merged.homepageUrl);
merged.savedVideoBackup = sanitizeSavedVideoBackup(merged.savedVideoBackup);
@@ -1660,6 +1680,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
),
syncScheduler: sanitizeSyncScheduler(next.syncScheduler),
autoQueue: sanitizeAutoQueue(next.autoQueue),
+ channelPriority: sanitizeChannelPriority(next.channelPriority),
socialLinks,
homepageUrl: normalizeHomepageUrl(next.homepageUrl),
savedVideoBackup: sanitizeSavedVideoBackup(next.savedVideoBackup),
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -8,6 +8,7 @@ import {
type ChannelGroup,
} from "./channelGroups";
import { getPaths, type Paths } from "./paths";
+import type { SiteChannelIndex } from "./channelPriority";
import { parseAccent } from "./accent";
import {
getSettings,
@@ -305,6 +306,29 @@ export function siteChannelSlugs(site: Site): Set<string> {
return new Set(site.channels.map((c) => c.slug));
}
+// siteId -> that site's channel slugs: the one shape `resolveFocusSlugs`
+// (lib/channelPriority.ts) resolves a `{kind:"site"}` focus against.
+//
+// ONE SPELLING, four callers. It was inlined four times — the runner's
+// `priorityContextFor`, the sync tick, the /channels writer and the status
+// payload — and four copies of "read every site.json and map it" is four
+// places for a focus to resolve against a different set. Read at the moment
+// the focus is resolved, never stored: a site focus tracks the site's
+// membership rather than freezing a list, which is the whole reason
+// `focus.kind === "site"` exists.
+//
+// THE CALLER DECIDES WHETHER TO CALL IT AT ALL. `{kind:"channels"}` and
+// `{kind:"none"}` resolve from the document alone, so every caller gates this
+// on `focus.kind === "site"` and a corpus with no site focus never reads the
+// sites directory.
+export function siteChannelIndex(paths: Paths = getPaths()): SiteChannelIndex {
+ const index: Record<string, string[]> = {};
+ for (const site of listSites(paths)) {
+ index[site.siteId] = [...siteChannelSlugs(site)];
+ }
+ return index;
+}
+
// The effective social links for a site: its own override when present, else
// the global default. Pass `settings` to avoid a redundant read; defaults to
// getSettings() for callers that don't already have it.
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
- **A channel's media can live on another drive.** A channel page has a **Storage** panel: where its media actually is, how much audio is on disk, how much room is free on the volume holding it, and **Move media to…** — give it a directory on another disk, press *Preview* to see the bytes and the free space there, and the move copies, **verifies**, and only then swaps `data/` for a link to the new location and records it. **Move back in place** reverses it. The source is never touched until the copy has verified, so a cancelled or crashed move leaves everything where it was and the partial copy resumable; re-running finishes it. Nothing else changes: every page, every job, yt-dlp and the search index read the channel exactly as before, because the path they use is unchanged. **The point is what happens when the drive is not mounted.** `data/` reads as empty then, and an empty `data/` means "nothing has been downloaded" to the download runner — an instruction to re-fetch the entire channel onto the disk that was too full to hold it. So an unreachable channel is **refused rather than guessed at**: its media jobs will not start, the four lane runners skip it (and keep running every other channel — this is not a lane stop), its report will not regenerate over an empty directory, and a red **Media unreachable** badge names the path on `/channels`, on the dashboard and on the channel itself. A relocated-and-reachable channel gets a neutral badge saying where; a channel in place gets none. The low-disk floor now measures **the volume the bytes are actually going to** rather than always the corpus disk, and holds each volume separately — a full SSD no longer pauses downloads landing on the platter. The **Media location** line on a channel's Configure form is read-only on purpose: it is a record of what is on disk, written only by a move that succeeded. The cold drive is typed **once**: **Settings → Default media root** seeds the root box in every channel's Storage panel, and `/channels` rows can now be ticked — select several and **Move media to…** queues one job per channel on that channel's own queue, so they serialize instead of fanning out, each one running its own space check at run time rather than at enqueue time (a root that fills partway through refuses the remainder cleanly, and a channel already on that root is skipped rather than failed). The default is a default and nothing more: it is never read by the move itself, which always takes an explicit root, and a relocated channel is not thereby deprioritized. **Nothing moves on its own, and nothing on disk changes until you move a channel.**
+- **Channels have priorities now, and the auto-queue's rules are generated from them.** Focusing on one group of channels — "finish Jeralyzer, hold the rest" — used to mean hand-editing four rule trees, and the only per-channel switch on `/channels` was **Sync included / excluded**, which gated sync and nothing else. Every channel row now carries a **tier** — *Normal*, *Low* or *Paused* — plus a corpus-wide **focus**: pick channels and press *Focus these*, or focus a whole site, and every lane runs the focused channels until they have nothing left, then falls through to the rest and retakes the lane the moment new focused work arrives. A focus is one fact, not four: the download, transcription, digest and speaker lanes are all held by it, and each lane's console carries a banner saying what is focused, how much of it is pending there, how many channels are held behind it, and **End focus**. Behind the disclosure on each row, any single operation can be pinned to its own tier — "keep this channel's playlist current but stop downloading it" is a *download* pin, and *Sync only* is a preset for it. A paused channel is dropped from the automatic lanes and from the sync scheduler, and **still runs from every Run button**: a hold is not a stop. Its row dims and its Build toggle is untouched, because publishing is a different question from scheduling. The four rule trees are **generated** from all of this: the policy editor on an operation's page shows them read-only with a link back to `/channels`, keeps editing everything that is not generated (enable, workers, order, the replace-auto-captions lane), and the channel leaves you had are replaced by the compiled ones. **Sync included / excluded is gone**, and it is the same statement said better: the 15 channels that carried it become *paused for sync alone* and keep every lane they were on. **Run the migration before you first start this version.** `Sync included / excluded` is a deleted field, and until the migration has moved those 15 channels to *paused for sync*, the editor reads them as having said nothing about sync — so they are back in the schedule, back in **Sync every channel**, back in each group's **Sync**, and shown as auto-sync eligible. Nothing downloads or transcribes differently, and the automatic tick only fires if your scheduler heartbeat is on, but a *Sync all* click in that window sweeps channels you had excluded. The order is: **stop the editor → `pnpm -C common exec tsx bin/migrate-channel-priority.ts` → start it again.** Run it with `--dry-run` first to see exactly what it would write, per channel, and what each row was derived from; the real run takes its own timestamped backup of `settings.json` beside the file, so there is nothing to copy by hand. After that it is a no-op — run it twice and the second run changes nothing. Your rule trees survive either way: they are what the migration reads the channel order out of, and if you set a tier before running it, the first save seeds itself from those same trees rather than replacing the order you hand-built.
- **Every pipeline is dispatched by one thing now: its lane’s runner. The two corpus sweeps and the arbiter are gone.** Digest and Speaker work were driven by a *sweep* — a corpus walk armed by its own switch, with its own scope, its own order and its own console — while Download and Transcription were driven by the auto-queue runner, with rules, a claim ladder, a next-up and a pick log. Two mechanisms, two vocabularies, two sets of bugs. There is one: **each of the four lanes has a runner, a rule list, and Start / Drain / Stop beside its pause**, on the operation’s own page. Arming a corpus pass is switching the lane on; scoping it to particular channels or operations is a *rule*, written the same way auto-transcribe’s have been written since it shipped. The dashboard and the widget keep a one-click switch per lane — **Run every channel** / **Stop the lane** where they said *Sweep every channel* / *Stop sweeping* — and the scope lives on the lane’s page, where you can see what it would do next. **Your armed scope is carried over, and no lane is switched on that was not.** The ten settings fields the sweeps used (`digest.sweepEnabled`, `sweepChannels`, `recencyOrder`, `recencyReach`; `backfill.sweepEnabled`, `sweepKinds`, `sweepChannels`, `order`, `reach`, `weight`) are read once and written into the lane’s rules the first time the editor starts: a sweep armed on three channels becomes three rules, an unscoped one becomes a single *every channel* rule, and a disarmed sweep becomes a switched-off lane. What is retired rather than migrated: **Reach**, because a rule already orders every video it claims across every channel — which rule goes first is the rule list’s job; the digest **order**, whose real meaning was always *newest day first, shortest video within a day* and which the lane spells as **Shortest first** (pick *Newest first* there if you want the date order alone); and the backfill lane’s **Resource share**, which was one number answering two different questions. A lane now stands aside for transcription when it would actually compete for the graphics card, and keeps its slots when it would not — so speaker-naming over an LLM endpoint no longer parks itself behind a transcription it was not competing with. **The arbiter, which never ran a single unit in production, is deleted**; the runner is what dispatches an operation-named rule. **Nothing on disk changes**, and the retired keys are left in `settings.json` — harmless, ignored, and yours to delete.
- **The transcode operation is gone — it never fired.** A channel page had a *Transcode* stage, `/operations/transcode` had a "no console here" panel, `/cleanup` offered "Clear failed transcodings", and the video list drew a third status dot — all for a re-encode step built against two failures that never happened in production: in 68 channels, no snapshot has ever listed a video as missing its target format, no `failed-transcodings` file has ever held an id, and only four channels even met the stage's gate. Transcription never needed it — a video whose audio is in another format transcribes from that file. What stayed is everything that was never the operation's: the download path still re-encodes what it extracts itself, the video page still offers **Transcode audio.\<ext\> → \<fmt\>** per file, and both audio-format sweeps on the Cleanup stage and `/cleanup` are unchanged (gated on the channel having an `audioFormat`, which is what they compare against). The snapshot bucket behind the sweep is `wrongFormatAudio` now — its operator-facing name — and old reports keep their stray key until their next refresh. A `?stage=transcode` bookmark opens the channel overview. **Nothing on disk changes.** Also: the Pool's running-jobs list names the eight kinds its buttons enqueue, and the site's Search aliases tab no longer carries a "no site selected" branch that could not run.
- **A site has tabs, and the family has one page.** Charts, Search aliases, Deploy, Build and Homepage were five sidebar entries beside *Sites*, three of them reading the site from a `?site=` parameter the sidebar picker had to seed, one of them (Build) about no site at all, and one (Homepage) about the family's own hub. A site is one thing now: **`/sites/<id>` is Settings · Charts · Search aliases · Publish**, the site named in the path, the picker following it (and Dashboard and Channels following the picker). **`/sites` is the family page**: the list, then *Release notes*, *Build all sites* with the Basic/Docker mode, the *Hub*, and the *Pool* — the corpus-wide index, stats, sidecar and archive jobs — folded under a disclosure. Search aliases keep both sections on the site's tab: the global dictionary and the site's overrides. Every button, label and log is unchanged; "Select a specific site from the sidebar" is gone because a site's page always has one. The five routes redirect — a `?site=<id>` bookmark lands on that site's tab (the query rides along), `?site=__all__` and the bare routes on `/sites`; a bookmark to a deleted site 404s there exactly as `/sites/<id>` does. The Sites group is one entry; the nav is **eleven**, the IA doc's end state. **Nothing on disk changes.**
diff --git a/editor/app/api/widget/sync/route.ts b/editor/app/api/widget/sync/route.ts
@@ -187,6 +187,7 @@ export async function buildWidgetSyncPayload(): Promise<WidgetSyncPayload> {
scheduler: settings.syncScheduler,
state,
now,
+ priority: settings.channelPriority,
});
const eligible = view.filter((v) => v.autoSyncEligible);
let nextRunAt: number | null = null;
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -29,7 +29,39 @@ import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotSc
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { runManagedFunction } from "yt-dlp-transcript-common/jobs/streamCommand";
import { drainStream } from "yt-dlp-transcript-common/jobs/drainStream";
-import type { Site } from "yt-dlp-transcript-common/lib/site";
+import {
+ siteChannelIndex,
+ type Site,
+} from "yt-dlp-transcript-common/lib/site";
+import {
+ getSettings,
+ writeSettings,
+} from "yt-dlp-transcript-common/lib/settings";
+import {
+ LANES,
+ type AutoQueueKind,
+} from "yt-dlp-transcript-common/lib/autoQueueTypes";
+import {
+ channelPriorityFromLegacy,
+ compileLanes,
+ DEFAULT_CHANNEL_TIER,
+ effectiveTier,
+ hasCompiledLaneRoots,
+ isChannelPaused,
+ isDefaultChannelPriority,
+ rankOf,
+ renameChannelInPriority,
+ resolveFocusSlugs,
+ sanitizeChannelPriority,
+ tierOrder,
+ type ChannelFocus,
+ type ChannelPriority,
+ type ChannelPriorityEntry,
+ type PriorityOperation,
+ type SiteChannelIndex,
+ type StoredChannelTier,
+} from "yt-dlp-transcript-common/lib/channelPriority";
+import { startAutoRunner } from "yt-dlp-transcript-common/controller/autoRunner";
import { activeSyncSlugs } from "yt-dlp-transcript-common/jobs/syncJobs";
import {
readSchedulerState,
@@ -47,7 +79,6 @@ import {
import { queueForSlugs, type QueueOutcome } from "./lib/queueForSlugs";
import { storePlaylistAction, syncAction } from "./[slug]/pipelineActions";
import { fetchPostsAction } from "./[slug]/socialActions";
-import { prioritizeChannelDownloadAction } from "../operations/actions";
export type ActionResult = { error: string } | undefined;
@@ -186,6 +217,16 @@ export async function createChannelAction(
return { error: `Channel "${slug}" already exists` };
}
await createChannel(paths, slug, config);
+ // THE COMPILED TREES NAME THEIR CHANNELS. A channel created since the last
+ // priority write has no leaf of its own and falls to the trailing catch-all
+ // — safe (bottom priority) and self-healing, but only on the next write. The
+ // recompile IS that write, through the one writer. Best-effort: the channel
+ // exists either way, and the runner compiles per tick regardless.
+ try {
+ await recompileChannelPriorityAction();
+ } catch {
+ /* best-effort — the channel was created regardless */
+ }
try {
await applySiteWrites(siteWrites, paths);
} catch (e) {
@@ -218,12 +259,15 @@ export async function createChannelAction(
/* best-effort — the channel was created regardless */
}
}
- // Optional "Add to top of auto-queue": prepend a channel leaf at the head of
- // the download policy tree and start the runner, for a channel you want
- // auto-downloading fast.
+ // Optional "Add to top of auto-queue": give the channel the first rank in the
+ // priority document, enable the download lane and start its runner, for a
+ // channel you want auto-downloading fast. The local writer rather than
+ // operations/actions.ts' wrapper, which imports this file — a cycle between
+ // two "use server" modules is not worth one `startAutoRunner` call.
if (config.url && formData.get("prioritizeDownload") != null) {
try {
- await prioritizeChannelDownloadAction(slug);
+ await prioritizeChannelDownloadPriorityAction(slug);
+ await startAutoRunner("download");
} catch {
/* best-effort — the channel was created regardless */
}
@@ -419,23 +463,6 @@ export async function toggleChannelBuildInclusionAction(
return undefined;
}
-export async function toggleChannelSyncInclusionAction(
- slug: string,
-): Promise<ActionResult> {
- const paths = getPaths();
- const existing = await readChannelConfig(paths, slug);
- if (!existing) return { error: `Channel "${slug}" not found` };
- const next = { ...existing };
- if (existing.excludeFromSync) {
- delete next.excludeFromSync;
- } else {
- next.excludeFromSync = true;
- }
- await writeChannelConfig(paths, slug, next);
- revalidatePath("/channels");
- return undefined;
-}
-
// Toggle whether this channel's reclaimable bytes count toward the aggregate
// "cleanable data" total on the /cleanup page (and its sidebar badge). The
// cleanup sweeps themselves stay available regardless; this only flips the
@@ -458,6 +485,15 @@ export async function toggleChannelCleanupInclusionAction(
return undefined;
}
+// Unranked sorts AFTER every ranked sibling — `orderWithin`'s rule in the
+// compiler, not a plain numeric compare, which would put a missing rank first.
+function compareSyncRank(a: number | null, b: number | null): number {
+ if (a === b) return 0;
+ if (a === null) return 1;
+ if (b === null) return -1;
+ return a - b;
+}
+
export type SyncAllResult = QueueOutcome;
// Queue a sync for every eligible channel. Each sync decides for itself whether
@@ -471,19 +507,41 @@ export async function syncAllChannelsAction(
const channels = await listChannelConfigs(paths);
const bySlug = new Map(channels.map((c) => [c.slug, c.config]));
const active = activeSyncSlugs();
- const outcome = await queueForSlugs(
- channels.map((c) => c.slug),
- {
- skip: (slug) => {
- const config = bySlug.get(slug);
- if (!config?.url) return "no url";
- if (config.excludeFromSync) return "excluded from sync all";
- if (active.has(slug)) return "already running";
- return null;
- },
- run: (slug) => syncAction(slug, undefined, opts?.fullSweep),
- },
+ // THE SAME ANSWER THE SCHEDULER GIVES, asked of the `sync` OPERATION.
+ //
+ // A manual pool sweep and the automatic one must agree about which channels
+ // are in the pool: the group Sync buttons ask the priority document through
+ // `stationWorkFor` and the scheduler asks it in `selectDueChannels`, so this
+ // loop asks the same question. It is the ONLY question now — S5 deleted
+ // `excludeFromSync` and migrated the 15 channels that carried it.
+ const priority = getSettings().channelPriority;
+ const slugs = channels.map((c) => c.slug);
+ const focus = new Set(
+ resolveFocusSlugs(priority, siteChannelIndex(paths), slugs),
+ );
+ // ORDER: focus, then tier, then rank, then slug. `queueForSlugs` runs the
+ // list in order and each sync takes a slot on the platform queue, so on a
+ // 68-channel pool the order IS the priority — the focus channels' syncs are
+ // the ones that land first.
+ const order = [...slugs].sort(
+ (a, b) =>
+ tierOrder(focus.has(a) ? "focus" : effectiveTier(priority, a, "sync")) -
+ tierOrder(
+ focus.has(b) ? "focus" : effectiveTier(priority, b, "sync"),
+ ) ||
+ compareSyncRank(rankOf(priority, a), rankOf(priority, b)) ||
+ a.localeCompare(b),
);
+ const outcome = await queueForSlugs(order, {
+ skip: (slug) => {
+ const config = bySlug.get(slug);
+ if (!config?.url) return "no url";
+ if (isChannelPaused(priority, slug, "sync")) return "paused for sync";
+ if (active.has(slug)) return "already running";
+ return null;
+ },
+ run: (slug) => syncAction(slug, undefined, opts?.fullSweep),
+ });
// Record the sweep's freshness marker for the monitor widget's last-sync
// readout. Read-modify-write right before the write keeps the clobber window
// vs. a concurrent scheduler tick minimal (single-user editor — acceptable).
@@ -508,6 +566,14 @@ export async function deleteChannelAction(
};
}
await deleteChannel(getPaths(), slug);
+ // Same reason as createChannelAction: the deleted channel keeps a leaf in
+ // every compiled tree until something recompiles. A leaf matching nothing is
+ // harmless to dispatch and confusing to read.
+ try {
+ await recompileChannelPriorityAction();
+ } catch {
+ /* best-effort — the channel is gone either way */
+ }
revalidatePath("/channels");
revalidatePath("/");
redirect("/channels");
@@ -568,6 +634,28 @@ export async function renameChannelAction(
} catch (e) {
return { error: (e as Error).message };
}
+ // THE PRIORITY DOCUMENT KEYS BY SLUG, so it has to follow the rename or the
+ // channel's tier, rank and per-operation overrides stay under a slug that no
+ // longer exists — silently, because nothing can tell a stale entry from a
+ // deliberate one — a `{kind:"channels"}` focus stops naming it, and every
+ // compiled root keeps a `prio-*-<oldSlug>` leaf matching nothing.
+ //
+ // Through the one writer, like create and delete: the re-key is the pure
+ // `renameChannelInPriority`, and the writer recompiles the four roots in the
+ // same `writeSettings`. Best-effort — the directory has already moved, and
+ // reporting a settings failure as a rename failure would be a lie.
+ try {
+ await saveChannelPriorityAction({
+ kind: "rename",
+ from: oldSlug,
+ to: newSlug,
+ });
+ } catch (e) {
+ console.warn(
+ `Channel rename ${oldSlug} -> ${newSlug}: channel priority not updated:`,
+ (e as Error).message,
+ );
+ }
// The directory move succeeded; any warnings are non-fatal metadata-migration
// problems. Log them (we redirect on success, so there's no UI to show them).
if (result.warnings.length > 0) {
@@ -580,3 +668,308 @@ export async function renameChannelAction(
revalidatePath("/");
redirect(`/channels/${newSlug}`);
}
+
+// ---------------------------------------------------------------------------
+// CHANNEL PRIORITY — the one writer
+// ---------------------------------------------------------------------------
+
+// EVERY WRITE OF `settings.channelPriority` GOES THROUGH `saveChannelPriorityAction`.
+//
+// One writer, for the same reason `withGateHeld` is the one writer of a lane's
+// `held`: the document is not the only thing a priority change produces. The
+// four `autoQueue[lane].root` trees are COMPILED from it (common/lib/
+// channelPriority.ts), so a second writer would leave the model and the trees
+// disagreeing until whoever wrote next happened to recompile. The recompile
+// therefore happens HERE, in the same `writeSettings` call that persists the
+// document, and every control on /channels funnels through the edit vocabulary
+// below rather than assembling a `ChannelPriority` of its own.
+//
+// The edit is a SERIALIZABLE union, not a callback: a server action's arguments
+// cross the network boundary, so "apply this function to the current document"
+// is not expressible. Each variant is one operator gesture.
+export type ChannelPriorityEdit =
+ // Set the BASE tier of one or more channels. Overrides survive; the sanitizer
+ // drops any that now equal the base.
+ | { kind: "tier"; slugs: string[]; tier: StoredChannelTier }
+ // Pin ONE operation to a tier, or clear the pin (`tier: null` = inherit).
+ | { kind: "operation"; slugs: string[]; operation: PriorityOperation; tier: StoredChannelTier | null }
+ // The two presets. "sync-only" is `{tier:"paused", overrides:{sync:"normal"}}`
+ // — keep the playlist current, dispatch nothing. "clear" returns the channel
+ // to the default (normal, unranked, unpinned) by dropping its entry.
+ | { kind: "preset"; slugs: string[]; preset: "sync-only" | "clear" }
+ // The corpus-wide focus selector, including `{kind:"none"}` (End focus).
+ | { kind: "focus"; focus: ChannelFocus }
+ // "Add to the top of the download queue" — the channel takes the FIRST rank
+ // and everything ranked at or below it shifts down one. Its base tier is
+ // forced to `normal` because the gesture is "run this next" and a `low` or
+ // `paused` channel ranked first is still behind (or absent from) every
+ // normal one. See prioritizeChannelDownloadAction.
+ | { kind: "promote"; slug: string }
+ // NOT AN EDIT: the channel POPULATION changed (a channel was created or
+ // deleted), so the four trees have to be re-derived from an unchanged
+ // document. Never seeds — see the writer.
+ | { kind: "recompile" }
+ // A channel was RENAMED. The document keys by slug, so the entry and any
+ // focus naming it move with it. Never seeds, for the same reason a
+ // recompile does not: a rename is not a statement about priority.
+ | { kind: "rename"; from: string; to: string };
+
+function entryFor(
+ model: ChannelPriority,
+ slug: string,
+): ChannelPriorityEntry {
+ const existing = model.channels[slug];
+ return existing
+ ? { ...existing, overrides: { ...(existing.overrides ?? {}) } }
+ : { tier: DEFAULT_CHANNEL_TIER };
+}
+
+// Pure. The sanitizer is what normalises the result — an override equal to the
+// base is dropped there, and so is an entry that says nothing the default does
+// not — so this only has to state the gesture.
+function applyPriorityEdit(
+ model: ChannelPriority,
+ edit: ChannelPriorityEdit,
+): ChannelPriority {
+ if (edit.kind === "recompile") return model;
+ if (edit.kind === "rename") {
+ return renameChannelInPriority(model, edit.from, edit.to);
+ }
+ if (edit.kind === "focus") return { ...model, focus: edit.focus };
+ if (edit.kind === "promote") {
+ const slug = edit.slug.trim();
+ if (!slug) return model;
+ const channels: Record<string, ChannelPriorityEntry> = {};
+ // The rank to take: the smallest one in use, or 0 when nothing is ranked.
+ // Everything at or below it shifts down one, so the promoted channel is
+ // strictly first and the existing order below is preserved exactly.
+ let top = Number.POSITIVE_INFINITY;
+ for (const [s, entry] of Object.entries(model.channels)) {
+ if (s !== slug && entry.rank !== undefined) {
+ top = Math.min(top, entry.rank);
+ }
+ }
+ const rank = Number.isFinite(top) ? top : 0;
+ for (const [s, entry] of Object.entries(model.channels)) {
+ channels[s] =
+ s !== slug && entry.rank !== undefined && entry.rank >= rank
+ ? { ...entry, rank: entry.rank + 1 }
+ : { ...entry };
+ }
+ channels[slug] = { ...entryFor(model, slug), tier: "normal", rank };
+ return { ...model, channels };
+ }
+ const channels: Record<string, ChannelPriorityEntry> = { ...model.channels };
+ for (const raw of edit.slugs) {
+ const slug = raw.trim();
+ if (!slug) continue;
+ if (edit.kind === "tier") {
+ channels[slug] = { ...entryFor(model, slug), tier: edit.tier };
+ continue;
+ }
+ if (edit.kind === "operation") {
+ const entry = entryFor(model, slug);
+ const overrides = { ...(entry.overrides ?? {}) };
+ if (edit.tier === null) delete overrides[edit.operation];
+ else overrides[edit.operation] = edit.tier;
+ channels[slug] = { ...entry, overrides };
+ continue;
+ }
+ if (edit.preset === "clear") {
+ delete channels[slug];
+ continue;
+ }
+ // "sync-only": paused everywhere, normal for sync. Its rank survives —
+ // the sync scheduler still orders it.
+ const entry = entryFor(model, slug);
+ channels[slug] = {
+ ...entry,
+ tier: "paused",
+ overrides: { sync: "normal" },
+ };
+ }
+ return { ...model, channels };
+}
+
+// THE ONE WRITER. Reads the current settings, applies one edit through
+// `sanitizeChannelPriority`, recompiles the four lane roots from the result and
+// persists both in a single `writeSettings`.
+//
+// Each policy is SPREAD rather than rebuilt, so `enabled`, `held`, `order`,
+// `snoozeUntil`, `maxWorkers` and `replaceAutoSubs` survive a priority change —
+// the rule `saveAutoQueueAction` states: a focus must never start a stopped lane
+// or unhold a held one.
+export async function saveChannelPriorityAction(
+ edit: ChannelPriorityEdit,
+ // Lanes to ENABLE in the same write. The one thing a caller may ask for
+ // beside the document, and only because "Add to the top of the download
+ // queue" has always meant both: prioritise AND turn the lane on. Two writes
+ // would race each other on one settings file. It only ever sets `enabled`
+ // true — nothing here can hold, unhold or snooze a lane.
+ opts?: { enableLanes?: readonly AutoQueueKind[] },
+): Promise<ActionResult> {
+ const paths = getPaths();
+ const settings = getSettings();
+ const stored = settings.channelPriority;
+ const configs = await listChannelConfigs(paths);
+ const slugs = configs.map((c) => c.slug);
+ // THE LEGACY SEED, and it is the difference between this feature shipping
+ // and this feature destroying the two hand-made 9+9 lane orders on its first
+ // click (the S2/S3 review, finding 3, restated with its mechanism in 8).
+ //
+ // `laneDispatchRoot` is all-or-nothing on `isDefaultChannelPriority`: the
+ // moment a document says ANYTHING, the stored trees stop being dispatched
+ // from and the compiled ones take over. A first click that set one tier and
+ // nothing else would therefore compile a tree in which no channel has a rank
+ // — every one of them alphabetical inside `prio-normal` — and the operator's
+ // order would be gone with no way back, because the trees it was written in
+ // have just been overwritten.
+ //
+ // So: when the stored document says nothing AND the stored trees are not
+ // already compiled, derive the document the migration would have produced
+ // and apply the edit on top of THAT. The corpus's existing order survives a
+ // first click by an operator who never ran the migration.
+ //
+ // NOT ON A `recompile` OR A `rename`: creating or renaming a channel is not
+ // an operator's statement about priority, and neither must silently switch a
+ // corpus from its stored trees to compiled ones.
+ const base =
+ edit.kind !== "recompile" &&
+ edit.kind !== "rename" &&
+ isDefaultChannelPriority(stored) &&
+ !hasCompiledLaneRoots(settings.autoQueue)
+ ? channelPriorityFromLegacy(
+ // THE SEED READS THE LANE ORDERS, NOT THE FLAG. `excludeFromSync` is
+ // deleted and `parseChannelConfig` drops the key, so nothing the
+ // editor can read still carries it — turning those 15 channels into
+ // `overrides: {sync:"paused"}` is the migration script's half, off
+ // the raw config.json. What this seed is for is the ORDER, and the
+ // order lives in the stored trees.
+ configs.map((c) => ({ slug: c.slug, config: {} })),
+ settings.autoQueue,
+ )
+ : stored;
+ const next = sanitizeChannelPriority(applyPriorityEdit(base, edit));
+ // WHEN A DEFAULT DOCUMENT STILL HAS TO COMPILE, and the two halves of the
+ // question are different corpora.
+ //
+ // The runner's bypass means a default document dispatches from the STORED
+ // tree, so what matters here is what that stored tree is:
+ //
+ // never compiled (hand-made, or the shipped default) -> LEAVE IT. This is
+ // the same rule `laneDispatchRoot` applies, and compiling over it would
+ // replace an operator's tree with an all-normal one at the moment the last
+ // priority was cleared.
+ //
+ // already compiled -> RECOMPILE, even for a default document. Ending a
+ // focus leaves the document saying nothing while every stored root still
+ // opens with `prio-focus` — and the runner, back on its bypass, would
+ // dispatch from exactly that tree and go on holding the channels the focus
+ // was just ended for. There is no hand-made tree left to protect on a
+ // corpus whose roots the compiler already wrote.
+ const autoQueue = { ...settings.autoQueue };
+ if (
+ !isDefaultChannelPriority(next) ||
+ hasCompiledLaneRoots(settings.autoQueue)
+ ) {
+ const focusSlugs = resolveFocusSlugs(next, siteChannelIndex(paths), slugs);
+ const roots = compileLanes(next, slugs, focusSlugs);
+ for (const lane of LANES) {
+ autoQueue[lane] = { ...settings.autoQueue[lane], root: roots[lane] };
+ }
+ }
+ for (const lane of opts?.enableLanes ?? []) {
+ autoQueue[lane] = { ...autoQueue[lane], enabled: true };
+ }
+ try {
+ await writeSettings({ ...settings, channelPriority: next, autoQueue });
+ } catch (e) {
+ return { error: (e as Error).message };
+ }
+ revalidatePath("/channels");
+ revalidatePath("/operations");
+ revalidatePath("/operations/[id]", "page");
+ return undefined;
+}
+
+// The named gestures. Each is one call to the writer above — they exist so a
+// control names what it does rather than assembling an edit union inline.
+export async function setChannelTierAction(
+ slugs: string[],
+ tier: StoredChannelTier,
+): Promise<ActionResult> {
+ return saveChannelPriorityAction({ kind: "tier", slugs, tier });
+}
+
+export async function setChannelOperationTierAction(
+ slugs: string[],
+ operation: PriorityOperation,
+ tier: StoredChannelTier | null,
+): Promise<ActionResult> {
+ return saveChannelPriorityAction({
+ kind: "operation",
+ slugs,
+ operation,
+ tier,
+ });
+}
+
+export async function applyChannelPriorityPresetAction(
+ slugs: string[],
+ preset: "sync-only" | "clear",
+): Promise<ActionResult> {
+ return saveChannelPriorityAction({ kind: "preset", slugs, preset });
+}
+
+export async function focusChannelsAction(
+ slugs: string[],
+): Promise<ActionResult> {
+ return saveChannelPriorityAction({
+ kind: "focus",
+ focus: { kind: "channels", slugs },
+ });
+}
+
+export async function focusSiteAction(siteId: string): Promise<ActionResult> {
+ return saveChannelPriorityAction({
+ kind: "focus",
+ focus: { kind: "site", siteId },
+ });
+}
+
+// THE CHANNEL POPULATION CHANGED. A compiled tree names its channels one leaf
+// each, so a channel created or deleted since the last write leaves the stored
+// trees stale — the new one falls to the trailing catch-all (safe: bottom
+// priority) and the deleted one keeps a leaf that matches nothing. The runner
+// compiles per tick and is unaffected either way; what goes stale is what is
+// ON DISK, and therefore what every reader of the stored tree sees.
+//
+// One writer, so the recompile is this one too. Best-effort by design: the
+// channel was created or deleted regardless, and the next priority save heals
+// the tree anyway.
+export async function recompileChannelPriorityAction(): Promise<ActionResult> {
+ return saveChannelPriorityAction({ kind: "recompile" });
+}
+
+// "Add to the top of the download queue", as a PRIORITY edit.
+//
+// It used to prepend a `prioritize-<slug>` leaf straight into
+// `autoQueue.download.root` (operations/actions.ts), which while a model
+// exists writes a tree the runner does not dispatch from — a second writer of
+// `root`, and a silent no-op. It is the same gesture said in the model's
+// vocabulary: first rank, everything below it shifted down, base tier normal,
+// and the download lane enabled in the same write.
+export async function prioritizeChannelDownloadPriorityAction(
+ slug: string,
+): Promise<ActionResult> {
+ const trimmed = (slug ?? "").trim();
+ if (!trimmed) return { error: "No channel slug supplied." };
+ return saveChannelPriorityAction(
+ { kind: "promote", slug: trimmed },
+ { enableLanes: ["download"] },
+ );
+}
+
+export async function endFocusAction(): Promise<ActionResult> {
+ return saveChannelPriorityAction({ kind: "focus", focus: { kind: "none" } });
+}
diff --git a/editor/app/channels/components/ChannelBulkBar.tsx b/editor/app/channels/components/ChannelBulkBar.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+// THE /channels BULK CONTROLS for channel priority — two bars, one writer.
+//
+// `ChannelFocusBar` is always on the page: the focus is ONE corpus-wide fact,
+// so choosing a site to focus and ending a focus are not properties of a row
+// selection and must not require one. `ChannelBulkBar` is the selection bar,
+// copying the idiom `BulkCadenceBar`/`SyncConsole` already established on the
+// sync console (a Set of slugs, a sticky footer, Apply + Clear) rather than
+// inventing a second one.
+//
+// Both post through `saveChannelPriorityAction`'s named gestures, which is the
+// only function that writes `settings.channelPriority` and the only one that
+// recompiles the four lane trees.
+
+import { useState, useTransition } from "react";
+import {
+ STORED_CHANNEL_TIERS,
+ type StoredChannelTier,
+} from "yt-dlp-transcript-common/lib/channelPriority";
+import {
+ endFocusAction,
+ focusChannelsAction,
+ focusSiteAction,
+ setChannelTierAction,
+ type ActionResult,
+} from "../actions";
+
+// A FAILED WRITE MUST SAY SO — the writer returns `{error}` before it
+// revalidates, so a swallowed result reads as "the click did nothing". One
+// runner per bar, one alert line, the shape SyncAllChannelsButton already uses.
+function useBarAction(): {
+ pending: boolean;
+ error: string | null;
+ run: (action: () => Promise<ActionResult>, after?: () => void) => void;
+} {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ return {
+ pending,
+ error,
+ run: (action, after) =>
+ startTransition(async () => {
+ try {
+ const result = await action();
+ setError(result?.error ?? null);
+ if (!result?.error) after?.();
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ }),
+ };
+}
+
+export type FocusSite = { siteId: string; title: string };
+
+const TIER_LABEL: Record<StoredChannelTier, string> = {
+ normal: "Normal",
+ low: "Low",
+ paused: "Paused",
+};
+
+export function ChannelFocusBar({
+ sites,
+ focusLabel,
+}: {
+ sites: FocusSite[];
+ // What is focused right now, already resolved by the page ("site Foo",
+ // "2 channels"), or null when nothing is. The full banner with the per-lane
+ // pending numbers is S4's FocusBanner; this bar is only the control.
+ focusLabel: string | null;
+}) {
+ const { pending, error, run } = useBarAction();
+ const [siteId, setSiteId] = useState(sites[0]?.siteId ?? "");
+
+ if (sites.length === 0 && !focusLabel) return null;
+
+ return (
+ <div
+ aria-label="channel focus"
+ className="flex flex-wrap items-center gap-2 rounded border border-border bg-card px-3 py-2 text-sm"
+ >
+ <span className="text-muted-foreground">
+ {focusLabel ? `Focus: ${focusLabel}` : "No focus"}
+ </span>
+ {sites.length > 0 && (
+ <>
+ <select
+ aria-label="focus site"
+ value={siteId}
+ disabled={pending}
+ onChange={(e) => setSiteId(e.target.value)}
+ className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50"
+ >
+ {sites.map((s) => (
+ <option key={s.siteId} value={s.siteId}>
+ {s.title}
+ </option>
+ ))}
+ </select>
+ <button
+ type="button"
+ disabled={pending || !siteId}
+ onClick={() => run(() => focusSiteAction(siteId))}
+ className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ Focus site
+ </button>
+ </>
+ )}
+ {focusLabel && (
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => run(() => endFocusAction())}
+ className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ End focus
+ </button>
+ )}
+ {error && (
+ <span
+ role="alert"
+ aria-label="focus error"
+ className="text-xs text-destructive"
+ >
+ {error}
+ </span>
+ )}
+ </div>
+ );
+}
+
+export function ChannelBulkBar({
+ slugs,
+ onClear,
+}: {
+ slugs: string[];
+ onClear: () => void;
+}) {
+ const { pending, error, run } = useBarAction();
+ const [tier, setTier] = useState<StoredChannelTier>("normal");
+
+ if (slugs.length === 0) return null;
+
+ return (
+ <div
+ aria-label="channel priority bulk"
+ className="sticky bottom-0 z-10 flex flex-wrap items-center gap-2 rounded border border-border bg-card p-3 text-sm shadow-lg motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2"
+ >
+ <span className="font-medium">{slugs.length} selected</span>
+ <label className="flex items-center gap-1 text-xs text-muted-foreground">
+ Set tier
+ <select
+ aria-label="bulk tier"
+ value={tier}
+ disabled={pending}
+ onChange={(e) => setTier(e.target.value as StoredChannelTier)}
+ className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50"
+ >
+ {STORED_CHANNEL_TIERS.map((t) => (
+ <option key={t} value={t}>
+ {TIER_LABEL[t]}
+ </option>
+ ))}
+ </select>
+ </label>
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => run(() => setChannelTierAction(slugs, tier), onClear)}
+ className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
+ >
+ Apply tier
+ </button>
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => run(() => focusChannelsAction(slugs), onClear)}
+ className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ Focus these
+ </button>
+ <button
+ type="button"
+ onClick={onClear}
+ className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted"
+ >
+ Clear
+ </button>
+ {error && (
+ <span
+ role="alert"
+ aria-label="priority bulk error"
+ className="text-xs text-destructive"
+ >
+ {error}
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/channels/components/ChannelSyncToggle.tsx b/editor/app/channels/components/ChannelSyncToggle.tsx
@@ -1,45 +0,0 @@
-"use client";
-
-import { toggleChannelSyncInclusionAction } from "../actions";
-
-export function ChannelSyncToggle({
- slug,
- excluded,
-}: {
- slug: string;
- excluded: boolean;
-}) {
- return (
- <form
- action={async () => {
- await toggleChannelSyncInclusionAction(slug);
- }}
- >
- <button
- type="submit"
- aria-label={`toggle sync inclusion for ${slug}`}
- aria-pressed={!excluded}
- title={
- excluded
- ? "Excluded from Sync all. Click to include."
- : "Included in Sync all. Click to exclude."
- }
- className={
- "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium border transition-colors " +
- (excluded
- ? "border-border text-muted-foreground hover:bg-muted"
- : "border-success/30 bg-success-soft text-success hover:bg-success/20")
- }
- >
- <span
- aria-hidden="true"
- className={
- "inline-block h-1.5 w-1.5 rounded-full " +
- (excluded ? "bg-muted-foreground" : "bg-success")
- }
- />
- {excluded ? "Skipped" : "Included"}
- </button>
- </form>
- );
-}
diff --git a/editor/app/channels/components/ChannelTierSelect.tsx b/editor/app/channels/components/ChannelTierSelect.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+// THE /channels ROW CONTROL for the channel priority model.
+//
+// It replaces the sync-inclusion toggle that used to sit in this cell. That
+// toggle flipped one boolean on one channel's config.json; this control writes
+// the corpus-wide priority document — one base tier per channel, plus optional
+// per-operation pins — through `saveChannelPriorityAction`, which is the ONE
+// writer of that block and recompiles the four lane trees in the same save.
+//
+// THE BASE TIER IS THE ROW; THE PINS ARE BEHIND A DISCLOSURE. A channel's tier
+// is the answer for every operation unless an operation is pinned, and pins are
+// the rare case (they exist because "stop syncing, keep everything else" — the
+// retired `excludeFromSync` — has to remain sayable). Putting five selects in
+// every row would bury the one number the page is for, so the row shows the
+// base tier and a marker counting the pins, and the disclosure is where they
+// are set.
+//
+// `focused` and `heldReason` are DISPLAY facts passed down from the page, not a
+// second read: focus is a corpus-wide selector resolved once on the server, and
+// a row cannot work out on its own whether it is being held.
+//
+// OPTIMISTIC, NOT STATEFUL. `useOptimistic` shows the operator's choice for the
+// length of the transition and then defers to the server value — so a bulk edit
+// or the page's auto-refresh can never leave this select disagreeing with
+// settings.json, which a `useState` seeded from props would.
+
+import { useOptimistic, useState, useTransition } from "react";
+import {
+ PRIORITY_OPERATIONS,
+ STORED_CHANNEL_TIERS,
+ type PriorityOperation,
+ type StoredChannelTier,
+} from "yt-dlp-transcript-common/lib/channelPriority";
+import {
+ applyChannelPriorityPresetAction,
+ setChannelOperationTierAction,
+ setChannelTierAction,
+ type ActionResult,
+} from "../actions";
+
+export type ChannelTierSelectProps = {
+ slug: string;
+ // The channel's BASE tier (tierOf), not its effective tier for any one
+ // operation — the overrides are shown beside it, not folded into it.
+ tier: StoredChannelTier;
+ // Per-operation pins, normalised (only operations that differ from `tier`).
+ overrides?: Partial<Record<PriorityOperation, StoredChannelTier>>;
+ // This channel is in the active focus set. Focus is a corpus-wide selector,
+ // never a stored tier, so it is passed in rather than read off `tier`.
+ focused?: boolean;
+ // "Held — focus: <name>" for a non-focus row while a focus holds the lanes.
+ heldReason?: string | null;
+ disabled?: boolean;
+};
+
+const TIER_LABEL: Record<StoredChannelTier, string> = {
+ normal: "Normal",
+ low: "Low",
+ paused: "Paused",
+};
+
+const OPERATION_LABEL: Record<PriorityOperation, string> = {
+ sync: "Sync",
+ transcription: "Transcription",
+ download: "Download",
+ digest: "Digest",
+ backfill: "Backfill",
+};
+
+type View = {
+ tier: StoredChannelTier;
+ overrides: Partial<Record<PriorityOperation, StoredChannelTier>>;
+};
+
+export default function ChannelTierSelect({
+ slug,
+ tier,
+ overrides = {},
+ focused = false,
+ heldReason = null,
+ disabled = false,
+}: ChannelTierSelectProps): React.ReactNode {
+ const [pending, startTransition] = useTransition();
+ const [view, setView] = useOptimistic<View>({ tier, overrides });
+ // A FAILED WRITE MUST SAY SO. The writer returns `{error}` before it
+ // revalidates, so on a failure the optimistic value silently snaps back to
+ // the server's — which reads as "the click did nothing" rather than as an
+ // error. Same shape as SyncAllChannelsButton's: role="alert", a labelled
+ // span, text-destructive.
+ const [error, setError] = useState<string | null>(null);
+ const pins = PRIORITY_OPERATIONS.filter((op) => view.overrides[op]);
+ const busy = disabled || pending;
+
+ function run(next: View, action: () => Promise<ActionResult>) {
+ startTransition(async () => {
+ setView(next);
+ try {
+ const result = await action();
+ setError(result?.error ?? null);
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ });
+ }
+
+ return (
+ <div className="flex flex-col gap-1 min-w-36">
+ <select
+ aria-label={`tier for ${slug}`}
+ value={view.tier}
+ disabled={busy}
+ onChange={(e) => {
+ const next = e.target.value as StoredChannelTier;
+ run({ ...view, tier: next }, () =>
+ setChannelTierAction([slug], next),
+ );
+ }}
+ className="rounded-md border border-border bg-card px-2 py-1 text-xs disabled:opacity-50"
+ >
+ {STORED_CHANNEL_TIERS.map((t) => (
+ <option key={t} value={t}>
+ {TIER_LABEL[t]}
+ </option>
+ ))}
+ </select>
+ <div className="flex flex-wrap items-center gap-1 text-[11px]">
+ {focused && (
+ <span
+ className="rounded-full border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-primary"
+ data-testid={`focused-${slug}`}
+ >
+ Focused
+ </span>
+ )}
+ {pins.length > 0 && (
+ <span
+ className="rounded-full border border-border px-1.5 py-0.5 text-muted-foreground"
+ title={pins
+ .map((op) => `${OPERATION_LABEL[op]}: ${TIER_LABEL[view.overrides[op]!]}`)
+ .join(" · ")}
+ >
+ {pins.length} pinned
+ </span>
+ )}
+ </div>
+ {error && (
+ <span
+ role="alert"
+ aria-label={`priority error for ${slug}`}
+ className="text-[11px] text-destructive"
+ >
+ {error}
+ </span>
+ )}
+ {heldReason && (
+ <span
+ role="note"
+ className="text-[11px] text-warning"
+ aria-label={`held reason for ${slug}`}
+ >
+ {heldReason}
+ </span>
+ )}
+ <details className="text-[11px]">
+ <summary
+ aria-label={`advanced priority for ${slug}`}
+ className="cursor-pointer text-muted-foreground hover:text-foreground"
+ >
+ Advanced
+ </summary>
+ <div className="mt-1 flex flex-col gap-1">
+ {PRIORITY_OPERATIONS.map((op) => (
+ <label key={op} className="flex items-center justify-between gap-2">
+ <span className="text-muted-foreground">
+ {OPERATION_LABEL[op]}
+ </span>
+ <select
+ aria-label={`${op} override for ${slug}`}
+ value={view.overrides[op] ?? ""}
+ disabled={busy}
+ onChange={(e) => {
+ const raw = e.target.value;
+ const next = raw === "" ? null : (raw as StoredChannelTier);
+ const overridesNext = { ...view.overrides };
+ if (next === null) delete overridesNext[op];
+ else overridesNext[op] = next;
+ run({ ...view, overrides: overridesNext }, () =>
+ setChannelOperationTierAction([slug], op, next),
+ );
+ }}
+ className="rounded border border-border bg-card px-1 py-0.5 disabled:opacity-50"
+ >
+ {/* Blank IS inherit, and the label names what it inherits — the
+ effective tier for this operation when nothing is pinned. */}
+ <option value="">Inherit ({TIER_LABEL[view.tier]})</option>
+ {STORED_CHANNEL_TIERS.map((t) => (
+ <option key={t} value={t}>
+ {TIER_LABEL[t]}
+ </option>
+ ))}
+ </select>
+ </label>
+ ))}
+ <div className="flex flex-wrap gap-1 pt-1">
+ {/* The preset that retires `excludeFromSync`'s inverse: keep the
+ playlist and metadata current, dispatch nothing. */}
+ <button
+ type="button"
+ aria-label={`sync only for ${slug}`}
+ disabled={busy}
+ onClick={() =>
+ run(
+ { tier: "paused", overrides: { sync: "normal" } },
+ () => applyChannelPriorityPresetAction([slug], "sync-only"),
+ )
+ }
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Sync only
+ </button>
+ <button
+ type="button"
+ aria-label={`clear priority for ${slug}`}
+ disabled={busy}
+ onClick={() =>
+ run({ tier: "normal", overrides: {} }, () =>
+ applyChannelPriorityPresetAction([slug], "clear"),
+ )
+ }
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Clear
+ </button>
+ </div>
+ </div>
+ </details>
+ </div>
+ );
+}
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -16,19 +16,43 @@ import { ChannelGroupHeaderRow } from "./ChannelGroupHeaderRow";
import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton";
import { ChannelBuildToggle } from "./ChannelBuildToggle";
import { ChannelSyncButton } from "./ChannelSyncButton";
-import { ChannelSyncToggle } from "./ChannelSyncToggle";
+import ChannelTierSelect from "./ChannelTierSelect";
+import {
+ ChannelBulkBar,
+ ChannelFocusBar,
+ type FocusSite,
+} from "./ChannelBulkBar";
import { InlineActionButton } from "../../components/actions/InlineActionButton";
import {
MediaLocationBadge,
type MediaBadgeInput,
} from "../../components/MediaLocationBadge";
import { ChannelStorageBulkBar } from "./ChannelStorageBulkBar";
+import {
+ tierOrder,
+ type PriorityOperation,
+ type StoredChannelTier,
+} from "yt-dlp-transcript-common/lib/channelPriority";
+
+// One row's share of the channel priority document, resolved on the server.
+// The row never reads the model itself: `focused` and `heldReason` are facts
+// about the corpus-wide focus selector, which no single row can answer.
+export type ChannelRowPriority = {
+ tier: StoredChannelTier;
+ rank: number | null;
+ overrides: Partial<Record<PriorityOperation, StoredChannelTier>>;
+ focused: boolean;
+ heldReason: string | null;
+};
// A row is a stat plus its pipeline bands, in column order. The bands are
// projected on the server from the same snapshot the counts come from, so a
// figure in a band and the count beside it cannot disagree.
export type ChannelRow = ChannelStat & {
pipelines: OperationBand[];
+ // Its tier, its pins, and whether the active focus is holding it. Replaces
+ // the row's sync-inclusion flag: one ordered model instead of one boolean.
+ priority: ChannelRowPriority;
// How old this channel's report is. Every count and every band on this row is
// projected from that report, so its age is the caveat on all of them — which
// is why it belongs beside them rather than on a page of its own.
@@ -66,7 +90,7 @@ type SortKey =
| "name"
| "handling"
| "build"
- | "sync"
+ | "tier"
| "playlist"
| "lastSync"
| "report"
@@ -81,7 +105,7 @@ const DEFAULT_DIR: Record<string, SortDir> = {
name: "asc",
handling: "asc",
build: "asc",
- sync: "asc",
+ tier: "asc",
playlist: "desc",
lastSync: "asc",
// Ascending, and missing dates sort as oldest: the first click puts the
@@ -131,6 +155,15 @@ function compareDates(a: string | undefined, b: string | undefined): number {
return new Date(a as string).getTime() - new Date(b as string).getTime();
}
+// Unranked sorts AFTER every ranked sibling, which is `orderWithin`'s rule in
+// the compiler — not compareNumbers', which sorts a missing value first.
+function compareRanks(a: number | null, b: number | null): number {
+ if (a === b) return 0;
+ if (a === null) return 1;
+ if (b === null) return -1;
+ return a - b;
+}
+
function compareBools(a: boolean, b: boolean): number {
// false (included) < true (excluded), so "Included" sorts first on asc.
return (a ? 1 : 0) - (b ? 1 : 0);
@@ -153,10 +186,16 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number {
a.config.excludeFromBuild === true,
b.config.excludeFromBuild === true,
);
- case "sync":
- return compareBools(
- a.config.excludeFromSync === true,
- b.config.excludeFromSync === true,
+ case "tier":
+ // THE COMPILED ORDER, not the stored one: focus is a position the focus
+ // selector produces, so a focused channel sorts above every normal one
+ // exactly as it does in the lane tree. Then rank (unranked last), then
+ // slug — the same three keys `compileLaneRoot` orders a group by.
+ return (
+ tierOrder(a.priority.focused ? "focus" : a.priority.tier) -
+ tierOrder(b.priority.focused ? "focus" : b.priority.tier) ||
+ compareRanks(a.priority.rank, b.priority.rank) ||
+ a.slug.localeCompare(b.slug)
);
case "playlist":
return compareNumbers(a.playlistCount, b.playlistCount);
@@ -189,6 +228,8 @@ export function ChannelsTable({
sections = null,
siteId,
defaultMediaRoot = "",
+ sites = [],
+ focusLabel = null,
}: {
channels: ChannelRow[];
// Which pipelines to draw, in group order, resolved on the server from the
@@ -204,6 +245,12 @@ export function ChannelsTable({
// settings.storage.mediaRoot, resolved on the server. Seeds the bulk bar's
// root box; "" when no cold root is configured.
defaultMediaRoot?: string;
+ // Every configured site, for the "Focus site" control. Not the same list as
+ // the page's scope selector: a focus is corpus-wide, so it can name a site
+ // whose channels are not the ones on screen.
+ sites?: FocusSite[];
+ // What the focus selector currently names, resolved on the server, or null.
+ focusLabel?: string | null;
}) {
const [sort, setSort] = useState<SortState>(null);
// Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a
@@ -254,6 +301,9 @@ export function ChannelsTable({
return (
<div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
+ <div className="px-4 md:px-0 pb-2">
+ <ChannelFocusBar sites={sites} focusLabel={focusLabel} />
+ </div>
{sections && sections.length > 0 && (
<label className="flex items-center gap-2 px-4 md:px-0 pb-2 text-xs text-muted-foreground">
<input
@@ -308,10 +358,11 @@ export function ChannelsTable({
onClick={onHeaderClick}
/>
<SortableTh
- label="Sync"
- sortKey="sync"
+ label="Tier"
+ sortKey="tier"
sort={sort}
onClick={onHeaderClick}
+ title="Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column."
/>
<SortableTh
label="Playlist"
@@ -397,6 +448,10 @@ export function ChannelsTable({
onClear={() => setSelected(new Set())}
defaultRoot={defaultMediaRoot}
/>
+ <ChannelBulkBar
+ slugs={selectedSlugs}
+ onClear={() => setSelected(new Set())}
+ />
<div className="flex flex-col gap-1 px-3 py-2 md:px-0">
<BandLegend />
{columns.some((c) => c.id.startsWith("attribution-")) && (
@@ -475,7 +530,11 @@ function ChannelTableRow({
<tr
className={
"border-t border-border " +
- (c.config.excludeFromBuild || c.config.excludeFromSync
+ // Dimmed for the two things that take the row out of a pipeline: it is
+ // excluded from the export build, or its base tier is Paused. (The sync
+ // exclusion flag that used to dim it is now a `sync` pin, which is a
+ // per-operation fact and not a property of the whole row.)
+ (c.config.excludeFromBuild || c.priority.tier === "paused"
? "opacity-60"
: "")
}
@@ -509,9 +568,12 @@ function ChannelTableRow({
/>
</Td>
<Td>
- <ChannelSyncToggle
+ <ChannelTierSelect
slug={c.slug}
- excluded={c.config.excludeFromSync === true}
+ tier={c.priority.tier}
+ overrides={c.priority.overrides}
+ focused={c.priority.focused}
+ heldReason={c.priority.heldReason}
/>
</Td>
<Td className="text-right" ariaLabel={`playlist count for ${c.slug}`}>
diff --git a/editor/app/channels/components/EndFocusButton.tsx b/editor/app/channels/components/EndFocusButton.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+// THE BANNER'S "End focus", for the surfaces that are NOT /channels.
+//
+// The banner itself has no writer and must not grow one (FocusBanner.tsx's
+// header states why): `settings.channelPriority` has exactly one writer,
+// `saveChannelPriorityAction`, and a second one for a single button is the
+// thing the model was built to avoid. So this is not a second writer — it is
+// the same one, reached through `endFocusAction`, the named gesture
+// `ChannelFocusBar` on /channels posts. One writer, two controls.
+//
+// It lives here rather than under operations/ because it belongs to the
+// priority vocabulary, beside the action it posts and the bar that shares it.
+//
+// A FAILED WRITE MUST SAY SO. The writer returns `{error}` before it
+// revalidates, so a swallowed result would read as "the click did nothing" —
+// the same rule, and the same shape, as `useBarAction` in ChannelBulkBar.
+
+import { useState, useTransition } from "react";
+import { endFocusAction } from "../actions";
+
+export default function EndFocusButton(): React.ReactNode {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ return (
+ <>
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() =>
+ startTransition(async () => {
+ try {
+ const result = await endFocusAction();
+ setError(result?.error ?? null);
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ })
+ }
+ className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ End focus
+ </button>
+ {error && (
+ <span
+ role="alert"
+ aria-label="focus error"
+ className="text-xs text-destructive"
+ >
+ {error}
+ </span>
+ )}
+ </>
+ );
+}
diff --git a/editor/app/channels/components/FocusBanner.tsx b/editor/app/channels/components/FocusBanner.tsx
@@ -0,0 +1,102 @@
+// THE FOCUS BANNER — what a focus is doing, said once, wherever dispatch is
+// being watched.
+//
+// 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.
+//
+// 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.
+//
+// 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.
+//
+// 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";
+
+export type FocusBannerProps = {
+ summary: FocusSummary;
+ // Display name for the focus: a site's title for a site focus, or a short
+ // channel list. Resolved by the caller — this file does no I/O and the model
+ // stores a siteId, not a title.
+ name?: string;
+ // 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 — including a focus that resolved to no
+ // channels at all, which compiles no focus group and holds no one.
+ if (!summary.active) 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/channels/lib/channelGroupSections.test.ts b/editor/app/channels/lib/channelGroupSections.test.ts
@@ -260,20 +260,40 @@ test("a social channel is eligible for sync only", () => {
assert.deepEqual(s.speakers.eligible, []);
});
-test("sync skips a channel with no url or excluded from sync", () => {
+test("sync skips a channel with no url", () => {
+ const sections = build(
+ siteOf({ channels: [{ slug: "ok" }, { slug: "nourl" }] }),
+ [channel("ok"), channel("nourl", { url: undefined })],
+ );
+ assert.deepEqual(sections[0].sync.eligible, ["ok"]);
+ // Download needs a url too.
+ assert.deepEqual(sections[0].download.eligible.sort(), ["ok"]);
+});
+
+// The priority document answers the same question the flag does, for the `sync`
+// operation specifically — which is what makes the migration lossless: a channel
+// paused for sync alone is still drawn by every other station.
+test("sync skips a channel paused for the sync operation, and only sync does", () => {
+ const settings = {
+ ...LANE_ON,
+ channelPriority: {
+ focus: { kind: "none" as const },
+ channels: {
+ pinned: { tier: "normal" as const, overrides: { sync: "paused" as const } },
+ off: { tier: "paused" as const },
+ },
+ },
+ } as SiteSettings;
const sections = build(
siteOf({
- channels: [{ slug: "ok" }, { slug: "nourl" }, { slug: "excluded" }],
+ channels: [{ slug: "ok" }, { slug: "pinned" }, { slug: "off" }],
}),
- [
- channel("ok"),
- channel("nourl", { url: undefined }),
- channel("excluded", { excludeFromSync: true }),
- ],
+ [channel("ok"), channel("pinned"), channel("off")],
+ settings,
);
assert.deepEqual(sections[0].sync.eligible, ["ok"]);
- // Download needs a url too.
- assert.deepEqual(sections[0].download.eligible.sort(), ["excluded", "ok"]);
+ // A `sync` pin moves nothing else: both are still download candidates.
+ assert.deepEqual(sections[0].download.eligible.sort(), ["off", "ok", "pinned"]);
});
test("speakers reports `off`, not 0, when no operation on the backfill lane is enabled", () => {
diff --git a/editor/app/channels/lib/channelGroupSections.ts b/editor/app/channels/lib/channelGroupSections.ts
@@ -18,6 +18,10 @@ import {
operationsGroupLabel,
reachableOperationWork,
} from "yt-dlp-transcript-common/lib/operations";
+import {
+ defaultChannelPriority,
+ isChannelPaused,
+} from "yt-dlp-transcript-common/lib/channelPriority";
import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings";
import type { Site } from "yt-dlp-transcript-common/lib/site";
import { normalizeBuckets } from "../[slug]/lib/stageStatus";
@@ -108,10 +112,13 @@ export type StationChannelWork = {
// through. One derivation, so the label and the fan-out can never disagree.
export function stationWorkFor(
station: StationId,
- brief: Pick<ChannelBrief, "config" | "snapshot">,
+ // The slug is part of the question now: whether an operation applies to a
+ // channel is answered by the corpus-wide priority document as well as by the
+ // channel's own config, and that document is keyed by slug.
+ brief: Pick<ChannelBrief, "slug" | "config" | "snapshot">,
settings: SiteSettings,
): StationChannelWork {
- const { config, snapshot } = brief;
+ const { slug, config, snapshot } = brief;
if (laneOffFor(station, settings)) {
return { eligible: false, work: 0, reason: "the lane is switched off" };
}
@@ -121,8 +128,18 @@ export function stationWorkFor(
// The same predicate "Sync every channel" applies. No figure: syncAction
// decides per channel whether it is due, so there is no count to promise.
if (!config.url) return { eligible: false, work: 0, reason: "no url" };
- if (config.excludeFromSync) {
- return { eligible: false, work: 0, reason: "excluded from sync" };
+ // THE PAUSED SECTION. The tier document is asked for the `sync` OPERATION
+ // — `isChannelPaused(model, slug, "sync")` — which is precisely what the
+ // deleted `excludeFromSync` flag meant, read the other way round, and is
+ // now the only thing asked: S5 deleted the flag and migrated the 15
+ // channels that carried it.
+ // `?? defaultChannelPriority()` for the same reason `isGateHeld` reaches
+ // its key with optional chaining: this function is handed partial settings
+ // objects by unit tests and by any caller that has not been through
+ // `getSettings`, and an absent document means today's behaviour.
+ const priority = settings.channelPriority ?? defaultChannelPriority();
+ if (isChannelPaused(priority, slug, "sync")) {
+ return { eligible: false, work: 0, reason: "paused for sync" };
}
return { eligible: true, work: 0 };
}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -10,15 +10,26 @@ import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia";
import {
getSite,
listSiteIds,
+ listSites,
siteChannelSlugs,
+ type Site,
} from "yt-dlp-transcript-common/lib/site";
import {
+ overridesOf,
+ rankOf,
+ resolveFocusSlugs,
+ tierOf,
+} from "yt-dlp-transcript-common/lib/channelPriority";
+import {
allOperations,
operationCatalog,
OPERATION_GROUP_ORDER,
type OperationGroup,
} from "yt-dlp-transcript-common/lib/operations";
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ getSettings,
+ type SiteSettings,
+} from "yt-dlp-transcript-common/lib/settings";
import { buildChannelBands } from "../components/pipelines/buildBands";
import { EXTERNAL_BAND_IDS } from "../components/pipelines/buildBands";
import {
@@ -114,12 +125,31 @@ function pipelineColumns(operationIds: ReadonlyArray<string>): {
};
}
+// The focus's display name. A site focus reads the site's title (so the bar
+// says what the operator picked, not a slug list); a channel focus names the
+// one channel or counts them.
+function focusLabelOf(
+ focus: SiteSettings["channelPriority"]["focus"],
+ focusSlugs: ReadonlyArray<string>,
+ sites: ReadonlyArray<Site>,
+): string | null {
+ if (focusSlugs.length === 0) return null;
+ if (focus.kind === "site") {
+ const site = sites.find((s) => s.siteId === focus.siteId);
+ return `site ${site?.siteTitle ?? focus.siteId}`;
+ }
+ return focusSlugs.length === 1
+ ? focusSlugs[0]
+ : `${focusSlugs.length} channels`;
+}
+
export default async function ChannelsPage({
searchParams,
}: {
searchParams: Promise<{ site?: string }>;
}) {
const paths = getPaths();
+ const settings = getSettings();
const { site } = await searchParams;
const active = resolveActiveSite(site, listSiteIds(paths));
// Counts come from each channel's last snapshot, not a corpus walk. One read
@@ -132,8 +162,27 @@ export default async function ChannelsPage({
// function over the same data, which is what stops a channel figure and a
// rail figure disagreeing about what "downloaded" means.)
const { ids, columns } = pipelineColumns(
- allOperations(getSettings()).map((k) => k.id),
+ allOperations(settings).map((k) => k.id),
+ );
+ // THE PRIORITY DOCUMENT, resolved ONCE for the whole table.
+ //
+ // A row cannot answer "am I focused" or "am I being held" on its own: the
+ // focus is one corpus-wide selector, and resolving it means reading every
+ // site's membership. So it is resolved here and handed down as per-row
+ // display facts — the same shape S4's banner will read from `focusSummary`.
+ const priority = settings.channelPriority;
+ const sites = listSites(paths);
+ const siteChannels = Object.fromEntries(
+ sites.map((s) => [s.siteId, [...siteChannelSlugs(s)]]),
);
+ const knownSlugs = briefs.map((b) => b.slug);
+ const focusSlugs = resolveFocusSlugs(priority, siteChannels, knownSlugs);
+ const focusSet = new Set(focusSlugs);
+ // A focus that resolved to nothing is not active — `resolveFocusSlugs`
+ // returns [] for an unknown siteId deliberately, so a typo never holds the
+ // corpus, and the bar must say "No focus" rather than name a site that is not
+ // there.
+ const focusLabel = focusLabelOf(priority.focus, focusSlugs, sites);
// Keyed by slug rather than by index: listChannelStatsFromSnapshots happens
// to map the briefs in order today, and pairing a channel's counts with
// another channel's bands is exactly the kind of silent wrongness this whole
@@ -173,6 +222,24 @@ export default async function ChannelsPage({
detail: media.detail,
}
: null,
+ priority: {
+ tier: tierOf(priority, stat.slug),
+ rank: rankOf(priority, stat.slug),
+ overrides: overridesOf(priority, stat.slug),
+ focused: focusSet.has(stat.slug),
+ // WHY THE ROW IS HELD, and only while something is actually focused.
+ // A paused channel is not "held by the focus" — it is off, which its
+ // own tier already says. The per-lane "and the focus still has pending
+ // work" qualification belongs to S4's banner, which has the leaf counts;
+ // this row-level reason states the structural fact: while a focus is
+ // active, strict descent reaches nothing below it.
+ heldReason:
+ focusSlugs.length > 0 &&
+ !focusSet.has(stat.slug) &&
+ tierOf(priority, stat.slug) !== "paused"
+ ? `Held — focus: ${focusLabel}`
+ : null,
+ },
};
});
// Scope to the active site's membership; "all sites" shows the full pool.
@@ -185,7 +252,7 @@ export default async function ChannelsPage({
? all.filter((c) => siteChannelSlugs(activeSite).has(c.slug))
: all;
const sections = activeSite
- ? buildChannelGroupSections(activeSite, channels, briefs, getSettings())
+ ? buildChannelGroupSections(activeSite, channels, briefs, settings)
: null;
const shown = new Set(channels.map((c) => c.slug));
const freshness = summariseFreshness(
@@ -222,6 +289,11 @@ export default async function ChannelsPage({
// The configured cold root, for the bulk bar's box. Read here, not
// in the client component — the settings page is its one writer.
defaultMediaRoot={getSettings().storage.mediaRoot}
+ sites={sites.map((s) => ({
+ siteId: s.siteId,
+ title: s.siteTitle || s.siteId,
+ }))}
+ focusLabel={focusLabel}
/>
<p
className="text-xs text-muted-foreground"
diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts
@@ -7,6 +7,7 @@ import {
writeSettings,
} from "yt-dlp-transcript-common/lib/settings";
import { laneRootFromScope } from "yt-dlp-transcript-common/lib/laneMigration";
+import { isDefaultChannelPriority } from "yt-dlp-transcript-common/lib/channelPriority";
import { operationsForLane } from "yt-dlp-transcript-common/lib/operations";
import type { AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes";
import {
@@ -176,6 +177,24 @@ export async function armLaneAction(
): Promise<ArmLaneResult> {
try {
const settings = getSettings();
+ // ONE WRITER OF `root` WHILE A MODEL EXISTS. A scope IS a tree — the whole
+ // point of this action is that it writes one — so while the channel
+ // priority document says anything, arming WITH a scope would write a tree
+ // the runner does not dispatch from (`laneDispatchRoot` compiles) and that
+ // the next priority save overwrites. Refused with the same message the
+ // policy editor gives, rather than accepted and silently ignored.
+ //
+ // Arming with NO scope is untouched: it keeps the stored tree, which is
+ // "switch this lane back on" and says nothing about priority.
+ if (scope && !isDefaultChannelPriority(settings.channelPriority)) {
+ return {
+ ok: false,
+ error:
+ "This lane's rules are generated from the channel priorities. " +
+ "Set the channels' tiers on /channels, then arm the lane without a " +
+ "scope — a tree written here is not what the runner dispatches from.",
+ };
+ }
// VALIDATED HERE, because the sanitizer does not.
//
// An unknown operation id survives a settings write and then matches
diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts
@@ -18,11 +18,11 @@ import {
} from "yt-dlp-transcript-common/controller/autoRunner";
import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState";
import {
- isGroup,
type AutoQueueGroup,
- type AutoQueueNode,
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
@@ -39,6 +39,16 @@ function revalidateOperations(): void {
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
@@ -57,6 +67,24 @@ export async function saveAutoQueueAction(
},
): Promise<SaveResult> {
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
@@ -77,7 +105,7 @@ export async function saveAutoQueueAction(
order: input.order,
snoozeUntil: current.autoQueue[kind].snoozeUntil ?? null,
held: current.autoQueue[kind].held,
- root: input.root,
+ root: compiled ? current.autoQueue[kind].root : input.root,
},
},
};
@@ -200,55 +228,29 @@ export async function resumeLaneAction(
return setLaneHeld(lane, false);
}
-// Recursively drop every leaf that matches this channel, so re-prioritizing the
-// same channel doesn't accumulate duplicate leaves (a group emptied of children
-// is kept — sanitizeAutoQueue tolerates it, and removing it could orphan a
-// group the operator configured). Returns a fresh tree.
-function stripChannelLeaves(node: AutoQueueNode, slug: string): AutoQueueNode {
- if (!isGroup(node)) return node;
- const children = node.children
- .filter(
- (c) =>
- isGroup(c) ||
- !(c.match.type === "channel" && c.match.value === slug),
- )
- .map((c) => stripChannelLeaves(c, slug));
- return { ...node, children };
-}
-
-// "Add to top of auto-queue": prepend a channel leaf at the HEAD of the download
-// policy's strict root (first child = highest priority), enable the download
-// policy, and start the runner if it isn't up. Directly uses the existing
-// policy-tree engine — no engine change. Idempotent: any prior leaf for this
-// channel is stripped first so the head stays the single owner (first-match-wins
-// in buildPendingByLeaf).
+// "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-<slug>` 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<SaveResult> {
- const trimmed = (slug ?? "").trim();
- if (!trimmed) return { ok: false, error: "No channel slug supplied." };
- const current = getSettings();
- const download = current.autoQueue.download;
- const stripped = stripChannelLeaves(download.root, trimmed) as AutoQueueGroup;
- const nextRoot: AutoQueueGroup = {
- ...stripped,
- children: [
- { id: `prioritize-${trimmed}`, match: { type: "channel", value: trimmed } },
- ...stripped.children,
- ],
- };
- const next: SiteSettings = {
- ...current,
- autoQueue: {
- ...current.autoQueue,
- download: { ...download, enabled: true, root: nextRoot },
- },
- };
- try {
- await writeSettings(next);
- } catch (e) {
- return { ok: false, error: (e as Error).message };
- }
+ const result = await prioritizeChannelDownloadPriorityAction(slug);
+ if (result?.error) return { ok: false, error: result.error };
await startAutoRunner("download");
revalidateOperations();
return { ok: true };
diff --git a/editor/app/operations/channelPriorityView.ts b/editor/app/operations/channelPriorityView.ts
@@ -0,0 +1,119 @@
+import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { listSites, siteChannelIndex } from "yt-dlp-transcript-common/lib/site";
+import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels";
+import {
+ type ChannelPriority,
+ type FocusSummary,
+ type PendingByLeaf,
+ type SiteChannelIndex,
+ focusSummary,
+ isDefaultChannelPriority,
+ 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 BY THE RUNNER'S OWN FUNCTION, not by a second one. S4 shipped a
+// `laneRootFor` here and a copy of the default-model gate beside it, flagged in
+// this header as the one fold S5 owed the file; S5 exported `laneDispatchRoot`
+// and `isDefaultChannelPriority` from controller/autoRunner.ts and deleted both
+// copies. There is now ONE definition of "which tree does this lane dispatch
+// from", and the console asks the runner for it.
+//
+// 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;
+};
+
+// "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 };
+ }
+ // NOT CACHED, DELIBERATELY. The runner holds its channel list on a 30 s TTL
+ // because it ticks every three seconds forever; this runs on the status
+ // poll, which is what an e2e spec (and an operator) reads immediately after
+ // creating, renaming or deleting a channel. A TTL here would make the
+ // console lag the corpus by up to half a minute for a listing that costs
+ // nothing at all while the model is default — the early return above never
+ // reaches it.
+ 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. `siteChannelIndex` is the one
+ // spelling of that read (lib/site.ts); the site TITLE is this surface's
+ // extra, because the banner names the focus and the model stores an id.
+ let index: SiteChannelIndex = {};
+ let name: string | null = null;
+ if (model.focus.kind === "site") {
+ index = siteChannelIndex(paths);
+ for (const site of listSites(paths)) {
+ if (site.siteId === model.focus.siteId) name = site.siteTitle;
+ }
+ name = name ?? model.focus.siteId;
+ }
+ const focusSlugs = resolveFocusSlugs(model, index, slugs);
+ if (model.focus.kind === "channels") name = channelFocusName(focusSlugs);
+ return { model, compiled: true, slugs, focusSlugs, name };
+}
+
+// `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,8 @@ 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 EndFocusButton from "../../channels/components/EndFocusButton";
import { HowPriorityWorks } from "./HowPriorityWorks";
import { OperationRail } from "./OperationRail";
import { RunnerOperationView } from "./RunnerOperationView";
@@ -124,7 +126,32 @@ 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.
+
+ END FOCUS IS HERE, not a link to go and find it. The banner's
+ `endFocus` slot takes a control from a caller that can reach the
+ ONE writer, and `EndFocusButton` posts `endFocusAction` — the
+ same named gesture /channels' focus bar posts. A console is where
+ an operator watches a focus drain, so it is where the click that
+ ends it belongs; the "Channel priorities" link beside it is still
+ the way to everything else. */}
+ <FocusBanner
+ summary={data[runnerKind].focus}
+ name={data[runnerKind].focusName ?? undefined}
+ lane={runnerKind}
+ endFocus={<EndFocusButton />}
+ />
<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
@@ -6,6 +6,7 @@ import {
type RecencyKeyView,
computeLeafPending,
getAutoRunnerStatus,
+ laneDispatchRoot,
} from "yt-dlp-transcript-common/controller/autoRunner";
import {
type AutoQueueKind,
@@ -16,7 +17,13 @@ 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,
+ 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: laneDispatchRoot(kind, stored, priority, priority.slugs),
+ };
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/app/scheduler/runTick.ts b/editor/app/scheduler/runTick.ts
@@ -24,6 +24,8 @@ import {
type SchedulerState,
} from "yt-dlp-transcript-common/jobs/syncSchedulerState";
import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig";
+import { resolveFocusSlugs } from "yt-dlp-transcript-common/lib/channelPriority";
+import { siteChannelIndex } from "yt-dlp-transcript-common/lib/site";
import { syncAction } from "../channels/[slug]/pipelineActions";
import { fetchPostsAction } from "../channels/[slug]/socialActions";
// STORAGE CHORES riding this heartbeat because it is the one timer the editor
@@ -114,12 +116,29 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> {
slug: c.slug,
config: c.config,
}));
+ // The focus set is the only half of the priority model that costs I/O, and
+ // ONLY a `{kind:"site"}` focus pays it: `resolveFocusSlugs` reads a site's
+ // `channels[]` so a focus on a site tracks its membership instead of
+ // freezing a list. `{kind:"none"}` and `{kind:"channels"}` resolve from the
+ // document alone. No cache here — the tick runs on the heartbeat, not per
+ // grant, so one `siteChannelIndex()` per tick is not a cost worth memoizing.
+ // `siteChannelIndex` (lib/site.ts) is the one spelling of that read; the
+ // runner, the /channels writer and the status payload ask the same one.
+ const priority = settings.channelPriority;
+ const siteChannels =
+ priority.focus.kind === "site" ? siteChannelIndex(paths) : {};
const { due, skipped } = selectDueChannels({
channels,
scheduler,
state,
activeSlugs,
now,
+ priority,
+ focusSlugs: resolveFocusSlugs(
+ priority,
+ siteChannels,
+ channels.map((c) => c.slug),
+ ),
});
// Concurrency cap doubles as the stagger: queue at most (cap - running)
@@ -133,8 +152,8 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> {
const queued: string[] = [];
const bySlug = new Map(channels.map((c) => [c.slug, c.config]));
for (const slug of toQueue) {
- // The scheduler's ELIGIBILITY rules are source-agnostic (url +
- // excludeFromSync + interval + lastSyncedAt), but the dispatch is not: a
+ // The scheduler's ELIGIBILITY rules are source-agnostic (url + sync
+ // tier + interval + lastSyncedAt), but the dispatch is not: a
// social channel must run a post fetch, not a yt-dlp video sync against
// its profile URL.
const result = isSocialChannel(bySlug.get(slug))
diff --git a/editor/app/scheduler/status.ts b/editor/app/scheduler/status.ts
@@ -41,6 +41,7 @@ export async function buildSchedulerStatusPayload(): Promise<SchedulerStatusPayl
scheduler: settings.syncScheduler,
state,
now,
+ priority: settings.channelPriority,
});
return {
now,
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -249,6 +249,11 @@ export async function saveSettingsAction(
// (this form doesn't edit it; the Auto-queue page does). writeSettings
// re-sanitizes it regardless.
autoQueue: getSettings().autoQueue,
+ // Preserved for the same reason, and for one more: it is the SOURCE the
+ // four roots above are compiled from, so rebuilding it here would silently
+ // undo a focus. Edited on /channels by saveChannelPriorityAction, which is
+ // its one writer.
+ channelPriority: getSettings().channelPriority,
socialLinks,
homepageUrl,
// Preserve the saved-video backup config on an unrelated settings save (the
diff --git a/editor/e2e/channel-priority.spec.ts b/editor/e2e/channel-priority.spec.ts
@@ -0,0 +1,440 @@
+import { test, expect } from "@playwright/test";
+import { readJson, resetData, writeSettings, writeSite } from "./helpers";
+
+// THE /channels PRIORITY CONTROLS, asserted against what lands on disk.
+//
+// Every one of these gestures goes through `saveChannelPriorityAction`, the one
+// writer of `settings.channelPriority` — so what a row shows and what
+// settings.json says cannot drift, and each test checks BOTH: the persisted
+// document, and the row after a reload (which remounts the control and re-seeds
+// it from the server).
+//
+// The compiled trees are checked too, because the recompile is half of what the
+// writer is for. A focus is a `prio-focus` group at the head of every lane's
+// strict root; ending the focus removes it. Nothing else in dispatch is touched.
+
+type Settings = {
+ channelPriority?: {
+ focus: { kind: string; siteId?: string; slugs?: string[] };
+ channels: Record<
+ string,
+ {
+ tier: string;
+ rank?: number;
+ overrides?: Record<string, string>;
+ }
+ >;
+ };
+ autoQueue?: Record<
+ string,
+ { root: { children: { id: string; children?: { id: string }[] }[] } }
+ >;
+};
+
+const settings = () => readJson<Settings>("test-settings.json");
+
+// Every channel in this fixture, and the pool view: with exactly one site
+// configured /channels scopes to it, and the focus-site test needs a site whose
+// membership is a strict subset of the pool.
+const ALL = "/channels?site=__all__";
+
+test("a row's tier select writes the priority document and survives a reload", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ await page.goto(ALL);
+
+ const tierA = page.getByLabel("tier for slow-a", { exact: true });
+ await expect(tierA).toHaveValue("normal");
+ await tierA.selectOption("low");
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels)
+ .toMatchObject({ "slow-a": { tier: "low" } });
+ // Only the channel that was touched appears: the document is a list of
+ // exceptions, and an entry equal to the default is dropped by the sanitizer.
+ {
+ const s = await settings();
+ expect(Object.keys(s.channelPriority?.channels ?? {})).toEqual(["slow-a"]);
+ }
+
+ await page.reload();
+ await expect(page.getByLabel("tier for slow-a", { exact: true })).toHaveValue(
+ "low",
+ );
+ await expect(page.getByLabel("tier for slow-b", { exact: true })).toHaveValue(
+ "normal",
+ );
+
+ // The compiled tree followed it in the same save: slow-a is in the low group,
+ // slow-b in the normal group, and the catch-all is last.
+ const root = (await settings()).autoQueue?.download.root;
+ expect(root?.children.map((c) => c.id)).toEqual([
+ "prio-normal",
+ "prio-low",
+ "prio-all",
+ ]);
+ expect(
+ root?.children.find((c) => c.id === "prio-low")?.children?.map((c) => c.id),
+ ).toEqual(["prio-low-slow-a"]);
+});
+
+test("Advanced pins one operation, and the pin is what differs from the base", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ await page.goto(ALL);
+
+ await page.getByLabel("advanced priority for slow-a").click();
+ await page
+ .getByLabel("download override for slow-a")
+ .selectOption("paused");
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels["slow-a"])
+ .toEqual({ tier: "normal", overrides: { download: "paused" } });
+
+ await page.reload();
+ await page.getByLabel("advanced priority for slow-a").click();
+ await expect(page.getByLabel("download override for slow-a")).toHaveValue(
+ "paused",
+ );
+ // The base tier is unmoved — a pin is a per-operation fact, not a row one.
+ await expect(page.getByLabel("tier for slow-a", { exact: true })).toHaveValue(
+ "normal",
+ );
+
+ // ONE lane's tree moved, and only one: slow-a is gone from download and still
+ // in normal everywhere else.
+ const s = await settings();
+ const normalOf = (lane: string) =>
+ s.autoQueue?.[lane].root.children
+ .find((c) => c.id === "prio-normal")
+ ?.children?.map((c) => c.id);
+ expect(normalOf("download")).toEqual(["prio-normal-slow-b"]);
+ expect(normalOf("transcription")).toEqual([
+ "prio-normal-slow-a",
+ "prio-normal-slow-b",
+ ]);
+});
+
+test("the Sync only preset is paused everywhere with sync pinned back", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ await page.goto(ALL);
+
+ await page.getByLabel("advanced priority for slow-a").click();
+ await page.getByLabel("sync only for slow-a").click();
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels["slow-a"])
+ .toEqual({ tier: "paused", overrides: { sync: "normal" } });
+
+ await page.reload();
+ await expect(page.getByLabel("tier for slow-a", { exact: true })).toHaveValue(
+ "paused",
+ );
+
+ // A paused channel has no leaf on any lane — paused is the one thing the tree
+ // cannot express, so it is removed from the compiled membership entirely.
+ const s = await settings();
+ for (const lane of ["download", "transcription", "digest", "backfill"]) {
+ const ids = JSON.stringify(s.autoQueue?.[lane].root);
+ expect(ids).not.toContain("slow-a");
+ }
+});
+
+// MOVED HERE FROM channel-sync-toggle.spec.ts, which this slice deletes with the
+// control it drove. The assertion is the same one — a pool sweep skips the
+// excluded channel and names it in the tooltip — restated against the tier the
+// flag became. `syncAllChannelsAction` asks the document for the `sync`
+// operation, so the manual sweep and the group Sync buttons agree.
+test("Sync all skips a channel paused for sync and says which", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ await page.goto(ALL);
+
+ await page.getByLabel("tier for slow-a", { exact: true }).selectOption("paused");
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels["slow-a"])
+ .toEqual({ tier: "paused" });
+
+ await page.getByRole("button", { name: "sync every channel" }).click();
+ const result = page.getByLabel("sync all result");
+ await expect(result).toContainText(/Queued 1 . skipped 1/, {
+ timeout: 10_000,
+ });
+ await expect(result).toHaveAttribute("title", /slow-a: paused for sync/);
+});
+
+test("Focus site holds the rest, and End focus releases them", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ // A site whose membership is slow-a alone, so "focus this site" has something
+ // to resolve and something to hold.
+ await writeSite("focusable", {
+ siteTitle: "Focusable",
+ channels: [{ slug: "slow-a", groupId: "default" }],
+ });
+ await page.goto(ALL);
+
+ const bar = page.getByLabel("channel focus");
+ await expect(bar).toContainText("No focus");
+ await bar.getByLabel("focus site").selectOption("focusable");
+ await bar.getByRole("button", { name: "Focus site" }).click();
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.focus)
+ .toEqual({ kind: "site", siteId: "focusable" });
+
+ await page.reload();
+ await expect(page.getByLabel("channel focus")).toContainText(
+ "Focus: site Focusable",
+ );
+ // The focused row says so; the one it holds says why.
+ await expect(page.getByTestId("focused-slow-a")).toBeVisible();
+ await expect(page.getByLabel("held reason for slow-b")).toContainText(
+ "Held — focus: site Focusable",
+ );
+ await expect(page.getByLabel("held reason for slow-a")).toHaveCount(0);
+
+ // The focus group is FIRST in every lane's strict root, which is what makes
+ // it hold: `pick()` descends into the first child that has work.
+ {
+ const s = await settings();
+ for (const lane of ["download", "transcription", "digest", "backfill"]) {
+ const children = s.autoQueue?.[lane].root.children.map((c) => c.id);
+ expect(children).toEqual(["prio-focus", "prio-normal", "prio-all"]);
+ expect(
+ s.autoQueue?.[lane].root.children[0].children?.map((c) => c.id),
+ ).toEqual(["prio-focus-slow-a"]);
+ }
+ }
+
+ await page
+ .getByLabel("channel focus")
+ .getByRole("button", { name: "End focus" })
+ .click();
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.focus)
+ .toEqual({ kind: "none" });
+
+ await page.reload();
+ await expect(page.getByLabel("channel focus")).toContainText("No focus");
+ await expect(page.getByLabel("held reason for slow-b")).toHaveCount(0);
+ {
+ const children = (
+ await settings()
+ ).autoQueue?.download.root.children.map((c) => c.id);
+ expect(children).toEqual(["prio-normal", "prio-all"]);
+ }
+});
+
+test("a row selection focuses those channels and bulk-sets their tier", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ await page.goto(ALL);
+
+ // No selection, no bulk bar.
+ await expect(page.getByLabel("channel priority bulk")).toHaveCount(0);
+ await page.getByLabel("select slow-a").check();
+
+ const bulk = page.getByLabel("channel priority bulk");
+ await expect(bulk).toContainText("1 selected");
+ await bulk.getByRole("button", { name: "Focus these" }).click();
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.focus)
+ .toEqual({ kind: "channels", slugs: ["slow-a"] });
+
+ // Applying a tier to both rows at once writes both entries in one save.
+ await page.getByLabel("select all channels").check();
+ await page.getByLabel("bulk tier").selectOption("low");
+ await page
+ .getByLabel("channel priority bulk")
+ .getByRole("button", { name: "Apply tier" })
+ .click();
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels)
+ .toEqual({ "slow-a": { tier: "low" }, "slow-b": { tier: "low" } });
+
+ await page.reload();
+ await expect(page.getByLabel("tier for slow-a", { exact: true })).toHaveValue(
+ "low",
+ );
+ await expect(page.getByLabel("tier for slow-b", { exact: true })).toHaveValue(
+ "low",
+ );
+ // A focused channel is still focused whatever its stored tier says: focus is
+ // a compiled POSITION, and it wins over the tier.
+ const children = (await settings()).autoQueue?.download.root.children.map(
+ (c) => c.id,
+ );
+ expect(children).toEqual(["prio-focus", "prio-low", "prio-all"]);
+});
+
+// --- THE FIRST CLICK ON A CORPUS THAT HAS NEVER SET A PRIORITY --------------
+//
+// The hazard the seed exists for (the S2/S3 review, findings 3 and 8). The
+// dispatched tree is all-or-nothing on the document: the moment it says
+// ANYTHING, the stored trees stop being dispatched from and compiled ones take
+// over. A hand-made lane order lives ONLY in those stored trees, so the first
+// click of a tier — on a corpus whose operator never ran the migration —
+// would compile a tree in which nothing has a rank, and the order would be
+// gone with the trees it lived in.
+//
+// So the writer seeds from `channelPriorityFromLegacy` when the stored
+// document says nothing AND the stored trees are not already compiled. The
+// fixture below is the live shape in miniature: a hand-made order that
+// DISAGREES with alphabetical, which is the only way to tell a preserved order
+// from a re-derived one.
+
+const LEGACY_ROOT = (lane: string) => ({
+ id: `${lane}-root`,
+ mode: "strict",
+ children: [
+ { id: `${lane}-1`, match: { type: "channel", value: "slow-b" } },
+ { id: `${lane}-2`, match: { type: "channel", value: "slow-a" } },
+ { id: `${lane}-all`, match: { type: "all" } },
+ ],
+});
+
+test("the first tier click seeds from the legacy trees and keeps their order", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ // A hand-made order, slow-b ahead of slow-a, in both ranked lanes — and no
+ // channelPriority document at all, which is every corpus before the
+ // migration script runs.
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ syncScheduler: { fullSweepIntervalMinutes: 0 },
+ autoQueue: {
+ transcription: { root: LEGACY_ROOT("transcription") },
+ download: { root: LEGACY_ROOT("download") },
+ },
+ });
+ await page.goto(ALL);
+ expect((await settings()).channelPriority?.channels ?? {}).toEqual({});
+
+ // One click, on one channel. Everything else about the corpus is untouched.
+ await page.getByLabel("tier for slow-a", { exact: true }).selectOption("low");
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels)
+ .toEqual({
+ // THE ORDER SURVIVED. slow-b was first in the hand-made lists and is
+ // rank 0; slow-a is rank 1. Re-derived from nothing — or read back out
+ // of a compiled tree — both channels would be unranked and the compiler
+ // would fall through to slug order, putting slow-a first.
+ "slow-b": { tier: "normal", rank: 0 },
+ "slow-a": { tier: "low", rank: 1 },
+ });
+
+ // And the click itself still landed: slow-a is in the low group.
+ {
+ const root = (await settings()).autoQueue?.download.root;
+ expect(root?.children.map((c) => c.id)).toEqual([
+ "prio-normal",
+ "prio-low",
+ "prio-all",
+ ]);
+ expect(
+ root?.children
+ .find((c) => c.id === "prio-low")
+ ?.children?.map((c) => c.id),
+ ).toEqual(["prio-low-slow-a"]);
+ }
+
+ // THE SEED IS ONCE. The trees are compiled now, so a second edit must not
+ // re-read them as legacy — which would rank the channels by their position
+ // in the compiled tree rather than by the order that produced it.
+ await page.reload();
+ await page.getByLabel("tier for slow-a", { exact: true }).selectOption("normal");
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels)
+ .toEqual({
+ "slow-b": { tier: "normal", rank: 0 },
+ "slow-a": { tier: "normal", rank: 1 },
+ });
+ {
+ const normal = (await settings()).autoQueue?.download.root.children.find(
+ (c) => c.id === "prio-normal",
+ );
+ // Rank order, not slug order.
+ expect(normal?.children?.map((c) => c.id)).toEqual([
+ "prio-normal-slow-b",
+ "prio-normal-slow-a",
+ ]);
+ }
+});
+
+// --- A RENAME MOVES THE DOCUMENT WITH THE CHANNEL --------------------------
+//
+// The model keys everything by slug, so a rename that does not pass through
+// the one writer loses the channel's tier, rank and per-operation overrides
+// under a slug that no longer exists — silently, because nothing downstream
+// can tell a stale entry from a deliberate one — and leaves a
+// `prio-*-<oldSlug>` leaf in every compiled root, matching nothing.
+
+test("renaming a channel carries its tier and leaves no leaf behind", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ await page.goto(ALL);
+
+ await page.getByLabel("tier for slow-a", { exact: true }).selectOption("low");
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels)
+ .toMatchObject({ "slow-a": { tier: "low" } });
+
+ // Deliberately no generateReport: renaming refuses while a job for the
+ // channel is active, and the danger zone renders without a report.
+ await page.goto("/channels/slow-a?stage=danger");
+ await page.getByLabel("new slug").fill("slow-a-renamed");
+ await page.getByLabel("confirm current slug").fill("slow-a");
+ await page.getByRole("button", { name: "Rename channel" }).click();
+ await expect(page).toHaveURL(/\/channels\/slow-a-renamed/);
+
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels)
+ .toEqual({ "slow-a-renamed": { tier: "low" } });
+
+ // And the compiled trees followed in the same write: the low group names the
+ // new slug, and nothing anywhere still names the old one.
+ {
+ const s = await settings();
+ const raw = JSON.stringify(s.autoQueue);
+ expect(raw.includes("prio-low-slow-a-renamed")).toBe(true);
+ expect(raw.includes("prio-low-slow-a\"")).toBe(false);
+ expect(raw.includes("prio-normal-slow-a\"")).toBe(false);
+ const root = s.autoQueue?.download.root;
+ expect(root?.children.map((c) => c.id)).toEqual([
+ "prio-normal",
+ "prio-low",
+ "prio-all",
+ ]);
+ expect(
+ root?.children.find((c) => c.id === "prio-low")?.children?.map((c) => c.id),
+ ).toEqual(["prio-low-slow-a-renamed"]);
+ expect(
+ root?.children
+ .find((c) => c.id === "prio-normal")
+ ?.children?.map((c) => c.id),
+ ).toEqual(["prio-normal-slow-b"]);
+ }
+
+ // The row on /channels shows the carried tier under the new slug.
+ await page.goto(ALL);
+ await expect(
+ page.getByLabel("tier for slow-a-renamed", { exact: true }),
+ ).toHaveValue("low");
+});
diff --git a/editor/e2e/channel-sync-toggle.spec.ts b/editor/e2e/channel-sync-toggle.spec.ts
@@ -1,58 +0,0 @@
-import { test, expect } from "@playwright/test";
-import { readJson, resetData } from "./helpers";
-import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
-
-// The sync-inclusion toggle on /channels flips `excludeFromSync` in the
-// channel's config.json. The next "Sync all" then skips that channel and
-// reports it in the existing skipped tooltip. Toggling back re-includes it.
-
-const SLOW_A_CONFIG = "test-transcripts/channels/slow-a/config.json";
-
-test("toggling a channel off excludes it from Sync all; toggling back restores it", async ({
- page,
-}) => {
- await resetData("two-slow-channels");
-
- await page.goto("/channels");
-
- const toggleA = page.getByRole("button", {
- name: "toggle sync inclusion for slow-a",
- });
- await expect(toggleA).toHaveText(/Included/);
- await expect(toggleA).toHaveAttribute("aria-pressed", "true");
-
- await toggleA.click();
-
- const toggleAAfter = page.getByRole("button", {
- name: "toggle sync inclusion for slow-a",
- });
- await expect(toggleAAfter).toHaveText(/Skipped/);
- await expect(toggleAAfter).toHaveAttribute("aria-pressed", "false");
-
- {
- const cfg = await readJson<ChannelConfig>(SLOW_A_CONFIG);
- expect(cfg.excludeFromSync).toBe(true);
- }
-
- // Run the pool-wide sweep. slow-a is excluded; slow-b queues.
- await page.getByRole("button", { name: "sync every channel" }).click();
- const result = page.getByLabel("sync all result");
- await expect(result).toContainText(/Queued 1 . skipped 1/, {
- timeout: 10_000,
- });
- await expect(result).toHaveAttribute(
- "title",
- /slow-a: excluded from sync all/,
- );
-
- // Toggle back on.
- await page.getByRole("button", { name: "toggle sync inclusion for slow-a" }).click();
- await expect(
- page.getByRole("button", { name: "toggle sync inclusion for slow-a" }),
- ).toHaveText(/Included/);
-
- {
- const cfg = await readJson<ChannelConfig>(SLOW_A_CONFIG);
- expect(cfg.excludeFromSync).toBeUndefined();
- }
-});
diff --git a/editor/e2e/channels-sort.spec.ts b/editor/e2e/channels-sort.spec.ts
@@ -26,7 +26,9 @@ test("clicking column headers sorts the channels table and indicates direction",
"Name",
"Handling",
"Build",
- "Sync",
+ // Was "Sync" — the sync-inclusion toggle's column is the channel priority
+ // tier now, and its sort key is the compiled order.
+ "Tier",
"Playlist",
"Last sync",
"Report",
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);
+});
diff --git a/editor/e2e/new-channel-onboarding.spec.ts b/editor/e2e/new-channel-onboarding.spec.ts
@@ -108,7 +108,7 @@ test("creating with 'Fetch playlist now' stores the playlist", async ({
.toBe(true);
});
-test("'Add to top of auto-queue' prepends a channel leaf and enables the runner", async ({
+test("'Add to top of auto-queue' takes the first rank and enables the runner", async ({
page,
}) => {
await resetData("empty");
@@ -128,26 +128,40 @@ test("'Add to top of auto-queue' prepends a channel leaf and enables the runner"
await page.getByRole("button", { name: "Create channel" }).click();
await expect(page).toHaveURL(/\/channels\/prio-chan(\?|$)/);
- // The download policy is enabled with our channel leaf at the head of the
- // strict root (first child = highest priority).
+ // The download policy is enabled and the channel has the FIRST RANK in the
+ // priority document, which is what "top of the auto-queue" means now.
+ //
+ // It used to prepend a `prioritize-<slug>` leaf straight into the stored
+ // tree. While a priority model exists that tree is COMPILED and the runner
+ // does not dispatch from a hand-written one — so the gesture is a priority
+ // edit, and the tree it produces is the compiler's: `prio-normal` first,
+ // this channel at the head of it, the catch-all last.
await expect
.poll(
async () => {
const s = await readJson<{
+ channelPriority?: { channels?: Record<string, { rank?: number }> };
autoQueue?: {
download?: {
enabled?: boolean;
- root?: { children?: { match?: { value?: string } }[] };
+ root?: { children?: { id?: string; children?: { id?: string }[] }[] };
};
};
}>("test-settings.json");
const dl = s.autoQueue?.download;
return {
enabled: dl?.enabled ?? false,
- head: dl?.root?.children?.[0]?.match?.value ?? null,
+ groups: dl?.root?.children?.map((c) => c.id) ?? null,
+ head: dl?.root?.children?.[0]?.children?.[0]?.id ?? null,
+ rank: s.channelPriority?.channels?.["prio-chan"]?.rank ?? null,
};
},
{ timeout: 15000 },
)
- .toEqual({ enabled: true, head: "prio-chan" });
+ .toEqual({
+ enabled: true,
+ groups: ["prio-normal", "prio-all"],
+ head: "prio-normal-prio-chan",
+ rank: 0,
+ });
});
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -3646,3 +3646,92 @@ actions reject a channel with running or queued jobs before they enqueue.
(`channelMedia.ts:40`) is the in-flight marker: present means "in transition" to every guard,
its `phase` is what lets an interrupted move resume, `deleteChannel` and `renameChannel` refuse
while it exists, and `clearRelocationMarker` (`:160`) removes it and nothing else.
+## Channel priority (verified 2026-09-11) — one tier per channel, four compiled trees
+
+Branch `channel-priority/s5`, off S0's `28bfee3`, merging `s1`–`s4` and closing the twelve
+review findings. Unmerged; every line:number below is at `d487519`. The plan and its "as
+shipped" record are [`channel-priority.md`](channel-priority.md).
+
+**The model is `settings.channelPriority` (`common/lib/channelPriority.ts`): one focus
+selector plus a sparse map of channels that differ from the default — a `tier`
+(`normal|low|paused`), an optional `rank`, and a per-operation `overrides` map over the five
+`PRIORITY_OPERATIONS` (`sync` plus the four lanes). It COMPILES, it is not consulted.**
+`compileLaneRoot` (`:486`) turns it into a lane's `AutoQueueGroup` — `prio-focus` >
+`prio-normal` > `prio-low` > `prio-all`, all strict — and the ordinary engine dispatches over
+it, so `buildPendingByLeaf`, `selectNextWork`, `laneLimit`, `pauseGates` and every `held` key
+are untouched. A focus holds the rest because strict descent already does that, re-asked on
+every grant.
+
+- **`isDefaultChannelPriority` (`channelPriority.ts:350`) is the whole bypass.** No focus and
+ no channel entry means the compiler never runs and the stored trees stand byte for byte. It
+ lives in the MODEL, not in the runner, because five callers in two packages ask it and a
+ controller cannot import the runner without an import cycle (`backfillReacquire` does).
+- **`laneDispatchRoot` (`controller/autoRunner.ts:441`) is the one answer to "which tree does
+ this lane dispatch from".** The runner loop and `computeLeafPending` call it, and so does the
+ editor's status payload (`editor/app/operations/channelPriorityView.ts`) — a console that
+ compiled its own would be a second compiler that merely agrees. It takes a
+ `PriorityDispatchContext` (`{model, focusSlugs}`), which is the widened shape that let the
+ editor pass its own resolution in.
+- **`priorityContextFor(paths, settings)` (`:400`) caches the resolved focus on a 60 s TTL
+ keyed by `(document, sitesDir)`.** The TTL is not a memo: a `{kind:"site"}` focus tracks the
+ site's `channels[]`, so a channel added to the focused site joins the focus with no settings
+ write. Only a site focus reads the sites directory. The settings object is a PARAMETER —
+ both call sites already hold it.
+- **Paused is a filter on the channel LIST, not a tree shape.** `listChannelMeta(paths, kind,
+ model)` (`:315`) drops `isChannelPaused(model, slug, kind)` — the EFFECTIVE tier for the lane
+ being listed — and it is the single source of that list for both the runner loop and the
+ status panel. A tree cannot express exclusion: a catch-all matches everything.
+- **`siteChannelIndex(paths)` (`lib/site.ts:324`) is the one spelling of "siteId -> slugs".**
+ It was inlined four times (runner, sync tick, writer, status payload).
+
+**ONE WRITER, and since this slice that means `root` too.** `saveChannelPriorityAction`
+(`editor/app/channels/actions.ts:761`) is the only function that writes
+`settings.channelPriority`, and it recompiles the four roots in the same `writeSettings` call,
+spreading each policy so `held`, `snoozeUntil`, `enabled`, `order` and `maxWorkers` survive.
+Three actions that used to write a root no longer may while a model exists:
+`saveAutoQueueAction` and `armLaneAction`-with-a-scope refuse server-side with a message
+naming `/channels` (S4's read-only `PolicyTreeEditor` is the courtesy; this is the rule), and
+`prioritizeChannelDownloadAction` is a priority EDIT (`{kind:"promote"}`) that takes the first
+rank and shifts the rest. `createChannel`/`deleteChannel` recompile through the same writer.
+The writer takes one option, `enableLanes`, because "Add to the top of the download queue"
+has always meant prioritise AND switch on, and two writes would race on one settings file.
+
+**THE SEEDING RULE, and it is what keeps a hand-made order alive.** The bypass is
+all-or-nothing, so the first click that makes a document non-default also stops the stored
+trees being dispatched from — and a hand-made lane order lives ONLY in those trees. So when
+the stored document is default AND `hasCompiledLaneRoots(settings.autoQueue)` is false
+(`channelPriority.ts:682` — `prio-*` ids are produced by the compiler and nothing else), the
+writer seeds from `channelPriorityFromLegacy` (`:748`) and applies the edit on top. Never on a
+`recompile`: creating a channel is not a statement about priority. A DEFAULT document compiles
+nothing, matching the runner's own bypass — clearing the last priority must not overwrite the
+stored trees with an all-normal compile.
+
+**`channelPriorityFromLegacy` renumbers DENSELY and is idempotent.** Merging the two lane
+orders by "the lower index wins" produces collisions (four pairs live), and a collision falls
+through to `orderWithin`'s slug fallback — neither lane's order. Tie rule: merged index, then
+the TRANSCRIPTION lane's own order, then slug. And it returns the stored document untouched
+once any root carries a `prio-*` id, because compiled channel leaves ARE bare channel leaves
+and a second run would otherwise re-derive the order from the tree that order produced.
+
+**A HOLD IS NEVER A STOP, and `backfillReacquire.decideKeep` (`:392`) is where that nearly
+became data loss.** It asked `policyDrawsBucket` on the stored transcription root; on the
+compiled root a channel paused for transcription has NO LEAF, so the honest reading answers
+"no-leaf" for exactly the channels just put on hold and unlinks their re-acquired audio. The
+pause is now asked FIRST and KEEPS (reason `"paused"`), below only "nothing landed" and "not
+this bucket's shape"; the leaf question is then asked of the compiled root, built for that one
+slug — which tier group holds a channel changes the order, never whether a leaf draws a bucket.
+
+**`excludeFromSync` is DELETED** — field, sanitizer clause, action, toggle, and both legacy
+skips in `syncScheduler.ts` (`:132`, `:259`), `syncAllChannelsAction` and `stationWorkFor`.
+`isChannelPaused(model, slug, "sync")` is the one question all five ask. `parseChannelConfig`
+DROPS the key rather than carrying it, so an un-migrated config parses to a channel that says
+nothing about sync and therefore syncs — which is what it did before the flag existed. That is
+why `common/bin/migrate-channel-priority.ts` reads the raw `config.json` for the flag, and why
+the editor's legacy seed passes `{}`: the seed is after the lane ORDER, not the flag.
+`excludeFromBuild` and `excludeFromCleanup` are untouched — different axes.
+
+**The live corpus, measured through the migration's `--dry-run` against a read-only copy of
+`settings.json`:** 68 channels, 28 entries, 14 ranked (dense 0–13, `quartering-live` first),
+15 carrying `overrides: {sync:"paused"}`, no focus, every base tier `normal`. All four
+compiled roots are `prio-normal(68) > prio-all`. No lane's membership moves; only the `sync`
+operation loses anyone.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,11 +3,13 @@
The working memory for the local-AI derived-corpus work. Rewritten at the end of every
session, before context is cleared. See [`README.md`](README.md) for the protocol.
-**Last updated:** 2026-09-11 — **`relocate-channel-media` shipped**, all three slices, on
-branch `storage/relocate-media` off `61eae05` and unmerged; the entry with the rollout order is
-below, under the Phase 1 record it builds on.
+**Last updated:** 2026-09-12 — **both interlude shipments are merged** on branch
+`integrate/2026-09-storage-priority`: `relocate-channel-media` (from `storage/relocate-media`)
+and `channel-priority` (from `channel-priority/s5`), in that order, off `e74f005`. The branch
+is unmerged and waits on a fast-forward to `main`; the two entries, and the one operator
+runbook they now share, are below under the Phase 1 record they build on.
-**2026-09-08 — one-core Phase 1 shipped** on branch `one-core/phase-1`
+**Previously:** 2026-09-08 — **one-core Phase 1 shipped** on branch `one-core/phase-1`
(`7f294df` → `81a663f` plus a docs commit, 36 commits, not merged): **dispatch is one scheduler, and the lane is the
noun.** The slice-level record — every sha range, every divergence, both operator gates — is
[`one-core-phase-1.md`](one-core-phase-1.md); the umbrella is
@@ -171,25 +173,101 @@ shipped, the bytes did not. The slice table, the two review rounds and eight div
that plan's "As shipped (2026-09-11)"; the anchors are in
[`FACTS.md`](FACTS.md#one-core-phase-1-verified-2026-09-08).
-**The rollout is the operator's, in this order:**
-
-1. **Mount the platter.** `sdb1` (1.8 T, ext4) at `/mnt/platter` with `nofail` in fstab;
+**2026-09-11 — channel priority shipped** on branch `channel-priority/s5`
+(`28bfee3` → the tip below, four merge commits plus eight of its own, **not merged**):
+**one tier per channel, one focus, four COMPILED lane trees.** S0 (`28bfee3`) is the model;
+S1 (`2710195`) dispatch; S2 (`76c5784`) sync; S3 (`88da736`) the `/channels` UI and the one
+writer; S4 (`9fcc8b4`) the banner and the read-only tree; S5 merged all four in order (no
+conflicts — the slices touched disjoint files), closed the twelve parked review findings, ran
+the migration's dry run against the live corpus and deleted `excludeFromSync`. Anchors:
+[`FACTS.md`](FACTS.md#channel-priority-verified-2026-09-11); the record with every divergence
+is [`channel-priority.md`](channel-priority.md)'s "as shipped".
+
+- **The model compiles, it is not consulted.** `settings.channelPriority` → `prio-focus` >
+ `prio-normal` > `prio-low` > `prio-all`, all strict, per lane. No dispatch code changed: a
+ focus holds the rest because strict descent already does, re-asked on every grant. An absent
+ document is today's behaviour byte for byte.
+- **The data-loss find.** `backfillReacquire.decideKeep` asked `policyDrawsBucket` on the
+ transcription root — on a COMPILED root a channel paused for transcription has no leaf, so
+ it would have answered "no-leaf" and unlinked the re-acquired audio of exactly the channels
+ an operator had just put on hold. The pause is asked first and KEEPS. A hold is never a stop.
+- **The order that nearly went.** The bypass is all-or-nothing, so the first tier click on
+ `/channels` stops the stored trees being dispatched from — and the two hand-made 9+9 lane
+ orders live only there. The one writer now seeds from `channelPriorityFromLegacy` when the
+ stored document says nothing and the stored roots are not already compiled. And a corpus
+ whose roots ARE compiled recompiles even for a default document, or ending a focus would
+ leave `prio-focus` at the head of a tree the runner is bypassing to.
+- **One writer of `root` too.** `saveAutoQueueAction` and `armLaneAction`-with-a-scope refuse
+ server-side while a model exists; `prioritizeChannelDownloadAction` is a priority edit;
+ create/delete recompile.
+- **`excludeFromSync` is deleted** — field, sanitizer clause, action, toggle, and all five
+ legacy reads. `excludeFromBuild`/`excludeFromCleanup` untouched.
+
+**The full editor suite at `a8e908a`: 528 passed, 0 failed of 528, 22.9 min** from this
+worktree behind the queue lock (518 + the 10 new priority cases). Three specs went red on
+the way and all three were right: `channel-priority.spec.ts:172` caught End focus leaving a
+stale `prio-focus` at the head of the stored tree, and `new-channel-onboarding.spec.ts:111`
+was still reading "Add to top of auto-queue" as a hand-written leaf.
+
+**Two flakes cost a suite run and are named so the next session does not re-chase them.** An
+earlier run at `a8e908a` was 526/528: `no-subs-fallback.spec.ts:114` failed on an `EEXIST`
+inside `resetData`'s `cp` (a filesystem race in the harness, 3/3 green on re-run), and
+`backfill.spec.ts:457` failed on `uncheck()` not changing a controlled checkbox — the
+`PolicyTreeEditor` adopt-effect race, where the 3 s status poll calls `setForm` between the
+click and its assertion. Measured after: 1 failure in 6 runs of that case at `a8e908a`, 0 in
+9 at `4d256a2`, and the clean 528/528 above. Its payload path for a DEFAULT priority
+document is unchanged by this branch, so it reads as the known adopt race rather than a
+regression — but the sample is small, and it is the case to look at first if it recurs.
+
+**⚠ THE MIGRATION MUST RUN BEFORE THE FIRST BOOT OF THIS CODE, not after it.**
+`excludeFromSync` is deleted and `parseChannelConfig` drops the key, so on a corpus that has
+not been migrated the 15 channels that carried it read as *saying nothing about sync* — and
+four places act on that the moment the editor starts: `syncScheduler.ts:132` (selection),
+`:259` (`autoSyncEligible`, so the projection changes on sight),
+`channels/actions.ts:524` (*Sync all*) and `channelGroupSections.ts:140` (a group's Sync).
+The automatic tick is the one thing that does NOT fire on the live box — its
+`heartbeatSeconds` is 0 — but *Sync all* or a group Sync in that window sweeps every channel
+the operator had excluded. Nothing else moves: no lane's membership, no download, no
+transcription. **Order: stop the editor → run the migration → start it.** The script takes
+its own timestamped `settings.json.pre-priority-<ISO>` backup beside the file before its
+first write (refusing to overwrite an existing one), so the backup is not a step anyone can
+skip.
+
+**IT HAS NOT BEEN RUN — that is the operator's step at rollout.**
+`common/bin/migrate-channel-priority.ts --dry-run` against a READ-ONLY copy of the live
+`settings.json` (no config.json or settings.json mtime moved) says: **68 channels, 28
+entries, 14 ranked, 15 pinned `sync=paused`, no focus, every base tier `normal`**, and all
+four compiled roots `prio-normal(68) > prio-all`. The ranked order is
+`quartering-live, the-quartering-rumble, the-quartering, HasanAbiVODs3, nuxanor, hasanabi,
+darlingstrawb, rekietalaw-rumble, chibi-reviews, nux-taku, destiny, omnivods-odysee,
+leaflit-rumble, piratesoftware` — dense 0–13, merged index then the transcription lane's
+order then slug. No lane's membership moves; only `sync` loses anyone.
+
+**The rollout is the operator's, and the two shipments share one sequence. The migration
+goes first, because it is the only step that must happen before this code ever boots.**
+
+1. **Run the channel-priority migration, with the editor stopped.** `excludeFromSync` is a
+ deleted field, so an unmigrated corpus reads its 15 channels as saying nothing about sync
+ the moment the editor starts. Order: **stop the editor →
+ `pnpm -C common exec tsx bin/migrate-channel-priority.ts` → start it again.** Run
+ `--dry-run` first; the real run takes its own timestamped
+ `settings.json.pre-priority-<ISO>` backup beside the file, and is a no-op on a second run.
+ Never run it against a live editor: a running one holds settings in memory and writes them
+ back on its own schedule.
+2. **Mount the platter.** `sdb1` (1.8 T, ext4) at `/mnt/platter` with `nofail` in fstab;
create `/mnt/platter/archilyzer-media` and `/mnt/platter/archilyzer-saved-videos`, owned by
`user`. Nothing below works before this, and no code needed it.
-2. **Step 0, the saved-video store, by hand** — the plan's runbook: rsync
+3. **The saved-video store, by hand** — the relocate plan's runbook: rsync
`transcripts/saved-videos/` out, verify with `--dry-run --itemize-changes`, move the
original aside, symlink the store root, restart, then delete the original. **That is the
step that frees the 130 GB**, it needs no code at all (every pointer carries an absolute
- `dir` and nothing walks the store root), and it goes first because a 100 %-full `/home` is
- a hazard to every unrelated writer while the channel moves run.
-3. **Channels through the UI, largest deprioritized first.** Storage panel per channel, or tick
+ `dir` and nothing walks the store root), and it goes before the channel moves because a
+ 100 %-full `/home` is a hazard to every unrelated writer while they run.
+4. **Channels through the UI, largest deprioritized first.** Storage panel per channel, or tick
rows on `/channels` and use the bulk bar; `omnimirror` (130.3 GB) is the obvious first move.
- Sizes are in the plan's table — they are sizes, not priorities.
+ Sizes are in the relocate plan's table — they are sizes, not priorities.
-**Next:** [`channel-priority.md`](channel-priority.md) (one channel priority model that
-replaces the two `excludeFrom*` flags and compiles to the four lane trees; S0 contract, then
-S1–S4 in parallel, S5 migration last), merged after relocate. Then phase 2 (the contract:
-one `ArchiveReader`), 3 slices. Read
+**Next:** one-core Phase 2 (the contract: one `ArchiveReader`), 3 slices. Read
`common/architecture.test.ts`'s allow-list first — it is the shortest accurate statement of
what is still tangled, it shrank by one across phase 1, and no slice added an entry, relocate
included.
diff --git a/plans/channel-priority.md b/plans/channel-priority.md
@@ -1,6 +1,18 @@
# Channel priority — one tier per channel, one focus, four compiled trees
-**Status:** design only, nothing implemented. Anchors are at `61eae05` on `one-core/phase-1`.
+**Status: SHIPPED** on branch `channel-priority/s5` (unmerged), 2026-09-11. All six slices
+are implemented; see **"As shipped"** at the foot of this file for the shas, the divergences
+and the live migration's dry-run numbers. Anchors are in
+[`FACTS.md`](FACTS.md#channel-priority-verified-2026-09-11). Everything above that section is
+the plan as written, kept as-is.
+
+Operator decisions taken, numbered as the Open questions section below: **Q1 — collapse** the
+two live per-lane channel orders to ONE (the model has one rank per channel). **Q2 — no**, a
+Paused channel still runs a MANUAL run; paused gates the sync scheduler, *Sync all* and the
+four auto lanes, nothing else. **Q3 — resolved by design**, by the per-operation override map
+(see the Model): all 15 migrate to a `sync` override and no lane's membership moves. **Q5 —
+no**, activating a focus never changes any lane's `enabled`. Q4 is still open and does not
+affect S0.
The ask: *"focus on one group of channels (e.g. jeralyzer) and pause all others until the
priority channels are done … rip out the current enable/disable feature on the channels page
@@ -107,13 +119,17 @@ runner's channel list, not a tree shape.**
30 s TTL (`CHANNEL_LIST_TTL_MS`, `:141`). Filtering Paused there makes a paused channel
invisible to all four lanes, catch-all included, in one predicate.
-4. **`excludeFromSync` dissolves into the Paused tier; `excludeFromBuild` stays.** They are
- different axes: one is scheduling, one is publishing, and the brief's own rule is that the
- lowest tier must not gate export. The `/channels` row keeps its Build toggle and loses its
- Sync toggle.
+4. **`excludeFromSync` dissolves into a `sync` OVERRIDE, not into the Paused tier;
+ `excludeFromBuild` stays.** The three are different axes: one is the sync cadence, one is
+ dispatch, one is publishing, and the brief's own rule is that the lowest tier must not gate
+ export. A per-operation override says "stop syncing, keep everything else" exactly, so the
+ migration moves no lane's membership. The `/channels` row keeps its Build toggle and loses
+ its Sync toggle.
-5. **No drag-and-drop, no per-lane overrides.** Tier is a `<select>`; intra-tier order is an
- optional integer `rank` (migration seeds it; ties fall back to slug order).
+5. **No drag-and-drop, and no per-lane RANK.** Tier is a `<select>`; intra-tier order is an
+ optional integer `rank` (migration seeds it; ties fall back to slug order), and there is
+ ONE rank per channel. Per-operation *tier* overrides do exist (above) — they change which
+ lane a channel is on, never its order within one.
## The model
@@ -124,6 +140,13 @@ bars `lib → controller|jobs`).
```ts
export const CHANNEL_TIERS = ["focus", "normal", "low", "paused"] as const;
export type ChannelTier = (typeof CHANNEL_TIERS)[number];
+// What may be STORED on a channel — "focus" is a compiled POSITION, never a stored tier.
+export const STORED_CHANNEL_TIERS = ["normal", "low", "paused"] as const;
+
+// The operations a tier can be pinned to: the four lanes plus `sync` (which is
+// not a lane — it is a per-channel cadence, the answer pauseLaneFor already gives it).
+export const PRIORITY_OPERATIONS = ["sync", ...LANES] as const;
+export type PriorityOperation = (typeof PRIORITY_OPERATIONS)[number];
export type ChannelFocus =
| { kind: "none" }
@@ -133,10 +156,42 @@ export type ChannelFocus =
export type ChannelPriority = {
focus: ChannelFocus;
// Only channels that differ from the default appear. Absent slug = normal, no rank.
- channels: Record<string, { tier: ChannelTier; rank?: number }>;
+ channels: Record<string, {
+ tier: StoredChannelTier;
+ rank?: number;
+ // PER-OPERATION OVERRIDES of the base tier. Only operations that DIFFER appear.
+ overrides?: Partial<Record<PriorityOperation, StoredChannelTier>>;
+ }>;
};
```
+**Per-operation overrides are the advanced half, and they are what makes the migration
+lossless.** The operator's case: *"I find the download on channels that sync to be an issue
+(e.g. omnibased)"* — a channel must be able to keep its playlist and metadata current while
+its download lane is parked, and in general any operation may sit at a different tier than
+the channel's base. That is also precisely what `excludeFromSync` meant, read the other way
+round, so the flag this model replaces becomes `{tier: "normal", overrides: {sync: "paused"}}`
+and nothing else moves.
+
+- `effectiveTier(model, slug, op)` = `overrides?.[op] ?? tier`, and **every dispatch-side
+ question asks it**: the compiler per lane, `listChannelMeta` for the lane it is listing,
+ the sync scheduler for `"sync"`. `tierOf` stays the BASE tier and is the display answer.
+- **The focus set stays ONE, corpus-wide.** A focused channel whose override for some
+ operation is `paused` is simply not drawn for that operation — focus wins over the stored
+ tier, paused wins over focus, per operation.
+- **Two presets, each the other's inverse.** `{tier:"normal", overrides:{sync:"paused"}}` is
+ "everything but sync"; `{tier:"paused", overrides:{sync:"normal"}}` is "sync only".
+- **Sanitizer normalisation:** an unknown operation key is dropped; an unknown tier VALUE is
+ dropped rather than coerced (coercing to `normal` would silently unpause an operation on a
+ paused channel — an override's job is to differ from the base, so a junk one falls back to
+ the base); an override equal to the base is normalised away, and an empty map with it; a
+ `rank` on a channel paused for *every* operation is dropped, while a "sync only" channel
+ keeps one because the sync scheduler still orders it. Keys are emitted in
+ `PRIORITY_OPERATIONS` order and slugs sorted, so the on-disk document has a stable diff.
+- **The four compiled trees are identical unless an override moves a channel.** One rank per
+ channel means the only thing that can differ between lanes is membership, and the only
+ thing that changes membership is an override.
+
- `focus: {kind:"site"}` is the first-class answer to *"focus = the channels of site X"*, and
it is resolved at compile time against `transcripts/sites/<id>/site.json`'s `channels[]`, so
it tracks membership rather than freezing a list. `{kind:"channels"}` backs "Focus these".
@@ -194,9 +249,11 @@ noise. `flattenLeaves`/`hasWork` walk ~69 nodes per pick.
for the banner, and `channelPriorityFromLegacy(configs, autoQueue)` for the migration.
2. **`common/controller/autoRunner.ts:288-296`** — `listChannelMeta` drops
- `tierOf(priority, slug) === "paused"`. One predicate; both the runner loop and
- `computeLeafPending` inherit it, and the 30 s TTL (`:141`) is the re-evaluation clock.
- *Nothing else in dispatch changes.*
+ `isChannelPaused(priority, slug, lane)` — the EFFECTIVE tier for the lane being listed,
+ so a channel paused for `download` and normal for `transcription` is absent from one and
+ present in the other. One predicate; both the runner loop and `computeLeafPending`
+ inherit it, and the 30 s TTL (`:141`) is the re-evaluation clock. *Nothing else in
+ dispatch changes.*
3. **A new idle reason is NOT added.** A lane whose focus group holds the rest is not idle —
it is dispatching focus work. When the whole tree is empty the existing `no-pending`
@@ -206,12 +263,15 @@ noise. `flattenLeaves`/`hasWork` walk ~69 nodes per pick.
4. **Sync** — `common/jobs/syncScheduler.ts`:
- `selectDueChannels` takes the model. The `excludeFromSync` skip at `:99` becomes
- `tierOf(priority, slug) === "paused"`, with the same silent `continue`.
+ `isChannelPaused(priority, slug, "sync")` — the effective tier for the `sync`
+ operation, which is exactly what the flag meant — with the same silent `continue`.
- The sort at `:125` becomes **tier rank, then rank, then `overdueMs` descending**:
- `focus < normal < low`. Most-overdue-first survives *within* a tier, so a focus channel
+ `focus < normal < low`, all read through `effectiveTier(·, "sync")`. Most-overdue-first
+ survives *within* a tier, so a focus channel
due by a minute outranks a low channel due by a day, and the tick's cap
(`editor/app/scheduler/runTick.ts:127-131`) therefore spends its slots on focus first.
- `autoSyncEligible` (`:193-197`) follows the same predicate.
+ - `tierOrder(tier)` in `channelPriority.ts` is the one sortable form of the tier order.
- `syncAllChannelsAction` (`editor/app/channels/actions.ts:466-472`) swaps its
`excludeFromSync` skip for the same one and sorts the candidate list the same way.
@@ -223,6 +283,10 @@ noise. `flattenLeaves`/`hasWork` walk ~69 nodes per pick.
and `BulkCadenceBar.tsx:37,41` verbatim rather than inventing an idiom. Bulk actions:
**Set tier**, **Focus these**, and a **Focus site: `<id>`** menu built from
`listSites()` (no selection needed).
+ - **Advanced: per-operation selects behind a disclosure**, beside the tier control, plus a
+ **Sync only** preset (and its inverse, which is what every migrated `excludeFromSync`
+ channel already carries). The row shows the base tier and a marker when any operation is
+ pinned; the disclosure is where the five operations are set.
- Sort key `sync` (`ChannelsTable.tsx:54-63,129-163`) becomes `tier` (tier order, then
rank, then slug). Row dimming (`:406-411`) keys off `tier === "paused"`.
- `channelGroupSections.ts:124`'s excluded-from-sync section becomes the Paused section.
@@ -264,20 +328,28 @@ Live data: **15 of 68 channels carry `excludeFromSync: true`** — `community-no
`exclusively-games`, `rcflightschool`, `rcspotlight`, `teamrcn`,
`the-incredible-salt-mine`, `steven-crowder`, `midwestly`, `redbar` — and **zero** carry
`excludeFromBuild`. One of the 15, `omnivods-odysee`, is also the 8th ranked leaf of the live
-download tree: today it is excluded from sync yet still drawn by the download lane. **Paused
-wins** — that is the whole point of a unified model — so migrating it is a real behaviour
-change for that one channel and must be named in the commit. `community-notes` is a jeralyzer
-channel, so a jeralyzer focus will not resurrect it either: paused is applied before the tree
-is consulted.
+download tree: today it is excluded from sync yet still drawn by the download lane.
+
+**The mapping is LOSSLESS, and that is what the override map is for.** `excludeFromSync`
+meant "stop syncing", not "stop everything", so it becomes
+`{tier: <base>, overrides: {sync: "paused"}}` — the base tier stays `normal` and the rank
+(if the channel has one) stands. `omnivods-odysee` keeps downloading exactly as it does
+today; **no lane's membership moves**, which makes the migration a settings rewrite rather
+than a behaviour change. An operator who wants one of the 15 paused outright sets its base
+tier afterwards, deliberately, on `/channels`.
- **Pure function**, `channelPriorityFromLegacy(configs, autoQueue): ChannelPriority`, in
`common/lib/channelPriority.ts`, in the `laneMigration.ts` style (no I/O, idempotent over its
own output, asserted through `sanitizeChannelPriority`):
- - `config.excludeFromSync === true` → `{tier: "paused"}`, checked **first**, so a paused
- channel that also has a lane leaf (`omnivods-odysee`) is paused and keeps no rank.
+ - `config.excludeFromSync === true` → `overrides: {sync: "paused"}` on the channel's entry,
+ never a base tier. A channel that also has a lane leaf (`omnivods-odysee`) keeps its rank
+ and every lane it was drawn by.
- A channel named by a bare channel leaf in **either** the download or the transcription root
- → `{tier: "normal", rank: <the lower of its two leaf indices>}`. Channels ranked in one
- lane only keep that lane's index.
+ → `{tier: "normal", rank: <the lower of its two indices>}`, where the index is its
+ position among that root's **bare channel leaves** (a dense order; identical to the leaf
+ index when every leaf is bare, which all 22 live leaves are — so a bucket or operation
+ leaf the model cannot express leaves no hole). Channels ranked in one lane only keep that
+ lane's index.
- Everything else → absent (normal, unranked).
- `focus: {kind: "none"}`.
- **It is NOT run from `getSettings`.** That function is synchronous and reads one file; the
@@ -285,9 +357,9 @@ is consulted.
`common/bin/migrate-channel-priority.ts`, run offline with `tsx` (never a second editor), that
reads the configs and `settings.json`, writes `channelPriority`, recompiles the four roots and
prints a diff. Idempotent: a second run is a no-op because the document already exists.
-- **The collapse is a behaviour change and must be measured.** Six channels are ranked in one
- lane only, and the three Quartering channels are in different orders in the two lanes; after
- the migration both lanes get one order. Gate it with `plans/tools/phase1-numbers.ts`
+- **The order collapse is the migration's ONE behaviour change, and must be measured.** Six
+ channels are ranked in one lane only, and the three Quartering channels are in different
+ orders in the two lanes; after the migration both lanes get one order. Gate it with `plans/tools/phase1-numbers.ts`
before/after over the live corpus, the way every Phase 1 slice was gated — the `*_leaves`
lines will move by construction, so the number that must not move is each lane's total
pending count.
@@ -308,7 +380,9 @@ is consulted.
corpus); channel focus keeps order and drops unknown slugs.
3. `compileLaneRoot`: group order focus → normal → low → catch-all-last; paused slugs never
appear; rank ordering with unranked tail; ids are `prio-*`; an empty focus emits no focus
- group; the output survives `sanitizeAutoQueue` unchanged.
+ group; a per-operation override moves a channel on **one** lane's tree and not the others.
+ The half that must import the engine — *the output survives `sanitizeAutoQueue`
+ unchanged*, with no id reassigned — is `jobs/channelPrioritySanitize.test.ts`.
4. **The focus/hold/done transition, asserted through the real engine**: build two
`ChannelWork` objects, compile a tree with channel A focused, and drive `buildPendingByLeaf`
+ `selectNextWork` — every pick is A's while A has work; the first pick after A's list is
@@ -317,7 +391,9 @@ is consulted.
in `lib/`, because it must import `jobs/autoQueuePolicy` and `architecture.test.ts` forbids
`lib/ → jobs/` — the same reason `laneMigration.test.ts` sits in `jobs/`.
5. `channelPriorityFromLegacy`: the live shape (9+9 leaves, 6 one-lane channels, 3 reordered);
- an `excludeFromSync` config; a file with the document already present (untouched).
+ an `excludeFromSync` config becoming a `sync` override only; and **the 15-channel case
+ round-tripping with no lane change** — every channel still on every lane's compiled tree,
+ only the `sync` operation losing anyone.
6. Sync ordering: `selectDueChannels` with one focus channel 1 min overdue and one low channel
1 day overdue returns the focus channel first; a paused channel is never due.
@@ -347,14 +423,20 @@ entry may be added**), and the named spec files.
## Slices
**S0 — the contract (lands first, small, blocks everything).** Branch `channel-priority/s0`.
-Files: `common/lib/channelPriority.ts` (all types, sanitizer, resolver, compiler, legacy
-function — the compiler may return a placeholder only if fully typed), the sanitizer wired into
+Files: `common/lib/channelPriority.ts` (all types including the per-operation overrides, the
+sanitizer, `effectiveTier`/`isChannelPaused`/`channelsForOperation`, the focus resolver, the
+compiler, `focusSummary` and the legacy function), the sanitizer wired into
`getSettings` (`settings.ts:~1396`) and `saveSettings` (`:1613-1620`), `SiteSettings` gains
-`channelPriority`, plus **empty-but-exported stubs** for the files S3/S4 import:
-`editor/app/channels/components/ChannelTierSelect.tsx` and
-`editor/app/channels/components/FocusBanner.tsx`. Tests 1-3 and 5. One commit. Nothing reads the
+`channelPriority` (and `editor/app/settings/actions.ts` PRESERVES it, the way it preserves
+`autoQueue` — rebuilding it would undo a focus), plus **empty-but-exported stubs** for the
+files S3/S4 import: `editor/app/channels/components/ChannelTierSelect.tsx` and
+`editor/app/channels/components/FocusBanner.tsx`. Tests 1-3 and 5. Nothing reads the
model yet, so the live tree is untouched and no number moves.
+The sanitizer round-trip half of test 3 lives in **`common/jobs/channelPrioritySanitize.test.ts`**,
+not in `lib/`: `architecture.test.ts` scans test files like any other and forbids `lib/ -> jobs/`,
+and its ALLOWED list may only shrink. Same reason `laneMigration.test.ts` sits in `jobs/`.
+
**S1 — dispatch.** Branch `channel-priority/s1`. Only `common/controller/autoRunner.ts`
(`listChannelMeta` paused filter) + `common/jobs/channelPriorityCompile.test.ts` (test 4).
@@ -389,26 +471,28 @@ plus `channels*.spec.ts`, and the full suite runs once at the merge.
## Out of scope
-Per-lane priority overrides; drag-and-drop ordering; time-boxed focus ("focus until Friday");
+Per-lane *rank* (a channel has one order, not four); drag-and-drop ordering; time-boxed focus ("focus until Friday");
auto-ending a focus when its work hits zero (the banner reports it, the operator ends it);
`excludeFromBuild` and `excludeFromCleanup`; the site-membership editor; any change to
`operationBatch`, `laneLimit`, `pauseGates`, the `held` keys or `.auto-queue/state.json`.
## Open questions for the operator
-1. **The two live trees disagree on six channels and on the Quartering ordering.** Collapse to
- one order (recommended — it is the point of the feature), or keep a per-lane `rank`?
-2. **Does Paused stop a *manual* run?** Recommended: **no**. Paused gates the sync scheduler,
+1. ~~**The two live trees disagree on six channels and on the Quartering ordering.** Collapse to
+ one order, or keep a per-lane `rank`?~~ **DECIDED: collapse.** One rank per channel.
+2. **Does Paused stop a *manual* run? DECIDED: no.** Paused gates the sync scheduler,
*Sync all*, and all four auto lanes; the per-video and per-channel Run buttons
(`runDigestChannelJob`, `runBackfillChannelJob`, the pipeline actions) still work, with a
"Paused — automatic work is off for this channel" badge on the channel page.
-3. **Are all 15 `excludeFromSync` channels really meant to be Paused everywhere?** Under the
- new model they stop being downloaded and transcribed too, not just synced. If some of them
- were only meant to stop *syncing* while still finishing a backlog, they want `low`, not
- `paused`, and the migration needs that list.
+3. ~~**Are all 15 `excludeFromSync` channels really meant to be Paused everywhere?**~~
+ **RESOLVED BY DESIGN (per-operation overrides).** The question only existed because the
+ first model could not say "stop syncing, keep everything else". It can now: all 15 migrate
+ to `overrides: {sync: "paused"}` with their base tier and rank untouched, no lane's
+ membership moves, and no list from the operator is needed. Pausing one outright is a
+ deliberate later edit on `/channels`.
4. **Do the 5 channels in no site belong in a tier by default?** They are normal today; a
site-scoped focus will hold them like any other non-focus channel.
-5. **Should a Focus also raise the lane's `enabled`?** Recommended: no — focusing must not
+5. **Should a Focus also raise the lane's `enabled`? DECIDED: no** — focusing must not
start a stopped lane, for the same reason `saveAutoQueueAction` refuses to unhold one.
## Roadmap placement — a recommendation, not applied here
@@ -433,3 +517,118 @@ auto-ending a focus when its work hits zero (the banner reports it, the operator
(one scheduler, one gate definition, one writer per settings block, a hold is never a stop),
so it belongs in the same interlude slot as `relocate-channel-media.md` and should cite them
rather than restate them.
+
+
+## As shipped (2026-09-11)
+
+Branch `channel-priority/s5`, off S0's `28bfee3` on top of `e74f005` (`one-core/phase-1`).
+**Unmerged**, and the migration has **not** been run — that is the operator's step at rollout.
+
+### The slices, in merge order
+
+| slice | branch | tip | what it is |
+|---|---|---|---|
+| S0 | `channel-priority/s0` | `28bfee3` | the model, the sanitizer, the compiler, the legacy read |
+| S1 | `channel-priority/s1` | `2710195` | `listChannelMeta`'s paused filter, `laneDispatchRoot`, the focus-hold log line |
+| S2 | `channel-priority/s2` | `76c5784` | `selectDueChannels`' predicate and its tier/rank/overdue order |
+| S3 | `channel-priority/s3` | `88da736` | `/channels` tier + bulk controls, `saveChannelPriorityAction` |
+| S4 | `channel-priority/s4` | `9fcc8b4` | the focus banner, the compiled ladder, the read-only tree |
+
+S1–S4 merged into S5 in order with **no conflicts** — the graph's disjoint-file-set claim held
+exactly. S5's own commits: the duplication fold (`7e3b282`), the keep decision (`13c5da4`),
+dense ranks and idempotence (`8c335d5`), one writer of `root` plus the legacy seed
+(`a5bb23f`), the seeding e2e (`a12aa14`), the migration script (`278e648`), the deletion of
+`excludeFromSync` (`dca3109`), the End-focus recompile (`d487519`), the records, the onboarding spec (`305fd7b`) and the
+review's three should-fixes (`a8e908a`: the rename writer, the migration's own backup, the
+rollout warning).
+
+### Divergences from the plan
+
+1. **`isDefaultChannelPriority` lives in `lib/channelPriority.ts`, not in the runner.** S1 kept
+ it private ("a dispatch-side question"), S4 shipped a second copy in the editor. Five
+ callers in two packages ask it, and `controller/backfillReacquire.ts` is one of them — a
+ controller importing the runner opens an import cycle. The model owns it; `laneDispatchRoot`
+ is exported from `autoRunner.ts` and is what the status payload calls, so there is one
+ definition of the dispatched tree as well.
+2. **`siteChannelIndex(paths)` is new, in `lib/site.ts`.** The plan did not name it; four
+ inlined copies of "read every site.json, map siteId → slugs" did.
+3. **The keep decision is in scope after all.** The plan said dispatch was untouched except for
+ `listChannelMeta`. `backfillReacquire.decideKeep` reads the transcription ROOT, which the
+ compiler rewrites, and on a compiled tree a transcription-paused channel has no leaf — so
+ the unchanged code would have unlinked re-acquired audio for every paused channel. It now
+ asks the pause first and keeps (`"paused"`), then asks the leaf question of the compiled
+ root. This is the one dispatch-adjacent change the plan did not foresee.
+4. **The legacy ranks are renumbered densely**, with a stated tie rule (merged index, then the
+ transcription lane's order, then slug). The plan's "lower of its two indices" leaves four
+ collisions on the live trees, and a collision falls through to slug order — neither lane's.
+5. **The migration is idempotent by DETECTION, not by "the document already exists".** Compiled
+ channel leaves are bare channel leaves, so a second run would re-derive ranks from its own
+ output. `hasCompiledLaneRoots` (a `prio-*` id anywhere in a lane root) is the gate.
+6. **The one writer seeds from the legacy trees**, and a corpus whose roots are already
+ compiled recompiles even for a DEFAULT document. Neither is in the plan, and both are
+ correctness: without the first, the first tier click destroys the hand-made order; without
+ the second, End focus leaves `prio-focus` at the head of the tree the runner bypasses to.
+7. **One writer now means `root` as well as the document.** `saveAutoQueueAction` and
+ `armLaneAction`-with-a-scope refuse server-side while a model exists, and
+ `prioritizeChannelDownloadAction` is a `{kind:"promote"}` priority edit. The writer takes
+ one option, `enableLanes`, so "Add to the top of the download queue" stays one write.
+8. **`channel-sync-toggle.spec.ts` went in S3**, not S5 — S3 removed the control it drove.
+9. **`renameChannelAction` is a priority writer too.** Not in the plan, which named only
+ create and delete. The document keys by slug, so a rename without it loses the channel's
+ tier, rank and overrides under a dead slug, drops it out of a `{kind:"channels"}` focus,
+ and leaves a `prio-*-<oldSlug>` leaf in every compiled root. The re-key is the pure
+ `renameChannelInPriority`; the write goes through the one writer.
+10. **The migration takes its own backup.** The plan left that to the operator. An operator
+ step on the destructive path is a step someone skips.
+
+### The banner's End focus, and why the link stayed
+
+S4 built `FocusBanner` with an `endFocus?: ReactNode` slot and a "Channel priorities" link,
+and deliberately no writer of its own. S5 fills the slot on the lane consoles with
+`EndFocusButton`, a client component that posts `endFocusAction` — S3's named gesture, so it
+is the same writer, not a second one. The link stays beside it: the button ends a focus, the
+link is the way to everything else the document says.
+
+### Gates
+
+`tsc --noEmit` clean in common, editor, export and mcp.
+`pnpm --filter yt-dlp-transcript-common test`: **985 passed** (974 at the merge — 951 + S1's
+11 + S2's 12 — plus 6 `laneDispatchRoot`/`priorityContextFor` cases, 4 `decideKeep` cases and
+1 idempotence case).
+**Full editor e2e at `a8e908a`: 528 passed, 0 failed, 22.9 min.**
+
+### The live migration, dry-run
+
+`SETTINGS_FILE` pointed at a read-only copy of the live `settings.json`, `TRANSCRIPTS_DIR` at
+the real corpus; no `config.json` or `settings.json` mtime moved. **68 channels, 28 entries,
+14 ranked, 15 pinned `sync=paused`, no focus, every base tier `normal`**, all four compiled
+roots `prio-normal(68) > prio-all`. Ranked order, dense 0–13:
+
+`quartering-live, the-quartering-rumble, the-quartering, HasanAbiVODs3, nuxanor, hasanabi,
+darlingstrawb, rekietalaw-rumble, chibi-reviews, nux-taku, destiny, omnivods-odysee,
+leaflit-rumble, piratesoftware`
+
+`omnivods-odysee` is the channel that is both ranked and excluded: it keeps its rank, keeps
+downloading, and loses only `sync`. No lane's membership moves.
+
+### Rollout — MIGRATE BEFORE THE FIRST BOOT, not after
+
+**This is a warning, not a recipe.** `excludeFromSync` is deleted and the parser drops the
+key, so until the migration has run the 15 channels that carried it read as saying nothing
+about sync, and four places act on that from the first render: `syncScheduler.ts:132`
+(selection), `:259` (`autoSyncEligible`), `channels/actions.ts:524` (*Sync all*) and
+`channelGroupSections.ts:140` (a group's Sync). The automatic tick is off on the live box
+(`heartbeatSeconds: 0`), so the scheduler will not act on its own — but one *Sync all* click
+in that window sweeps every channel the operator had excluded. No lane's membership moves and
+nothing downloads or transcribes differently; it is the sync pool alone.
+
+1. **Stop the editor.** Never migrate under a running one: it holds settings in memory and
+ writes them back on its own schedule.
+2. `pnpm -C common exec tsx bin/migrate-channel-priority.ts --dry-run` — read the table.
+3. `pnpm -C common exec tsx bin/migrate-channel-priority.ts` — it writes
+ `settings.json.pre-priority-<ISO>` beside the file first, refusing to overwrite an
+ existing backup name, so the backup is not a step anyone can skip.
+4. **Start the editor.** The runner re-reads the document per tick; the worker pool and the
+ heartbeat arm at boot.
+
+Idempotent: a second run changes nothing.