commit 04c88cc460f7fcde5d632d5021884e2de6c6b0f2
parent a32f2f1ff14dda0787ef8cd3b0fa4c0991d01d3d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 12:27:21 -0400
priority: one writer of `root`, and a first click that cannot lose the order
Four findings, one rule: while the channel-priority document says anything,
`autoQueue[lane].root` is COMPILED, and the compiler's writer is the only
thing that may write it.
Three actions were still writing a root directly, and all three were writing a
tree the runner does not dispatch from — a silent no-op at best, and a tree
the next priority save overwrites:
- `prioritizeChannelDownloadAction` (finding 2) is now a priority EDIT. "Add
to the top of the download queue" is `{kind:"promote"}`: first rank,
everything ranked at or below it shifted down one, base tier forced to
normal, and the download lane enabled IN THE SAME WRITE — two writes would
race on one settings file, which is why the writer takes `enableLanes` and
why that is the only thing it will accept beside the document. The immediate
`startAutoRunner` is unchanged: the button has always meant "and go".
- `saveAutoQueueAction` (findings 7, 12) refuses a CHANGED tree while a model
exists, with a message naming /channels, and persists the stored root either
way. S4's read-only editor is a courtesy; this is a server action reachable
with any payload. Everything else on that form still saves — enabled,
maxWorkers, order and replaceAutoSubs are not compiled.
- `armLaneAction` with a scope (finding 11) refuses for the same reason: a
scope IS a tree. Arming with NO scope is untouched — it keeps the stored
tree and says nothing about priority.
THE LEGACY SEED (findings 3, 8), which is the difference between this shipping
and this destroying the two hand-made 9+9 lane orders on its first click.
`laneDispatchRoot` is all-or-nothing: the moment a document says anything the
stored trees stop being dispatched from. A first click setting one tier would
compile a tree in which nothing has a rank — alphabetical inside
`prio-normal` — over the trees that order lived in. So when the stored
document says nothing AND the stored trees are not already compiled, the
writer derives what the migration would have produced and applies the edit on
top of that. Never on a `recompile`, because creating a channel is not an
operator's statement about priority.
A DEFAULT DOCUMENT NOW COMPILES NOTHING, matching `laneDispatchRoot`'s own
bypass — clearing the last priority used to overwrite the stored trees with an
all-normal compile that the runner, back on its bypass, would then dispatch
from.
`createChannel` / `deleteChannel` recompile through that same writer
(finding 9): a compiled tree names its channels one leaf each, so a channel
added or removed since the last write leaves the stored trees stale.
common 985/985; tsc clean in common, editor, export, mcp.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 221 insertions(+), 64 deletions(-)
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -37,12 +37,18 @@ import {
getSettings,
writeSettings,
} from "yt-dlp-transcript-common/lib/settings";
-import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes";
import {
+ LANES,
+ type AutoQueueKind,
+} from "yt-dlp-transcript-common/lib/autoQueueTypes";
+import {
+ channelPriorityFromLegacy,
compileLanes,
DEFAULT_CHANNEL_TIER,
effectiveTier,
+ hasCompiledLaneRoots,
isChannelPaused,
+ isDefaultChannelPriority,
rankOf,
resolveFocusSlugs,
sanitizeChannelPriority,
@@ -54,6 +60,7 @@ import {
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,
@@ -71,7 +78,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;
@@ -210,6 +216,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) {
@@ -242,12 +258,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 */
}
@@ -553,6 +572,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");
@@ -655,7 +682,17 @@ export type ChannelPriorityEdit =
// 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 };
+ | { 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" };
function entryFor(
model: ChannelPriority,
@@ -674,7 +711,31 @@ function applyPriorityEdit(
model: ChannelPriority,
edit: ChannelPriorityEdit,
): ChannelPriority {
+ if (edit.kind === "recompile") return model;
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();
@@ -717,18 +778,60 @@ function applyPriorityEdit(
// 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 next = sanitizeChannelPriority(
- applyPriorityEdit(settings.channelPriority, edit),
- );
- const slugs = (await listChannelConfigs(paths)).map((c) => c.slug);
- const focusSlugs = resolveFocusSlugs(next, siteChannelIndex(paths), slugs);
- const roots = compileLanes(next, slugs, focusSlugs);
+ 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`: creating a channel is not an operator's statement
+ // about priority, and it must not silently switch a corpus from its stored
+ // trees to compiled ones.
+ const base =
+ edit.kind !== "recompile" &&
+ isDefaultChannelPriority(stored) &&
+ !hasCompiledLaneRoots(settings.autoQueue)
+ ? channelPriorityFromLegacy(configs, settings.autoQueue)
+ : stored;
+ const next = sanitizeChannelPriority(applyPriorityEdit(base, edit));
+ // A DEFAULT DOCUMENT COMPILES NOTHING, which is the same rule
+ // `laneDispatchRoot` applies: with nothing to say, the stored trees stand
+ // exactly as they are. Compiling here anyway would replace a hand-made tree
+ // with an all-normal one at the moment the last priority was cleared — and
+ // the runner, back on its bypass, would then dispatch from that.
const autoQueue = { ...settings.autoQueue };
- for (const lane of LANES) {
- autoQueue[lane] = { ...settings.autoQueue[lane], root: roots[lane] };
+ if (!isDefaultChannelPriority(next)) {
+ 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 });
@@ -786,6 +889,39 @@ export async function focusSiteAction(siteId: string): Promise<ActionResult> {
});
}
+// 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/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 };