Archilyzer · Source

archilyzer

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

commit 6431abfc9563965f6b322e740a62f23eb34200fd
parent 6b4b523e7d43d35e8b2376483e225780689a7ad5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 11:37:35 -0400

editor: /channels loses the sync toggle and gains the tier it was standing in for

The cell that held `ChannelSyncToggle` is now `ChannelTierSelect`. The toggle
flipped one boolean on one channel's config.json; the select writes the
corpus-wide priority document through the one writer, which recompiles the four
lane trees in the same save. The column header follows it: "Sync" becomes
"Tier", and its sort key is the COMPILED order — tier position (a focused
channel sorts as focus, which is a position and never a stored value), then
rank with unranked last, then slug: the same three keys `compileLaneRoot`
orders a group by.

The base tier is the row and the pins are behind a disclosure. A channel's tier
answers every operation unless one is pinned, and pins are the rare case — they
exist because "stop syncing, keep everything else", the retired
`excludeFromSync`, has to remain sayable. Five selects per row would bury the
one number the page is for, so the row shows the base tier and counts its pins,
and Advanced is where the five operations and the "Sync only" preset live.

The control is 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 a select disagreeing
with settings.json — which a `useState` seeded from props would.

Selection follows `SyncConsole`/`BulkCadenceBar` verbatim rather than inventing
a second idiom: a Set of SLUGS (not indices, so a re-render that reorders rows
cannot retarget it), intersected with what is on screen. The focus bar is
separate and always rendered: a focus is ONE corpus-wide fact, so picking a
site to focus and ending a focus are not properties of a row selection and must
not require one.

`focused` and `heldReason` are resolved ONCE on the page and handed down. A row
cannot answer either on its own — the focus is one selector and resolving it
means reading every site's membership. A paused row gets no held reason: it is
off, which its own tier already says.

`stationWorkFor`'s sync station asks the document for the `sync` OPERATION,
beside the legacy flag rather than instead of it: until S5's migration rewrites
the 15 channels carrying `excludeFromSync`, dropping that read would silently
re-include every one of them in a group's Sync sweep.

`ChannelSyncToggle.tsx` and its action are left in place — S5 deletes them with
the field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Aeditor/app/channels/components/ChannelBulkBar.tsx | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelTierSelect.tsx | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
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+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
6 files changed, 637 insertions(+), 42 deletions(-)

diff --git a/editor/app/channels/components/ChannelBulkBar.tsx b/editor/app/channels/components/ChannelBulkBar.tsx @@ -0,0 +1,175 @@ +"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, +} from "../actions"; + +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, startTransition] = useTransition(); + 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={() => + startTransition(async () => { + await 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={() => + startTransition(async () => { + await endFocusAction(); + }) + } + className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" + > + End focus + </button> + )} + </div> + ); +} + +export function ChannelBulkBar({ + slugs, + onClear, +}: { + slugs: string[]; + onClear: () => void; +}) { + const [pending, startTransition] = useTransition(); + 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={() => + startTransition(async () => { + await 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={() => + startTransition(async () => { + await 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> + </div> + ); +} diff --git a/editor/app/channels/components/ChannelTierSelect.tsx b/editor/app/channels/components/ChannelTierSelect.tsx @@ -1,23 +1,42 @@ -// 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, useTransition } from "react"; +import { + PRIORITY_OPERATIONS, + STORED_CHANNEL_TIERS, + type PriorityOperation, + type StoredChannelTier, } from "yt-dlp-transcript-common/lib/channelPriority"; +import { + applyChannelPriorityPresetAction, + setChannelOperationTierAction, + setChannelTierAction, +} from "../actions"; export type ChannelTierSelectProps = { slug: string; @@ -34,8 +53,166 @@ 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 }); + const pins = PRIORITY_OPERATIONS.filter((op) => view.overrides[op]); + const busy = disabled || pending; + + function run(next: View, action: () => Promise<unknown>) { + startTransition(async () => { + setView(next); + await action(); + }); + } + + 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> + {heldReason && ( + <span + 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"; // 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. +// 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; +}; + 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"