commit 00f8679f25645b0fe6015379addfa2eb96476e1f
parent a1d081dd366dd337b5927e117d11386e4c4716a8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 12:06:20 -0400
Merge channel-priority/s3 — the /channels UI and the one writer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
10 files changed, 1214 insertions(+), 114 deletions(-)
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -29,7 +29,32 @@ 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,
+ effectiveTier,
+ isChannelPaused,
+ rankOf,
+ resolveFocusSlugs,
+ sanitizeChannelPriority,
+ tierOrder,
+ 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,
@@ -447,6 +472,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
@@ -460,19 +494,42 @@ 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 already ask the priority document
+ // through `stationWorkFor`, and this slice removed the only control that
+ // could set the flag this loop used to read. So the model is asked here too,
+ // BESIDE the legacy flag — S5 deletes the second branch with the field.
+ 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 (config.excludeFromSync) return "excluded from sync all";
+ 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).
@@ -569,3 +626,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" } });
+}
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/ChannelTierSelect.tsx b/editor/app/channels/components/ChannelTierSelect.tsx
@@ -1,23 +1,43 @@
-// THE /channels TIER CONTROL — a STUB, filled in by slice S3.
+"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.
//
-// It exists at S0 only so S3 and S4 each edit a file the other does not: S3
-// fills this in and S4 fills in FocusBanner.tsx, and the two branches run in
-// parallel with no shared file. The props are the contract both slices code
-// against; the body is `null` until S3.
+// 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.
//
-// What S3 makes of it: the cell that `ChannelSyncToggle` occupies today
-// (ChannelsTable.tsx) becomes a `<select>` over STORED_CHANNEL_TIERS with
-// `aria-label={`tier for ${slug}`}`, posting `setChannelTierAction(slug, tier)`
-// — one writer, `saveChannelPriorityAction`, recompiles the four roots in the
-// same saveSettings call. `focused` and `heldReason` are DISPLAY facts derived
-// from the same `focusSummary` the banner uses, not a second read. The
-// per-operation overrides sit behind a disclosure beside the select, with a
-// "Sync only" preset; `overrides` carries what is currently pinned.
-
-import type {
- PriorityOperation,
- StoredChannelTier,
+// `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;
@@ -34,8 +54,187 @@ export type ChannelTierSelectProps = {
disabled?: boolean;
};
-export default function ChannelTierSelect(
- _props: ChannelTierSelectProps,
-): React.ReactNode {
- return null;
+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,14 +16,38 @@ 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 {
+ 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.
@@ -56,7 +80,7 @@ type SortKey =
| "name"
| "handling"
| "build"
- | "sync"
+ | "tier"
| "playlist"
| "lastSync"
| "report"
@@ -71,7 +95,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
@@ -121,6 +145,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);
@@ -143,10 +176,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);
@@ -178,6 +217,8 @@ export function ChannelsTable({
columns,
sections = null,
siteId,
+ sites = [],
+ focusLabel = null,
}: {
channels: ChannelRow[];
// Which pipelines to draw, in group order, resolved on the server from the
@@ -190,8 +231,18 @@ export function ChannelsTable({
// pool. That path is today's flat table, unchanged.
sections?: ChannelGroupSection[] | null;
siteId?: 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 priority edit. A Set of SLUGS, not indices, so a
+ // re-render that reorders or drops a row cannot retarget the selection —
+ // the same reason SyncConsole keys its selection this way.
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
// Plain component state, deliberately NOT the URL: router.replace races the
// global AutoRefresh's router.refresh() and gets dropped.
const [grouped, setGrouped] = useState(true);
@@ -206,8 +257,24 @@ export function ChannelsTable({
[channels],
);
const showSections = grouped && !!sections && sections.length > 0 && !!siteId;
- // Eight fixed columns, one per pipeline, then Actions.
- const colSpan = 9 + columns.length;
+ // The select column, eight fixed columns, one per pipeline, then Actions.
+ const colSpan = 10 + columns.length;
+ // Always the intersection with what is on screen: a slug can leave the table
+ // between renders (a scope change, a deletion), and a bulk edit must not act
+ // on a row nobody can see.
+ const selectedSlugs = channels
+ .map((c) => c.slug)
+ .filter((s) => selected.has(s));
+ const allSelected =
+ channels.length > 0 && selectedSlugs.length === channels.length;
+
+ function toggleOne(slug: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (!next.delete(slug)) next.add(slug);
+ return next;
+ });
+ }
function onHeaderClick(key: SortKey) {
setSort((prev) => {
@@ -220,6 +287,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
@@ -234,6 +304,21 @@ export function ChannelsTable({
<table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full">
<thead className="bg-muted">
<tr>
+ <th className="px-2 py-2">
+ <input
+ type="checkbox"
+ aria-label="select all channels"
+ checked={allSelected}
+ onChange={(e) =>
+ setSelected(
+ e.target.checked
+ ? new Set(channels.map((c) => c.slug))
+ : new Set(),
+ )
+ }
+ className="accent-primary"
+ />
+ </th>
<SortableTh
label="Slug"
sortKey="slug"
@@ -259,10 +344,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"
@@ -319,18 +405,34 @@ export function ChannelsTable({
section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []),
sort,
).map((c) => (
- <ChannelTableRow key={c.slug} channel={c} columns={columns} />
+ <ChannelTableRow
+ key={c.slug}
+ channel={c}
+ columns={columns}
+ selected={selected.has(c.slug)}
+ onToggle={toggleOne}
+ />
))}
</tbody>
))
) : (
<tbody>
{rows.map((c) => (
- <ChannelTableRow key={c.slug} channel={c} columns={columns} />
+ <ChannelTableRow
+ key={c.slug}
+ channel={c}
+ columns={columns}
+ selected={selected.has(c.slug)}
+ onToggle={toggleOne}
+ />
))}
</tbody>
)}
</table>
+ <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-")) && (
@@ -397,19 +499,36 @@ function PipelineCell({
function ChannelTableRow({
channel: c,
columns,
+ selected,
+ onToggle,
}: {
channel: ChannelRow;
columns: PipelineColumn[];
+ selected: boolean;
+ onToggle: (slug: string) => void;
}) {
return (
<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"
: "")
}
>
+ <td className="px-2 py-2">
+ <input
+ type="checkbox"
+ aria-label={`select ${c.slug}`}
+ checked={selected}
+ onChange={() => onToggle(c.slug)}
+ className="accent-primary"
+ />
+ </td>
<Td className="font-mono">
<Link
href={`/channels/${c.slug}`}
@@ -427,9 +546,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/lib/channelGroupSections.test.ts b/editor/app/channels/lib/channelGroupSections.test.ts
@@ -276,6 +276,32 @@ test("sync skips a channel with no url or excluded from sync", () => {
assert.deepEqual(sections[0].download.eligible.sort(), ["excluded", "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: "pinned" }, { slug: "off" }],
+ }),
+ [channel("ok"), channel("pinned"), channel("off")],
+ settings,
+ );
+ assert.deepEqual(sections[0].sync.eligible, ["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", () => {
const withWork = channel(
"c",
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,6 +128,22 @@ 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" };
+ // THE PAUSED SECTION. The tier document is asked for the `sync` OPERATION
+ // — `isChannelPaused(model, slug, "sync")` — which is precisely what the
+ // retired `excludeFromSync` flag meant, read the other way round.
+ //
+ // The flag is still read BESIDE it, not instead of it: until S5's migration
+ // rewrites the 15 channels that carry it, dropping the legacy read here
+ // would silently re-include every one of them in a group's "Sync" sweep.
+ // S5 deletes the second branch with the field.
+ // `?? 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" };
+ }
if (config.excludeFromSync) {
return { eligible: false, work: 0, reason: "excluded from sync" };
}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -9,15 +9,26 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths";
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 {
@@ -113,12 +124,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
@@ -131,8 +161,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
@@ -148,6 +197,24 @@ export default async function ChannelsPage({
generatedAt: brief?.snapshot?.generatedAt ?? null,
state: brief ? reportStateOf(brief) : ("missing" as const),
},
+ 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.
@@ -160,7 +227,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(
@@ -194,6 +261,11 @@ export default async function ChannelsPage({
columns={columns}
sections={sections}
siteId={activeSite?.siteId}
+ 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/e2e/channel-priority.spec.ts b/editor/e2e/channel-priority.spec.ts
@@ -0,0 +1,280 @@
+import { test, expect } from "@playwright/test";
+import { readJson, resetData, 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"]);
+});
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",