commit 6b4b523e7d43d35e8b2376483e225780689a7ad5
parent 26689d10b832efe9345baa20d62a27facfeb089e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 11:37:19 -0400
editor: one writer for the channel priority document, and it recompiles
`saveChannelPriorityAction` in editor/app/channels/actions.ts is the only
function that writes `settings.channelPriority`. It has to be, because the
document is not the only thing a priority change produces: the four
`autoQueue[lane].root` trees are COMPILED from it, so a second writer would
leave the model and the trees disagreeing until whoever wrote next happened to
recompile. The recompile therefore happens in the same `writeSettings` call,
with each policy SPREAD rather than rebuilt — `enabled`, `held`, `order`,
`snoozeUntil`, `maxWorkers` and `replaceAutoSubs` all survive, which is the rule
`saveAutoQueueAction` already states: a focus must never start a stopped lane or
unhold a held one.
The edit is a serializable union, not a callback — a server action's arguments
cross the boundary, so "apply this function to the current document" is not
expressible. Each variant is one operator gesture: a base tier on a set of
slugs, one operation pinned or unpinned, a preset, or the corpus-wide focus.
`applyPriorityEdit` only has to state the gesture; `sanitizeChannelPriority`
normalises the result (an override equal to the base is dropped, and so is an
entry saying nothing the default does not).
The site membership behind `focus.kind === "site"` is read at WRITE time, never
stored: a site focus tracks the site's membership rather than freezing a list,
which is the whole reason that variant exists.
The named gestures — setChannelTierAction, setChannelOperationTierAction,
applyChannelPriorityPresetAction, focusChannelsAction, focusSiteAction,
endFocusAction — are one call each to the writer, so a control names what it
does instead of assembling an edit inline.
Nothing reads these yet; the UI lands next.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 197 insertions(+), 1 deletion(-)
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -29,7 +29,28 @@ 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 {
+ listSites,
+ siteChannelSlugs,
+ type Site,
+} from "yt-dlp-transcript-common/lib/site";
+import {
+ getSettings,
+ writeSettings,
+} from "yt-dlp-transcript-common/lib/settings";
+import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes";
+import {
+ compileLanes,
+ DEFAULT_CHANNEL_TIER,
+ resolveFocusSlugs,
+ sanitizeChannelPriority,
+ type ChannelFocus,
+ type ChannelPriority,
+ type ChannelPriorityEntry,
+ type PriorityOperation,
+ type SiteChannelIndex,
+ type StoredChannelTier,
+} from "yt-dlp-transcript-common/lib/channelPriority";
import { activeSyncSlugs } from "yt-dlp-transcript-common/jobs/syncJobs";
import {
readSchedulerState,
@@ -569,3 +590,178 @@ 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 };
+
+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 === "focus") return { ...model, focus: edit.focus };
+ 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 };
+}
+
+// siteId -> its channel slugs, for `resolveFocusSlugs`. Read at WRITE time, not
+// stored: a site focus tracks the site's membership rather than freezing a list,
+// which is the whole reason `focus.kind === "site"` exists.
+function siteChannelIndex(paths: ReturnType<typeof getPaths>): SiteChannelIndex {
+ const index: Record<string, string[]> = {};
+ for (const site of listSites(paths)) {
+ index[site.siteId] = [...siteChannelSlugs(site)];
+ }
+ return index;
+}
+
+// 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,
+): 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 autoQueue = { ...settings.autoQueue };
+ for (const lane of LANES) {
+ autoQueue[lane] = { ...settings.autoQueue[lane], root: roots[lane] };
+ }
+ 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 },
+ });
+}
+
+export async function endFocusAction(): Promise<ActionResult> {
+ return saveChannelPriorityAction({ kind: "focus", focus: { kind: "none" } });
+}