commit 2f215eb731fc785551a5ce100c0cc5c69ae70ef6
parent 26689d10b832efe9345baa20d62a27facfeb089e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 11:43:49 -0400
common: the sync scheduler asks the priority model, and orders focus first
S2 of plans/channel-priority.md. `selectDueChannels` and `buildScheduleView`
take the model and ask it exactly one question each.
The due loop gains `isChannelPaused(priority, slug, "sync")` beside — not
instead of — the legacy `excludeFromSync` skip, with the same silent
`continue`. Both stand until S5's migration has turned every flag into
`overrides: {sync: "paused"}` and deletes the flag; until then the flag is
still the only thing some channels carry. The EFFECTIVE tier for the "sync"
operation is what decides, so the two presets work as designed:
`{tier:"normal", overrides:{sync:"paused"}}` stops syncing and keeps
downloading, and `{tier:"paused", overrides:{sync:"normal"}}` is the
sync-only channel the operator asked for (omnibased).
The sort becomes 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 `maxConcurrentSyncs` spends its slots on focus
first. Ties all the way down keep the input order — slug order from
`listChannelConfigs`, and a stable sort — which is exactly what this function
returned before the model existed.
`autoSyncEligible` follows the same predicate, so the sync console cannot
show a channel as eligible that the scheduler will not schedule.
Focus is passed IN, resolved. It is a compiled position, never a stored
tier, and a `{kind:"site"}` focus reads `transcripts/sites/*/site.json` —
I/O this pure module must not do. `runTick.ts` resolves it (only a site
focus pays the `listSites` read) and hands it in; `buildScheduleView` does
not need it at all, because focus changes the ORDER, not who is eligible.
The three editor call sites pass `settings.channelPriority`. Required rather
than optional on both inputs: an optional model with a default would compile
green and schedule paused channels forever, and no later slice wires these.
Nothing about the tick's timing, the sync operation or runYtdlp changes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 101 insertions(+), 11 deletions(-)
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,23 @@ 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
+ // BOTH SKIPS STAND until S5 deletes the legacy one. `excludeFromSync` is
+ // the flag the priority model replaces; the migration turns each of them
+ // into `overrides: {sync: "paused"}`, and until it has run the flag is
+ // still the only thing some channels carry. Silent `continue` either way —
+ // "this channel does not auto-sync" is configuration, not a hold worth a
+ // line in the run log.
if (config.excludeFromSync) continue;
+ if (isChannelPaused(priority, slug, "sync")) continue;
const interval = resolveIntervalMinutes(config, scheduler);
if (interval <= 0) continue; // per-channel disabled
@@ -119,10 +154,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 +190,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 +225,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);
@@ -194,6 +259,7 @@ export function buildScheduleView(input: {
scheduler.enabled &&
!!config.url &&
!config.excludeFromSync &&
+ !isChannelPaused(priority, slug, "sync") &&
interval > 0,
intervalMinutes: interval,
inheritsInterval: config.syncIntervalMinutes === undefined,
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/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 { listSites } 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,31 @@ 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 `listSites()` per tick is not a cost worth memoizing.
+ const priority = settings.channelPriority;
+ const siteChannels: Record<string, string[]> = {};
+ if (priority.focus.kind === "site") {
+ for (const site of listSites(paths)) {
+ siteChannels[site.siteId] = site.channels.map((c) => c.slug);
+ }
+ }
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)
@@ -134,7 +155,8 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> {
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
+ // excludeFromSync + 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,