import type { ChannelConfig } from "../lib/channelConfig"; import type { ChannelSnapshot } from "../controller/channelSnapshot"; import type { ChannelMediaLocation } from "../lib/channelMedia"; import { mediaHoldText } from "../lib/channelMediaHold"; import type { PriorityOperation, StoredChannelTier, } from "../lib/channelPriority"; import { INTERNAL_LOCATION_ID, locationOfDataDir, type StorageLocation, } from "../lib/storageLocations"; import type { OperationBand } from "./pipeline/band"; import { digestReachableCountOf, undownloadedCountOf, untranscribedCountOf, } from "./actionableCounts"; // ONE CHANNEL ROW, whichever table draws it. // // Three tables used to build their row inline — the /channels rack // (`channels/page.tsx`), the dashboard's channels table (`app/page.tsx`) and the // operation pages' work tables (`ChannelWorkTable`) — each reading the config // and the snapshot its own way. This is the one projection: the server builds a // `ChannelRowView` per channel and the shared client table // (`editor/app/channels/components/ChannelsTable.tsx`) draws whichever columns // its caller names. // // CLIENT-SAFE BY CONSTRUCTION. The view never carries `config`: a channel // config holds cookie paths, yt-dlp args and the download filter, none of which // a table draws and all of which would otherwise be serialized into every // page's RSC payload. The builder reads the config; the view carries the six // fields of it that a cell renders. // 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>; focused: boolean; heldReason: string | null; // Why the machine paused it, or null. See autoPauseReasonOf. autoPausedReason: string | null; }; // The priority a table that draws no Tier column hands the builder: what a // channel with no entry in the priority document resolves to. export function neutralChannelPriority(): ChannelRowPriority { return { tier: "normal", rank: null, overrides: {}, focused: false, heldReason: null, autoPausedReason: null, }; } // Where this channel's media physically is, as the badge draws it. Narrower // than ChannelMediaLocation: a row only needs what it draws. export type ChannelRowMedia = Pick< ChannelMediaLocation, "status" | "target" | "detail" > & { // The name of the storage location this channel's media is on, projected by // the server that built the row. A fourth string is still cheaper than // shipping the location list to every table. locationLabel?: string; }; export type ReportState = "current" | "stale" | "missing"; export type ChannelRowView = { slug: string; // `config.name`, "" when unset. name: string; handling: ChannelConfig["handling"]; // Whether the channel has a URL — Sync and Download need one. hasUrl: boolean; excludeFromBuild: boolean; lastSyncedAt: string | null; // The playlist file's line count; null when there is no playlist file. playlistCount: number | null; downloadCount: number; transcriptCount: number; // The per-channel work figures, off the SAME helpers the actionable census // counts with (./actionableCounts.ts), so a dashboard cell and the widget's // "Needs work" strip cannot disagree about a channel. work: { videos: number; undownloaded: number; untranscribed: number; digestReachable: number; }; // The pipeline bands, in column order, projected from the same snapshot the // counts come from. `[]` for a table that draws no pipeline column. pipelines: OperationBand[]; 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. report: { generatedAt: string | null; state: ReportState }; // Null for an in-place channel — the overwhelming majority — so the badge // marks only the rows whose other numbers may not be trustworthy. media: ChannelRowMedia | null; // THE CHANNEL'S MEDIA HOLD, in its one wording (lib/channelMediaHold.ts): // "held: its media is moving (…)" while a relocation marker stands, and the // drive's words while it is unreachable, stalled or inconsistent. Null when // the lanes read the channel. The rack draws it beside the tier, because it // is the reason the lanes are skipping the row (release 16 slice RM). mediaHold: string | null; // WHICH VOLUME THE MEDIA IS ON, as an id a filter can name: a location id, // "internal" for the corpus volume, or "" for a mediaDir under a root nobody // named. (The text is on the corpus volume whatever this says.) volumeId: string; volumeLabel: string; // `snapshot.totalMediaBytes` — the MEDIA TIER's bytes since release 17 (the // audio and the raw live chat: what a move carries). NULL, not 0, for a // report written before the field existed: a 400 GB channel that has not // been measured must not sort as the smallest thing on the disk. mediaBytes: number | null; }; export type ChannelRowInput = { slug: string; config: ChannelConfig; snapshot: ChannelSnapshot | null; playlistCount: number | null; bands: OperationBand[]; priority: ChannelRowPriority; // The server's inspectChannelMedia result, or null when it was not asked. // An in-place result is dropped here, once, for every table. media: Pick | null; volume: ChannelVolumeId; // The badge's location name. Defaults to the volume's label (undefined for // the unnamed-root volume, which renders exactly what the badge rendered // before locations existed). The dashboard names it from the media target, // as it always has, and passes that here. mediaLocationLabel?: string; }; // How old a channel's report is, in the three states /channels draws: // "missing" (never generated), "stale" (older than the last sync — so every // count read off it may be wrong) and "current". export function reportStateOf(brief: { config: Pick; snapshot: Pick | null; }): ReportState { if (!brief.snapshot) return "missing"; const synced = brief.config.lastSyncedAt; if (!synced) return "current"; return new Date(synced).getTime() > new Date(brief.snapshot.generatedAt).getTime() ? "stale" : "current"; } export type ChannelVolumeId = { id: string; label: string }; // WHICH VOLUME A CHANNEL'S MEDIA IS ON, as a filterable id. // // Same derivation the badge uses (`config.mediaDir` under a location's root, // longest match wins; a `legacy` channel by its retired `dataDir`) with one // addition: no target at all means the corpus volume, which is the row the // operator is trying to empty and therefore the one they most need to filter // to. A target under a root NOBODY named is neither — it gets "" and falls out // of every volume filter, which is the honest answer and the nudge to name // that root on /storage. export function channelVolumeOf( config: Pick | undefined, locations: StorageLocation[], ): ChannelVolumeId { const trimmed = config?.mediaDir?.trim() || config?.dataDir?.trim(); if (!trimmed) { return { id: INTERNAL_LOCATION_ID, label: "Internal" }; } const found = locationOfDataDir(trimmed, locations); return found ? { id: found.id, label: found.label || found.id } : { id: "", label: "Elsewhere" }; } export function buildChannelRowView(i: ChannelRowInput): ChannelRowView { const snap = i.snapshot; return { slug: i.slug, name: i.config.name ?? "", handling: i.config.handling, hasUrl: Boolean(i.config.url), excludeFromBuild: i.config.excludeFromBuild === true, lastSyncedAt: i.config.lastSyncedAt ?? null, playlistCount: i.playlistCount, downloadCount: snap?.totals.downloaded ?? 0, transcriptCount: snap?.totals.transcribed ?? 0, work: { videos: snap?.totals.videos ?? 0, undownloaded: undownloadedCountOf(snap), untranscribed: untranscribedCountOf(snap), digestReachable: digestReachableCountOf(snap), }, pipelines: i.bands, priority: i.priority, report: { generatedAt: snap?.generatedAt ?? null, state: reportStateOf({ config: i.config, snapshot: snap }), }, media: i.media && i.media.status !== "in-place" ? { status: i.media.status, target: i.media.target, detail: i.media.detail, locationLabel: "mediaLocationLabel" in i ? i.mediaLocationLabel : i.volume.id === "" ? undefined : i.volume.label, } : null, mediaHold: i.media ? mediaHoldText(i.media.status) : null, volumeId: i.volume.id, volumeLabel: i.volume.label, mediaBytes: snap?.totalMediaBytes ?? null, }; }