Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Meditor/app/channels/actions.ts | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Aeditor/app/channels/components/ChannelBulkBar.tsx | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelTierSelect.tsx | 241++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Meditor/app/channels/components/ChannelsTable.tsx | 154++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Meditor/app/channels/lib/channelGroupSections.test.ts | 26++++++++++++++++++++++++++
Meditor/app/channels/lib/channelGroupSections.ts | 27+++++++++++++++++++++++++--
Meditor/app/channels/page.tsx | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Aeditor/e2e/channel-priority.spec.ts | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/e2e/channel-sync-toggle.spec.ts | 58----------------------------------------------------------
Meditor/e2e/channels-sort.spec.ts | 4+++-
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",