Archilyzer · Source

archilyzer

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

commit ab18d2defbdc0aa39e18dd0fe510ee0e9c922305
parent 848ce0f545872bee66d30bf0065fe75288a60fbe
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 24 Aug 2026 22:59:19 -0400

channels: section the table by site group, and give each group the pipeline

Every site already sorts its channels into authored groups, and the
editor threw the grouping away and rendered one flat table. /channels
is now one table sectioned by group — columns locked across sections
so counts compare — with each group's name, description, and an
off-by-default note. Sorting sorts within a group; "Group by section"
flattens again. Under all sites it stays flat: groups partition one
site, there is no grouping across the pool.

Each group header carries that group's slice of the pipeline:
Sync ── Download N ── Transcribe N ── Digest N ── Backfill N, where the
figure IS the button. Figures are never summed and each is read through
the same snapshot reader the channel page uses. A finished station is
quiet text; one with no eligible channel is disabled and says why; a
figure including an unreported channel carries a trailing +; a
switched-off lane reads "off", never 0. GPU-scale stations confirm
first. The fan-out queues the existing per-channel job once per
channel, skipping channels with nothing to do.

The pool-wide button is now "Sync every channel" — it always swept the
whole pool regardless of scope. A group Sync does not stamp
lastSyncAllAt: a group is not the pool.

e2e: channel-groups.spec.ts (6 specs), channel-sync-toggle updated for
the rename.

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

Diffstat:
Meditor/CHANGELOG.md | 3+++
Aeditor/app/channels/components/ChannelGroupHeaderRow.tsx | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/components/ChannelGroupLine.tsx | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelsTable.tsx | 222+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Meditor/app/channels/components/SyncAllChannelsButton.tsx | 11++++++-----
Meditor/app/channels/page.tsx | 31++++++++++++++++++++++---------
Aeditor/e2e/channel-groups.spec.ts | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/channel-sync-toggle.spec.ts | 4++--
8 files changed, 707 insertions(+), 93 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +- **`/channels` is sectioned by the groups a site files its channels into, and each section can run the pipeline over its own shelf.** Every site already sorts its channels into authored groups — on Jeralyzer they are *Archives* ("Channels that archive Jeremy's content, out of his reach"), *Guest Appearances* and *Extended Universe* — and the editor showed none of it: it scoped the rows to the active site, threw the grouping away, and rendered one flat table. Those descriptions were written on the Sites page, stored, and rendered **nowhere**. The table is now one table sectioned by group — columns stay locked across every section, because comparing transcript counts between groups is the point — with each group's name, its description, and an *off by default* note when visitors don't get that group preselected. Sorting a column sorts within each group; a **Group by section** tick flattens it again without leaving the site. Under *all sites* the table stays flat, since groups only partition one site's channels and there is no single grouping across the pool. +- **Each group header carries that group's slice of the pipeline, and every stage is labelled with the work it would actually do.** `Sync ── Download 143 ── Transcribe 27 ── Digest 1,204 ── Backfill 88`: the channel page's transit line, collapsed to the five runnable stages, where each station's figure IS the button — you cannot press a stage without the number it will act on being the label you pressed. "Re-sync the archives" and "get the guest appearances transcribed" used to mean clicking through channels one at a time. The five figures are never added together, and each is read through the same snapshot reader the channel page uses, so a group total cannot disagree with the channel it came from. A station whose work is provably finished stops being a button and becomes quiet text (*Downloaded*, *Transcribed*); a station with no eligible channel is disabled and says why — a `youtube`-handling channel never runs whisper, so counting it would inflate a figure on a button that would skip it. A figure that includes a channel which has never reported is marked with a trailing `+`, because it is a floor and not a total, and where **no** channel has reported it reads `—` rather than `0`. A switched-off lane reads **off**, never `0`: a snapshot's counts outlive the feature being switched off, and an empty work list because something is disabled is not the same as being finished. Download, Transcribe, Digest and Backfill confirm first — at group scale these are GPU-days. The fan-out queues the existing per-channel job once per channel, so they serialize on the lane they already shared: no new concurrency, and a channel with nothing to do is skipped rather than given a no-op job. +- **The pool-wide button now says what it does: Sync every channel.** It was labelled *Sync all* / *Full sweep all* on a page that has had a site scope for some time, so it read as "all of these" when it has always swept the entire channel pool regardless of what is on screen. Its behaviour is unchanged; a group's own **Sync** is the one that respects the visible scope. A group sweep also deliberately does *not* stamp the "last full sync" marker the monitor widget reads — a group is not the pool, and it must not claim to be. - **The sweep console now says where each operation will run.** Each row of the scope control gains a read-only line naming the delegate machines that can take that operation — LLM endpoints for the call-bound kinds, tagged executors for units, with their slot counts — and the digest lane, which has no operation list, gets the same line on its panel. It is derived through the same matching rule the scheduler grants by, so the display and the routing cannot disagree, and it is deliberately not part of the scope: delegation is decided per item at dispatch, not persisted at arm time. The one loud state: remote capacity is configured and none of it can currently take work (everything degraded or disabled) — which from the arm button looks identical to "distributed" and would otherwise be discovered from a week of single-machine throughput. - **The backfill lane now dispatches units to those executors, and the lane's ceiling rises to match.** For every candidate the runner first tries a free slot on a *tagged* remote worker whose tags cover the operation (tagging is the opt-in: an untagged remote from before this protocol keeps doing transcription only, rather than being shipped envelopes an older build answers with errors). A shipped unit comes back as records, applied through the same guarded writers a local run uses — so a unit's text-only speaker record still loses to a diarized one that landed while it was in flight. A network failure is charged to the *worker*, never the work: the item retries on another executor or falls back to running locally, and a machine that stops answering is health-checked and benched, exactly as remote transcription already does. The lane's concurrency becomes local + remote slots — with the remote share counted at the *minimum* across the run's operations, so on a mixed run a slot justified by one operation's remote capacity can never push a different operation onto this box while it is meant to be standing aside. Digest deliberately does not ship as units: its distribution is the LLM-endpoint fan-out, which reaches the same ollama with less machinery. - **A machine with no corpus can now run whole backfill units for this one.** The remote-transcription protocol grew a general sibling: `/api/worker/unit` accepts one unit of any *backfill kind* — speaker attribution, diarization — as a small envelope of input files, runs it against a throwaway scratch corpus, and hands the produced sidecar back. Each kind now declares its own contract: what a unit needs (attribution ships the cue sidecar, metadata and raw transcript — the freshness gate compares their mtimes, so the executor writes the cues file *last* or the unit would silently do nothing), what it produces, and how the result lands back on the primary — always through the guarded writers, never a raw copy, so a unit's text-only record still cannot overwrite a diarized one and applying one digest section still preserves the other. Two refusals are load-bearing: the endpoint takes only backfill kinds (downloads and transcription are refused at the door, so download politeness stays one machine's promise), and a unit that reports "disabled" or "not configured" on the executor is an *error* — on a bare box that answer means the primary's injected model/prompt identity was dropped and the executor's default settings leaked in, which is exactly how a second machine writes permanently-stale records. Deploying an executor needs no new software: this app, `ARCHILYZER_IDLE_BOOT=1`, `WORKER_TOKEN`, and an empty transcripts dir — see RUNNING_IN_DOCKER.md. diff --git a/editor/app/channels/components/ChannelGroupHeaderRow.tsx b/editor/app/channels/components/ChannelGroupHeaderRow.tsx @@ -0,0 +1,69 @@ +"use client"; + +import type { ChannelGroupSection } from "../lib/channelGroupSections"; +import { ChannelGroupLine } from "./ChannelGroupLine"; + +// A group's section header: one full-colspan row above its channels' rows, so +// the columns stay locked across every group (comparing transcript counts +// across groups is the point, which is what rules out cards). +// +// scope="rowgroup", NOT colgroup. `rowgroup` maps to role `rowheader`; +// `colgroup` maps to `columnheader` and would drop group names into the same +// role bucket channels-sort.spec.ts iterates over the real column headers. +// +// The accessible name is set explicitly to the group's name so +// getByRole("rowheader", { name }) addresses the group and not the whole line of +// station buttons and counts nested inside it. +export function ChannelGroupHeaderRow({ + section, + siteId, + colSpan, +}: { + section: ChannelGroupSection; + siteId: string; + // The table's full width, owned by ChannelsTable: it is the one that knows + // how many pipeline columns the settings switched on. + colSpan: number; +}) { + const { group, channels } = section; + const name = group.name || group.id; + return ( + <tr className="border-t-2 border-border bg-muted/40"> + <th + colSpan={colSpan} + scope="rowgroup" + id={`group-${group.id}`} + aria-label={name} + data-group={group.id} + className="px-3 py-2 text-left font-normal align-top" + > + <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> + <span + data-testid="group-name" + className="text-xs font-semibold uppercase tracking-wider" + > + {name} + </span> + {/* Rendered because it is true and currently invisible: a visitor to + the public site does not get this group preselected. */} + {!group.selectedByDefault && ( + <span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1"> + off by default + </span> + )} + {/* The authored description. Written by SiteForm, parsed by + parseChannelGroup, and until now rendered nowhere in the editor. */} + {group.description && ( + <span className="text-xs text-muted-foreground"> + {group.description} + </span> + )} + <span className="ml-auto text-xs text-muted-foreground whitespace-nowrap"> + {channels.length === 1 ? "1 channel" : `${channels.length} channels`} + </span> + </div> + <ChannelGroupLine section={section} siteId={siteId} /> + </th> + </tr> + ); +} diff --git a/editor/app/channels/components/ChannelGroupLine.tsx b/editor/app/channels/components/ChannelGroupLine.tsx @@ -0,0 +1,254 @@ +"use client"; + +import Link from "next/link"; +import { Fragment, useEffect, useState } from "react"; +import type { + ChannelGroupSection, + StationId, + StationWork, +} from "../lib/channelGroupSections"; +import { + backfillChannelGroupAction, + digestChannelGroupAction, + downloadChannelGroupAction, + syncChannelGroupAction, + transcribeChannelGroupAction, + type GroupOpResult, +} from "../groupActions"; + +// The channel page's transit line, collapsed to the five runnable stages and +// scoped to one group. +// +// THE SIGNATURE: you cannot press a stage without the number it will act on +// being the label you pressed. Hairline connectors keep the five reading as one +// pipeline rather than a toolbar. + +type Station = { + id: StationId; + label: string; + // What replaces the button once the group's work is provably finished. A dead + // button reading "Download 0" invites a click that would do nothing. + done: string; + // Why this station cannot run for this group at all. + notEligible: string; + // GPU-weeks at group scale — every stage but Sync confirms first. + confirm?: (group: string, count: string) => string; + run: (siteId: string, groupId: string) => Promise<GroupOpResult>; +}; + +const STATIONS: Station[] = [ + { + id: "sync", + label: "Sync", + done: "Synced", + notEligible: "No channel in this group has a URL to sync.", + run: syncChannelGroupAction, + }, + { + id: "download", + label: "Download", + done: "Downloaded", + notEligible: "No channel in this group can download (no URL, or posts only).", + confirm: (group, count) => + `Download ${count} not-yet-downloaded video(s) across every channel in "${group}"? They queue one channel at a time per platform.`, + run: downloadChannelGroupAction, + }, + { + id: "transcribe", + label: "Transcribe", + done: "Transcribed", + notEligible: + "No channel in this group is set to transcribe — a youtube-handling channel never runs whisper.", + confirm: (group, count) => + `Transcribe ${count} downloaded video(s) across every channel in "${group}"? They run strictly one at a time on the transcription queue, and whisper is slow.`, + run: transcribeChannelGroupAction, + }, + { + id: "digest", + label: "Digest", + done: "Digested", + notEligible: "No channel in this group has a transcript to digest yet.", + confirm: (group, count) => + `Digest ${count} transcript(s) across every channel in "${group}" on the local lane? On a large group this is hours to days of GPU time.`, + run: digestChannelGroupAction, + }, + { + id: "backfill", + label: "Backfill", + done: "Backfill current", + notEligible: "No channel in this group has media to backfill.", + confirm: (group, count) => + `Backfill ${count} video(s) across every channel in "${group}" with every enabled lane kind? On a large group this is hours to days of work.`, + run: backfillChannelGroupAction, + }, +]; + +type Status = + | { kind: "idle" } + | { kind: "running"; station: StationId } + | { kind: "done"; station: StationId; result: GroupOpResult } + | { kind: "error"; station: StationId; message: string }; + +// The figure printed on the control. Null means the station carries no figure +// (Sync — syncAction decides per channel whether it is due, so there is nothing +// to promise). +function figureOf(work: StationWork, station: StationId): string | null { + if (station === "sync") return null; + // A floor, not a total: some channels have never reported. Never 0 when the + // page cannot say — the house rule. + if (work.total === 0 && work.unknown.length > 0) return "—"; + if (work.unknown.length > 0) return `${work.total.toLocaleString()}+`; + return work.total.toLocaleString(); +} + +function unknownTitle(work: StationWork): string | undefined { + if (work.unknown.length === 0) return undefined; + return ( + `No report yet for ${work.unknown.join(", ")}, so this is a floor — ` + + `run Refresh report on those channels for a firm number.` + ); +} + +export function ChannelGroupLine({ + section, + siteId, +}: { + section: ChannelGroupSection; + siteId: string; +}) { + const [status, setStatus] = useState<Status>({ kind: "idle" }); + // Disabled until mounted, deliberately. A server-rendered button has no + // handler until React hydrates, so a click before then fires NOTHING — no + // request, no job, no error, nothing to debug. That is the exact failure that + // made the digest pilot's job look "un-created". + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const group = section.group; + const name = group.name || group.id; + const busy = status.kind === "running"; + + async function press(station: Station, work: StationWork) { + const confirmText = station.confirm?.( + name, + figureOf(work, station.id) ?? String(work.eligible.length), + ); + if (confirmText && !window.confirm(confirmText)) return; + setStatus({ kind: "running", station: station.id }); + try { + setStatus({ + kind: "done", + station: station.id, + result: await station.run(siteId, group.id), + }); + } catch (e) { + setStatus({ + kind: "error", + station: station.id, + message: (e as Error).message, + }); + } + } + + return ( + <div + role="group" + aria-label={`group work for ${name}`} + className="mt-2 flex flex-wrap items-center" + > + {STATIONS.map((station, i) => { + const work = section[station.id]; + const figure = figureOf(work, station.id); + const running = + status.kind === "running" && status.station === station.id; + return ( + <Fragment key={station.id}> + {i > 0 && ( + /* Hairline connector: five buttons read as a toolbar, five + stations on a line read as the pipeline they are. */ + <span aria-hidden="true" className="w-3 h-px bg-border" /> + )} + {work.laneOff ? ( + <button + type="button" + disabled + aria-label={`${station.id} group ${name}`} + title={`The ${station.label.toLowerCase()} lane is switched off in Settings. Its channels may still carry recorded work — an off lane is not a finished one.`} + className={CHIP + " opacity-50 cursor-not-allowed"} + > + {station.label} off + </button> + ) : work.eligible.length === 0 ? ( + <button + type="button" + disabled + aria-label={`${station.id} group ${name}`} + title={station.notEligible} + className={CHIP + " opacity-50 cursor-not-allowed"} + > + {station.label} + </button> + ) : figure !== null && work.total === 0 && work.unknown.length === 0 ? ( + /* Provably nothing to do. Static text, not a dead button. */ + <span + aria-label={`${station.id} group ${name}`} + className="px-2 py-1 text-xs text-muted-foreground" + > + {station.done} + </span> + ) : ( + <button + type="button" + onClick={() => press(station, work)} + disabled={busy || !mounted} + aria-label={`${station.id} group ${name}`} + title={unknownTitle(work)} + className={CHIP + " hover:bg-muted disabled:opacity-50"} + > + {running ? "Queuing…" : station.label} + {figure !== null && ( + <> + {" "} + <span className="font-medium tabular-nums">{figure}</span> + </> + )} + </button> + )} + </Fragment> + ); + })} + {status.kind === "done" && ( + <span + aria-label={`${status.station} group ${name} result`} + className="ml-3 text-xs text-muted-foreground" + title={ + status.result.skipped.length === 0 + ? undefined + : status.result.skipped + .map((s) => `${s.slug}: ${s.reason}`) + .join("\n") + } + > + Queued {status.result.queued.length} · skipped{" "} + {status.result.skipped.length} ·{" "} + <Link href="/jobs" className="underline hover:text-foreground"> + view jobs + </Link> + </span> + )} + {status.kind === "error" && ( + <span + role="alert" + aria-label={`${status.station} group ${name} error`} + className="ml-3 text-xs text-destructive" + > + {status.message} + </span> + )} + </div> + ); +} + +const CHIP = + "px-2 py-1 rounded border border-border text-xs whitespace-nowrap " + + "bg-background"; diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -11,6 +11,8 @@ import { BandLegend, StateBand, } from "../../components/pipelines/StateBand"; +import type { ChannelGroupSection } from "../lib/channelGroupSections"; +import { ChannelGroupHeaderRow } from "./ChannelGroupHeaderRow"; import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; import { ChannelBuildToggle } from "./ChannelBuildToggle"; import { ChannelSyncButton } from "./ChannelSyncButton"; @@ -146,9 +148,22 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { } } +// Pure, and module-level so the grouped render can apply the SAME sort state +// independently to each section — which is what "sorts within each group" means. +function applySort( + list: ReadonlyArray<ChannelRow>, + sort: SortState, +): ReadonlyArray<ChannelRow> { + if (!sort) return list; + const sorted = [...list].sort((a, b) => cmp(a, b, sort.key)); + return sort.dir === "desc" ? sorted.reverse() : sorted; +} + export function ChannelsTable({ channels, columns, + sections = null, + siteId, }: { channels: ChannelRow[]; // Which pipelines to draw, in group order, resolved on the server from the @@ -156,14 +171,29 @@ export function ChannelsTable({ // honest rendering: an empty column because nobody enabled attribution is not // the same as an attribution lane with nothing left to do. columns: PipelineColumn[]; + // Null under "all sites" (or with no sites configured): groups only partition + // a SINGLE site's channels, so there is no one grouping to render across the + // pool. That path is today's flat table, unchanged. + sections?: ChannelGroupSection[] | null; + siteId?: string; }) { const [sort, setSort] = useState<SortState>(null); + // Plain component state, deliberately NOT the URL: router.replace races the + // global AutoRefresh's router.refresh() and gets dropped. + const [grouped, setGrouped] = useState(true); - const rows = useMemo(() => { - if (!sort) return channels; - const sorted = [...channels].sort((a, b) => cmp(a, b, sort.key)); - return sort.dir === "desc" ? sorted.reverse() : sorted; - }, [channels, sort]); + const rows = useMemo(() => applySort(channels, sort), [channels, sort]); + // A section lists ChannelStats; the bands live on the ChannelRow the page + // built for the same slug. Joined by slug, never by index — pairing one + // channel's counts with another channel's bands is exactly the silent + // wrongness the row type exists to remove. + const rowBySlug = useMemo( + () => new Map(channels.map((c) => [c.slug, c])), + [channels], + ); + const showSections = grouped && !!sections && sections.length > 0 && !!siteId; + // Seven fixed columns, one per pipeline, then Actions. + const colSpan = 8 + columns.length; function onHeaderClick(key: SortKey) { setSort((prev) => { @@ -176,6 +206,17 @@ export function ChannelsTable({ return ( <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> + {sections && sections.length > 0 && ( + <label className="flex items-center gap-2 px-4 md:px-0 pb-2 text-xs text-muted-foreground"> + <input + type="checkbox" + checked={grouped} + onChange={(e) => setGrouped(e.target.checked)} + className="accent-primary" + /> + Group by section + </label> + )} <table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full"> <thead className="bg-muted"> <tr> @@ -239,80 +280,34 @@ export function ChannelsTable({ </th> </tr> </thead> - <tbody> - {rows.map((c) => ( - <tr - key={c.slug} - className={ - "border-t border-border " + - (c.config.excludeFromBuild || c.config.excludeFromSync - ? "opacity-60" - : "") - } + {showSections ? ( + // One sort state, applied independently per section — which IS + // "sorts within each group". + sections.map((section) => ( + <tbody + key={section.group.id} + aria-labelledby={`group-${section.group.id}`} > - <Td className="font-mono"> - <Link - href={`/channels/${c.slug}`} - className="underline hover:text-foreground" - > - {c.slug} - </Link> - </Td> - <Td>{c.config.name ?? ""}</Td> - <Td>{c.config.handling}</Td> - <Td> - <ChannelBuildToggle - slug={c.slug} - excluded={c.config.excludeFromBuild === true} - /> - </Td> - <Td> - <ChannelSyncToggle - slug={c.slug} - excluded={c.config.excludeFromSync === true} - /> - </Td> - <Td - className="text-right" - ariaLabel={`playlist count for ${c.slug}`} - > - {c.playlistCount ?? "—"} - </Td> - <Td className="text-xs text-muted-foreground whitespace-nowrap"> - {c.config.lastSyncedAt - ? new Date(c.config.lastSyncedAt).toLocaleString() - : ""} - </Td> - {columns.map((col) => ( - <PipelineCell - key={col.id} - slug={c.slug} - column={col} - band={bandOf(c, col.id)} - count={ - col.id === "download" - ? c.downloadCount - : col.id === "transcription" - ? c.transcriptCount - : null - } - /> + <ChannelGroupHeaderRow + section={section} + siteId={siteId as string} + colSpan={colSpan} + /> + {applySort( + section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []), + sort, + ).map((c) => ( + <ChannelTableRow key={c.slug} channel={c} columns={columns} /> ))} - <Td className="whitespace-nowrap"> - <div className="flex items-center gap-2"> - <ChannelSyncButton - slug={c.slug} - disabled={!c.config.url} - /> - <ChannelAvailabilityButton - slug={c.slug} - disabled={!c.config.url} - /> - </div> - </Td> - </tr> - ))} - </tbody> + </tbody> + )) + ) : ( + <tbody> + {rows.map((c) => ( + <ChannelTableRow key={c.slug} channel={c} columns={columns} /> + ))} + </tbody> + )} </table> <div className="flex flex-col gap-1 px-3 py-2 md:px-0"> <BandLegend /> @@ -375,6 +370,79 @@ function PipelineCell({ ); } +// One channel's row, shared by the flat and the sectioned render so the two +// can never drift apart column-for-column. +function ChannelTableRow({ + channel: c, + columns, +}: { + channel: ChannelRow; + columns: PipelineColumn[]; +}) { + return ( + <tr + className={ + "border-t border-border " + + (c.config.excludeFromBuild || c.config.excludeFromSync + ? "opacity-60" + : "") + } + > + <Td className="font-mono"> + <Link + href={`/channels/${c.slug}`} + className="underline hover:text-foreground" + > + {c.slug} + </Link> + </Td> + <Td>{c.config.name ?? ""}</Td> + <Td>{c.config.handling}</Td> + <Td> + <ChannelBuildToggle + slug={c.slug} + excluded={c.config.excludeFromBuild === true} + /> + </Td> + <Td> + <ChannelSyncToggle + slug={c.slug} + excluded={c.config.excludeFromSync === true} + /> + </Td> + <Td className="text-right" ariaLabel={`playlist count for ${c.slug}`}> + {c.playlistCount ?? "—"} + </Td> + <Td className="text-xs text-muted-foreground whitespace-nowrap"> + {c.config.lastSyncedAt + ? new Date(c.config.lastSyncedAt).toLocaleString() + : ""} + </Td> + {columns.map((col) => ( + <PipelineCell + key={col.id} + slug={c.slug} + column={col} + band={bandOf(c, col.id)} + count={ + col.id === "download" + ? c.downloadCount + : col.id === "transcription" + ? c.transcriptCount + : null + } + /> + ))} + <Td className="whitespace-nowrap"> + <div className="flex items-center gap-2"> + <ChannelSyncButton slug={c.slug} disabled={!c.config.url} /> + <ChannelAvailabilityButton slug={c.slug} disabled={!c.config.url} /> + </div> + </Td> + </tr> + ); +} + function SortableTh({ label, sortKey, diff --git a/editor/app/channels/components/SyncAllChannelsButton.tsx b/editor/app/channels/components/SyncAllChannelsButton.tsx @@ -34,10 +34,11 @@ export function SyncAllChannelsButton() { type="button" onClick={() => handleClick(false)} disabled={running} - aria-label="sync all channels" + aria-label="sync every channel" + title="Sweeps the WHOLE channel pool, not just the channels shown here. Use a group's Sync for the visible scope." className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" > - {running && !sweeping ? "Syncing…" : "Sync all"} + {running && !sweeping ? "Syncing…" : "Sync every channel"} </button> {/* Forces the deep pass on every channel instead of waiting out each one's cadence — the button to reach for right after upgrading, when @@ -46,11 +47,11 @@ export function SyncAllChannelsButton() { type="button" onClick={() => handleClick(true)} disabled={running} - title="Re-read every channel's whole listing: refresh its video list and flag videos that have gone missing upstream." - aria-label="full sweep all channels" + title="Re-read every channel's whole listing — the WHOLE pool, not just the channels shown here: refresh each video list and flag videos that have gone missing upstream." + aria-label="full sweep every channel" className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50" > - {sweeping ? "Sweeping…" : "Full sweep all"} + {sweeping ? "Sweeping…" : "Full sweep every channel"} </button> {status.kind === "done" && ( <span diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -25,6 +25,7 @@ import { type ChannelRow, type PipelineColumn, } from "./components/ChannelsTable"; +import { buildChannelGroupSections } from "./lib/channelGroupSections"; import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton"; import { resolveActiveSite } from "../lib/activeSite"; @@ -135,13 +136,17 @@ export default async function ChannelsPage({ pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids), })); // Scope to the active site's membership; "all sites" shows the full pool. - const channels = - active.isAll || !active.siteId - ? all - : (() => { - const slugs = siteChannelSlugs(getSite(active.siteId, paths)); - return all.filter((c) => slugs.has(c.slug)); - })(); + // + // Groups PARTITION one site's channels, so they can only be rendered when a + // single site is the active scope — there is no one grouping across the pool. + const activeSite = + active.isAll || !active.siteId ? null : getSite(active.siteId, paths); + const channels = activeSite + ? all.filter((c) => siteChannelSlugs(activeSite).has(c.slug)) + : all; + const sections = activeSite + ? buildChannelGroupSections(activeSite, channels, briefs, getSettings()) + : null; const shown = new Set(channels.map((c) => c.slug)); const freshness = summariseFreshness( briefs.filter((b) => shown.has(b.slug)), @@ -168,7 +173,12 @@ export default async function ChannelsPage({ </p> ) : ( <> - <ChannelsTable channels={channels} columns={columns} /> + <ChannelsTable + channels={channels} + columns={columns} + sections={sections} + siteId={activeSite?.siteId} + /> <p className="text-xs text-muted-foreground" data-testid="channels-freshness" @@ -195,7 +205,10 @@ export default async function ChannelsPage({ <> {" "} No report yet for {freshness.missing.join(", ")} — those rows - read zero. + read zero + {sections + ? ", and a group figure that counts one of them is marked with a trailing + to say it is a floor rather than a total." + : "."} </> )} </p> diff --git a/editor/e2e/channel-groups.spec.ts b/editor/e2e/channel-groups.spec.ts @@ -0,0 +1,206 @@ +import { test, expect, type Page } from "@playwright/test"; +import { + channelStage, + generateReport, + jobRowByKind, + resetData, + writeChannelConfig, + writeSite, +} from "./helpers"; + +// /channels renders as one table SECTIONED by the active site's channel groups, +// each section header carrying that group's slice of the pipeline. Groups +// partition a single site's channels, so the sections only exist when a single +// site is the active scope — under "all sites" the table stays flat. + +// A group's <tbody>, addressed by the header it is labelled by. Row indices are +// only ever taken WITHIN one of these — the page-wide row list spans every group +// and its headers. +const rowgroup = (page: Page, groupId: string) => + page.locator(`tbody[aria-labelledby="group-${groupId}"]`); + +// News sorts FIRST (order 1) even though it is second in the file — sortGroups +// orders by explicit order, then name. +const GROUPS = [ + { + id: "default", + name: "All channels", + selectedByDefault: true, + order: 2, + }, + { + id: "news", + name: "News", + description: "Shows where Jeremy is the guest", + selectedByDefault: false, + order: 1, + }, +]; + +// Two groups over the shared two-channel pool, plus a third channel so one +// group holds two rows (which is what makes a within-group sort observable). +async function seed() { + await resetData("two-slow-channels"); + await writeChannelConfig("slow-c", { name: "Slow C" }); + await writeSite("alpha", { + siteTitle: "Alpha", + groups: GROUPS, + channels: [ + { slug: "slow-a" }, + { slug: "slow-c" }, + { slug: "slow-b", groupId: "news" }, + ], + }); +} + +test("a single site's channels render sectioned by group, with each group's authored description", async ({ + page, +}) => { + await seed(); + await page.goto("/channels?site=alpha"); + + // Section order is sortGroups order, not file order. + await expect(page.getByTestId("group-name")).toHaveText([ + "News", + "All channels", + ]); + // One rowheader per group, named by the group. scope="rowgroup" keeps these + // out of the columnheader bucket channels-sort.spec.ts iterates. + await expect(page.getByRole("rowheader")).toHaveCount(2); + await expect(page.getByRole("rowheader", { name: "News" })).toBeVisible(); + + // The authored description — the first time this field is rendered anywhere in + // the editor — and the not-preselected note beside it. + await expect( + page.getByText("Shows where Jeremy is the guest"), + ).toBeVisible(); + await expect(page.getByText("off by default")).toBeVisible(); + + // Membership: slow-b is in News, the other two in the default group. + await expect( + rowgroup(page, "news").getByRole("link", { name: "slow-b" }), + ).toBeVisible(); + await expect(rowgroup(page, "news").getByRole("row")).toHaveCount(2); // header + 1 + await expect( + rowgroup(page, "default").getByRole("link", { name: "slow-a" }), + ).toBeVisible(); + await expect( + rowgroup(page, "default").getByRole("link", { name: "slow-c" }), + ).toBeVisible(); + + // Unticking "Group by section" flattens without leaving the site. + await page.getByLabel("Group by section").uncheck(); + await expect(page.getByRole("rowheader")).toHaveCount(0); + await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible(); +}); + +test("under all sites the table stays flat — there is no one grouping across the pool", async ({ + page, +}) => { + await seed(); + await page.goto("/channels?site=__all__"); + + await expect(page.getByRole("rowheader")).toHaveCount(0); + await expect(page.getByTestId("group-name")).toHaveCount(0); + await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible(); + await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible(); +}); + +test("sorting applies within each group, leaving the section order alone", async ({ + page, +}) => { + await seed(); + await page.goto("/channels?site=alpha"); + + const defaultRows = rowgroup(page, "default").getByRole("row"); + // Row 0 of a section's rowgroup is its header row. + await page.getByRole("button", { name: "sort by Slug" }).click(); + await expect(defaultRows.nth(1)).toContainText("slow-a"); + await expect(defaultRows.nth(2)).toContainText("slow-c"); + + // Flip: the rows swap INSIDE the section and the sections stay put. + await page.getByRole("button", { name: "sort by Slug" }).click(); + await expect(defaultRows.nth(1)).toContainText("slow-c"); + await expect(defaultRows.nth(2)).toContainText("slow-a"); + await expect(page.getByTestId("group-name")).toHaveText([ + "News", + "All channels", + ]); +}); + +test("a group's Sync queues that group's channels and nothing else", async ({ + page, +}) => { + await seed(); + await page.goto("/channels?site=alpha"); + + await page.getByLabel("sync group News").click(); + await expect(page.getByLabel("sync group News result")).toContainText( + /Queued 1 . skipped 0/, + { timeout: 15_000 }, + ); + + await page.goto("/jobs"); + // The machine kind, never the rendered label (/jobs renders jobKindLabel). + const syncRows = jobRowByKind(page, "sync"); + await expect(syncRows).toHaveCount(1); + await expect(syncRows).toContainText("slow-b"); +}); + +test("a station with no eligible channel is disabled and says why", async ({ + page, +}) => { + await seed(); + await page.goto("/channels?site=alpha"); + + // Both fixture channels are handling: "youtube", and a youtube channel never + // runs whisper — so counting it would inflate the figure on a button that + // would skip it anyway. + const transcribe = page.getByLabel("transcribe group News"); + await expect(transcribe).toBeDisabled(); + await expect(transcribe).toHaveAttribute("title", /never runs whisper/); + + // The default test settings enable no backfill lane kind, so the station reads + // "off" — NOT 0 (which reads as finished) and not — (which reads as unknown). + const backfill = page.getByLabel("backfill group News"); + await expect(backfill).toHaveText("Backfill off"); + await expect(backfill).toBeDisabled(); + await expect(backfill).toHaveAttribute("title", /not a finished one/); +}); + +test("a group figure reads — until a channel reports, then a real number", async ({ + page, +}) => { + test.setTimeout(120_000); + // One site configured → resolveActiveSite defaults to it, so grouping is the + // render with no ?site= param at all. + await resetData("test-pipeline"); + await writeSite("solo", { + siteTitle: "Solo", + channels: [{ slug: "test-pipeline" }], + }); + await page.goto("/channels"); + + const download = page.getByLabel("download group All channels"); + // No report yet: the page cannot say, so it says so. Never 0. + await expect(download).toContainText("—"); + + await generateReport(page, "test-pipeline"); + await page.goto(channelStage("test-pipeline", "playlist")); + await page.getByRole("button", { name: "Store playlist" }).click(); + await expect(page.getByLabel("Store playlist output")).toContainText( + "Wrote 5 URLs", + { timeout: 20_000 }, + ); + + // The snapshot scheduler debounces (~1s), so reload until the figure lands. + await expect + .poll( + async () => { + await page.goto("/channels"); + return page.getByLabel("download group All channels").textContent(); + }, + { timeout: 30_000, intervals: [500, 1000, 2000] }, + ) + .toMatch(/Download 5$/); +}); diff --git a/editor/e2e/channel-sync-toggle.spec.ts b/editor/e2e/channel-sync-toggle.spec.ts @@ -34,8 +34,8 @@ test("toggling a channel off excludes it from Sync all; toggling back restores i expect(cfg.excludeFromSync).toBe(true); } - // Run Sync all. slow-a is excluded; slow-b queues. - await page.getByRole("button", { name: "sync all channels" }).click(); + // 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,