import type { Metadata } from "next"; import Link from "next/link"; import { listChannelBriefs, listChannelStatsFromSnapshots, type ChannelBrief, } from "yt-dlp-transcript-common/controller/channels"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { healthTimings, notAnsweringText, stalledLocation, } from "yt-dlp-transcript-common/lib/storageHealth"; import { clearRuleText } from "yt-dlp-transcript-common/lib/storageHealthTimings"; import { getSite, listSiteIds, listSites, siteChannelSlugs, type Site, } from "yt-dlp-transcript-common/lib/site"; import { autoPauseReasonOf, 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, type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; import { INTERNAL_LOCATION_ID, INTERNAL_LOCATION_LABEL, volumeFreeBytes, } from "yt-dlp-transcript-common/controller/storageLocations"; import { buildChannelBands } from "yt-dlp-transcript-common/views/pipeline/buildBands"; import { EXTERNAL_BAND_IDS } from "yt-dlp-transcript-common/views/pipeline/buildBands"; import { ChannelsRack } from "./components/ChannelsRack"; import type { PipelineColumn } from "./components/channelColumnPresets"; import { buildChannelRowView, channelVolumeOf, type ChannelRowView, } from "yt-dlp-transcript-common/views/channelRow"; import type { ChannelVolume } from "./components/ChannelVolumeBar"; import { buildChannelGroupSections, type ChannelGroupSectionView, } from "yt-dlp-transcript-common/views/channelGroupSections"; import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton"; import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton"; import { readActiveSite } from "../lib/activeSiteServer"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Channels" }; // The video/transcript/download counts in this table are projected from each // channel's last generated snapshot rather than counted off disk, which is what // makes the page load in milliseconds instead of seconds. That trade is only // honest if the page says how old the numbers are — so report the OLDEST // snapshot on screen, and name the channels that have never had one. function summariseFreshness(briefs: ReadonlyArray): { oldest: string | null; missing: string[]; } { let oldest: number | null = null; let oldestIso: string | null = null; const missing: string[] = []; for (const b of briefs) { const at = b.snapshot?.generatedAt; if (!at) { missing.push(b.slug); continue; } const ms = Date.parse(at); if (Number.isNaN(ms)) continue; if (oldest === null || ms < oldest) { oldest = ms; oldestIso = at; } } return { oldest: oldestIso, missing }; } // WHICH PIPELINES GET A COLUMN, and in what order. // // Both answers come off the registry rather than a list maintained here: the // set is the two external operations plus every ENABLED backfill kind, and the // order is OPERATION_GROUP_ORDER, which the channel transit line lays its // stations out in too. A reader moving between the two pages sees the same // left-to-right sequence, and adding a kind adds a column without touching this // file. // // Sync is catalogued but channel-scoped, so no column: `ids` is built from // EXTERNAL_BAND_IDS plus the enabled backfill kinds and sync is in neither, and // its group is not in OPERATION_GROUP_ORDER either — a column here would have // to state a per-video coverage sync does not have. function pipelineColumns(operationIds: ReadonlyArray): { ids: string[]; columns: PipelineColumn[]; } { const catalog = new Map(operationCatalog().map((o) => [o.id, o])); const ids = [...EXTERNAL_BAND_IDS, ...operationIds].filter((id) => catalog.has(id), ); const rank = (id: string): number => { const group = catalog.get(id)?.group as OperationGroup | undefined; const i = group ? OPERATION_GROUP_ORDER.indexOf(group) : -1; return i === -1 ? OPERATION_GROUP_ORDER.length : i; }; // Stable within a group, so registry order still decides that diarization // precedes the two attribution lanes that consume it. const ordered = ids .map((id, i) => ({ id, i })) .sort((a, b) => rank(a.id) - rank(b.id) || a.i - b.i) .map((e) => e.id); const countLabels: Record = { download: "downloads count", transcription: "transcripts count", }; return { ids: ordered, columns: ordered.map((id) => { const op = catalog.get(id)!; return { id, shortLabel: op.shortLabel, label: op.label, costBasis: op.costBasis, countLabel: countLabels[id] ?? null, }; }), }; } // 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, sites: ReadonlyArray, ): 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; location?: string; sort?: string }>; }) { const paths = getPaths(); const settings = getSettings(); const { site, location: locationParam, sort: sortParam } = await searchParams; // The active site: a `?site=` link's for this request, else the cookie's. const active = await readActiveSite(site, listSiteIds(paths)); // Counts come from each channel's last snapshot, not a corpus walk. One read // serves both the table and the freshness footer below. const briefs = await listChannelBriefs(paths); const stats = await listChannelStatsFromSnapshots(paths, briefs); // The bands come off the SAME briefs the counts do — the snapshot is already // in hand, so this is a fold over memory and not a second read. (The band for // a channel and the corpus band it contributes to are literally the same // 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(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 // change exists to remove. const snapshots = new Map(briefs.map((b) => [b.slug, b.snapshot])); const briefBySlug = new Map(briefs.map((b) => [b.slug, b])); // WHERE EACH CHANNEL'S MEDIA IS. Two stats and a small JSON read per channel, // with the config already in hand from the brief — dozens of channels, so a // few hundred syscalls, and explicitly not a corpus walk. It is done here // rather than skipped because an unmounted drive makes every OTHER number on // this row wrong (an unreachable data/ reads as "nothing downloaded"), and a // page of confidently wrong counts with no marking is the failure this badge // exists to prevent. Only a non-in-place location is carried into the row. const mediaBySlug = new Map( await Promise.all( briefs.map( async (b) => [b.slug, await inspectChannelMedia(paths, b.slug, b.config)] as const, ), ), ); const locations = settings.storage.locations; // TWO SYSCALLS PER VOLUME, NOT A PROBE. See volumeFreeBytes: this table draws // 71 rows on every auto-refresh and the rule is that tables never shell out. const freeByVolume = await volumeFreeBytes({ paths, locations }); // A DRIVE THAT IS NOT ANSWERING, said on its chip. From memory — the health // pass (the block device's counters, every `storage.health.passIntervalMs`) // or the watchdog on a read is what found it (lib/storageHealth.ts); the // table asks nothing. const notAnsweringByVolume: Record = {}; for (const loc of locations) { const stall = stalledLocation(loc); if (stall) notAnsweringByVolume[loc.id] = notAnsweringText(stall); } // ONE ROW PER CHANNEL, off the shared builder (common/views/channelRow.ts): // the dashboard and the operation pages build theirs the same way. The view // carries no `config` — this table is a client component, and a channel // config holds cookie paths and yt-dlp args no cell draws. const all: ChannelRowView[] = stats.map((stat) => { const brief = briefBySlug.get(stat.slug); return buildChannelRowView({ slug: stat.slug, config: stat.config, snapshot: snapshots.get(stat.slug) ?? null, playlistCount: stat.playlistCount, bands: buildChannelBands(snapshots.get(stat.slug) ?? null, ids), // WHERE THE MEDIA IS: inspectChannelMedia's result; the builder drops an // in-place one and names the location from the volume, which is the // same prefix match the Location column shows — one answer, not two. media: mediaBySlug.get(stat.slug) ?? null, volume: channelVolumeOf(brief?.config, locations), priority: { tier: tierOf(priority, stat.slug), rank: rankOf(priority, stat.slug), overrides: overridesOf(priority, stat.slug), focused: focusSet.has(stat.slug), // WHY IT IS PAUSED, when the machine paused it. Null for every channel // the operator paused (or did not pause) themselves — the record is // cleared by any manual tier change, so this can only ever describe a // pause nobody chose. autoPausedReason: autoPauseReasonOf(priority, 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. // // 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 inSite = activeSite ? all.filter((c) => siteChannelSlugs(activeSite).has(c.slug)) : all; // THE VOLUME BAR IS BUILT FROM THE SITE SCOPE, NOT FROM THE FILTERED LIST. // A filter that erased every chip but the one you are standing on would be a // one-way door, and the numbers on the other chips are exactly what makes // "which of these should I move" answerable. const volumeIds = [INTERNAL_LOCATION_ID, ...locations.map((l) => l.id), ""]; const volumes: ChannelVolume[] = volumeIds .map((id) => { const rows = inSite.filter((c) => c.volumeId === id); const measured = rows.filter((c) => c.mediaBytes !== null); return { id, label: id === INTERNAL_LOCATION_ID ? INTERNAL_LOCATION_LABEL : id === "" ? "Elsewhere (unnamed root)" : (locations.find((l) => l.id === id)?.label ?? id), channels: rows.length, bytes: measured.reduce((sum, c) => sum + (c.mediaBytes ?? 0), 0), unmeasured: rows.length - measured.length, freeBytes: freeByVolume[id], ...(notAnsweringByVolume[id] ? { notAnswering: notAnsweringByVolume[id], clears: clearRuleText(healthTimings().clearAfterCleanPasses), } : {}), }; }) // The unnamed-root chip only exists when something is actually on one. .filter((v) => v.id !== "" || v.channels > 0); // ?location=, alongside ?site=. Ignored when it names nothing on // screen — a stale link from /storage after the last channel moved off a // location must show the page, not an empty table with no way back. const locationFilter = locationParam !== undefined && volumes.some((v) => v.id === locationParam && v.channels > 0) ? locationParam : null; const channels = locationFilter === null ? inSite : inSite.filter((c) => c.volumeId === locationFilter); const shownSlugs = new Set(channels.map((c) => c.slug)); // GROUPS ARE A PARTITION OF A SITE, so a volume filter and the grouped render // cannot both be true: half a group is not a group. Filtering flattens. // PROJECTED to slugs before they cross to the client: a section's stats // carry each channel's full config, and the rack reads only slug and count. const sections: ChannelGroupSectionView[] | null = activeSite && locationFilter === null ? buildChannelGroupSections( activeSite, stats.filter((st) => shownSlugs.has(st.slug)), briefs, settings, ).map((s) => ({ ...s, channels: s.channels.map((c) => ({ slug: c.slug })), })) : null; const freshness = summariseFreshness( briefs.filter((b) => shownSlugs.has(b.slug)), ); return ( // ON md+ THE DOCUMENT STOPS SCROLLING. The page is a flex column exactly // the height of the viewport (main carries py-6, hence -3rem) so that the // header, the focus line, the instrument bar and the selection deck are // fixed chrome and the rack between them is the only thing that moves. // Below md there is no height and the document scrolls as it always has.
{/* THE HEADER WRAPS; IT NEVER SQUEEZES. The title block has a 16rem basis, so when the corpus-wide cluster cannot sit beside it at full width the cluster drops onto its own line. It used to be nowrap with the cluster shrink-0, and at a narrow md+ column (sidebar open) the title block was left ~60px and its text ran one word per line. */}

Channels

{/* A STATUS LINE, NOT A LEAD PARAGRAPH. Every pipeline on the page is read from each channel's last report, so the line says how old the oldest one is and how many have none (the rack's Report column names them). */} {channels.length > 0 && (

{channels.length}{" "} {channels.length === 1 ? "channel" : "channels"} ·{" "} {freshness.oldest ? ( <> oldest report{" "} ) : ( <>no reports yet — run Update all reports )} {freshness.missing.length > 0 && freshness.oldest && ( <> {" "} ·{" "} {freshness.missing.length} {" "} without a report {sections ? " (a group figure marked + is a floor)" : ""} )}

)}
{/* FOUR DARK PILLS OF EQUAL WEIGHT was four things shouting. Three of these sweep the WHOLE pool and are used rarely; one creates a channel and is the page's actual call to action. So the three are outlines under an eyebrow that says what they act on, and only "New channel" is filled. */}
Every channel
New channel
{channels.length === 0 ? (

{active.siteId && !active.isAll ? `No channels assigned to ${active.siteId} yet — add some on the Sites page, or create a channel to add it here.` : "No channels yet."}

) : ( ({ id: loc.id, label: loc.label || loc.id, root: loc.root, }))} defaultLocationId={settings.storage.defaultLocationId} sites={sites.map((s) => ({ siteId: s.siteId, title: s.siteTitle || s.siteId, }))} focusLabel={focusLabel} /> )}
); }