commit a4dfb0cd256b886b0344490a694ecdad0c5964c3
parent 4572213d70dd41e717892d9cbc4c35c58154afe4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 12:54:43 -0400
views: channelRow + actionableCounts; loadActionable wraps them
`common/views/actionableCounts.ts` holds the per-channel work counts as pure
functions over one snapshot; `loadActionable.ts` keeps its `actionable*Count`
names as one-line wrappers, so no caller moves. `common/views/channelRow.ts`
is the one client-safe channel row (`ChannelRowView`, no `config`) with
`buildChannelRowView`, `reportStateOf` (moved; re-exported by loadActionable),
`ChannelRowPriority`, `ChannelRowMedia` (MediaLocationBadge's `MediaBadgeInput`
is now that type) and `channelVolumeOf`. No UI change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 668 insertions(+), 135 deletions(-)
diff --git a/common/views/actionableCounts.test.ts b/common/views/actionableCounts.test.ts
@@ -0,0 +1,116 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { ChannelSnapshot } from "../controller/channelSnapshot";
+import type { OperationSnapshotEntry } from "../lib/operations";
+import { normalizeBuckets } from "./pipeline/stageStatus";
+import {
+ cleanExtraFormatsBytesOf,
+ cleanExtraFormatsCountOf,
+ cleanTranscribedBytesOf,
+ cleanTranscribedCountOf,
+ digestReachableCountOf,
+ digestWarningsCountOf,
+ incompleteTranscriptCountOf,
+ metadataScanCountOf,
+ missingNeverFetchedCountOf,
+ shortAudioCountOf,
+ undownloadedCountOf,
+ untranscribedCountOf,
+} from "./actionableCounts";
+
+function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot {
+ return {
+ generatedAt: "2026-08-01T00:00:00.000Z",
+ totals: { videos: 10, transcribed: 4, downloaded: 6 },
+ buckets: normalizeBuckets(undefined),
+ undownloadedIds: [],
+ ...patch,
+ };
+}
+
+const ALL = [
+ undownloadedCountOf,
+ missingNeverFetchedCountOf,
+ metadataScanCountOf,
+ untranscribedCountOf,
+ incompleteTranscriptCountOf,
+ shortAudioCountOf,
+ cleanTranscribedCountOf,
+ cleanExtraFormatsCountOf,
+ digestReachableCountOf,
+ digestWarningsCountOf,
+ cleanTranscribedBytesOf,
+ cleanExtraFormatsBytesOf,
+];
+
+test("a channel with no report counts zero everywhere", () => {
+ for (const f of ALL) {
+ assert.equal(f(null), 0, f.name);
+ assert.equal(f(undefined), 0, f.name);
+ }
+});
+
+test("a snapshot written before the optional buckets existed counts zero", () => {
+ // The on-disk shape of an old report: no optional buckets, no cleanupBytes,
+ // no metadataScan, no missingNeverFetched, no backfill entries.
+ const old = {
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ totals: { videos: 3, transcribed: 0, downloaded: 0 },
+ buckets: { downloadedNoTranscript: [] },
+ undownloadedIds: [],
+ } as unknown as ChannelSnapshot;
+ for (const f of ALL) assert.equal(f(old), 0, f.name);
+});
+
+test("undownloaded and untranscribed drop the availability exclusions", () => {
+ const snap = snapshotOf({
+ undownloadedIds: ["a", "b", "c", "d"],
+ buckets: {
+ ...normalizeBuckets(undefined),
+ downloadedNoTranscript: ["t1", "t2", "b"],
+ },
+ excludedFromDownload: { membersOnly: ["a"], deleted: ["b"], private: [] },
+ });
+ assert.equal(undownloadedCountOf(snap), 2);
+ assert.equal(untranscribedCountOf(snap), 2);
+ // With nothing excluded the lengths are the counts.
+ const plain = snapshotOf({ undownloadedIds: ["a", "b"] });
+ assert.equal(undownloadedCountOf(plain), 2);
+});
+
+test("the bucket counts are the bucket lengths; the bytes are cleanupBytes", () => {
+ const snap = snapshotOf({
+ buckets: {
+ ...normalizeBuckets(undefined),
+ incompleteTranscript: ["i"],
+ shortAudio: ["s1", "s2"],
+ transcribedWithAudio: ["w1", "w2", "w3"],
+ multipleAudioFormats: ["m"],
+ digestWarnings: ["d1", "d2"],
+ },
+ cleanupBytes: { transcribedWithAudio: 300, multipleAudioFormats: 40 },
+ metadataScan: { unscanned: 7 } as ChannelSnapshot["metadataScan"],
+ missingNeverFetched: [{}, {}] as ChannelSnapshot["missingNeverFetched"],
+ });
+ assert.equal(incompleteTranscriptCountOf(snap), 1);
+ assert.equal(shortAudioCountOf(snap), 2);
+ assert.equal(cleanTranscribedCountOf(snap), 3);
+ assert.equal(cleanExtraFormatsCountOf(snap), 1);
+ assert.equal(digestWarningsCountOf(snap), 2);
+ assert.equal(cleanTranscribedBytesOf(snap), 300);
+ assert.equal(cleanExtraFormatsBytesOf(snap), 40);
+ assert.equal(metadataScanCountOf(snap), 7);
+ assert.equal(missingNeverFetchedCountOf(snap), 2);
+});
+
+test("digest reachable is the digest registry entry's reachable", () => {
+ const entry = {
+ ids: [],
+ missing: 5,
+ stale: 2,
+ missingInput: 0,
+ partial: 1,
+ } as unknown as OperationSnapshotEntry;
+ const snap = snapshotOf({ backfill: { digest: entry } });
+ assert.equal(digestReachableCountOf(snap), 8);
+});
diff --git a/common/views/actionableCounts.ts b/common/views/actionableCounts.ts
@@ -0,0 +1,108 @@
+import {
+ digestWorkOf,
+ excludedDownloadIdSet,
+ type ChannelSnapshot,
+} from "../controller/channelSnapshot";
+
+// THE PER-CHANNEL WORK COUNTS, as pure functions over one channel's snapshot.
+//
+// These were the `actionable*Count` helpers in the editor's
+// `lib/actionable/loadActionable.ts`, each taking an `ActionableRow` and reading
+// only `row.snapshot`. They move here so the channel-row builder
+// (`./channelRow.ts`) and the actionable census count the SAME way — the
+// dashboard's Videos / Digest to do cells and the widget's "Needs work" strip
+// were each a hand copy of the census before. `loadActionable.ts` keeps its
+// `actionable*` names as one-line wrappers over these, so no caller moved.
+//
+// Every function takes `null`/`undefined` (a channel that has never been
+// reported) and answers 0, and every optional bucket defaults to 0 for a
+// snapshot written before the bucket existed.
+
+type Snap = ChannelSnapshot | null | undefined;
+
+// Counts that drive the actionable lists exclude IDs that the availability
+// check has flagged as deleted / members-only / private — those videos
+// can't be acted on, so they shouldn't inflate "needs attention" totals.
+// `undownloadedIds` is already filtered at snapshot generation time, but we
+// apply the filter again so a stale snapshot can't surface excluded IDs.
+function countActionable(
+ snapshot: Snap,
+ ids: readonly string[] | undefined,
+): number {
+ if (!snapshot || !ids) return 0;
+ const excluded = excludedDownloadIdSet(snapshot);
+ if (excluded.size === 0) return ids.length;
+ let n = 0;
+ for (const id of ids) if (!excluded.has(id)) n++;
+ return n;
+}
+
+export function undownloadedCountOf(snapshot: Snap): number {
+ return countActionable(snapshot, snapshot?.undownloadedIds);
+}
+
+// Videos the roster says we were told about, never downloaded, and that have
+// since left the listing. Deliberately NOT run through countActionable: the
+// availability exclusions are keyed on videos we have on disk, and these have no
+// dir at all.
+export function missingNeverFetchedCountOf(snapshot: Snap): number {
+ return snapshot?.missingNeverFetched?.length ?? 0;
+}
+
+// Listed videos the metadata scan has neither read nor recently failed on —
+// the scan operation's backlog. Deliberately NOT run through countActionable:
+// these videos have no directory, so the availability exclusions (which are
+// keyed on what is on disk) cannot say anything about them.
+export function metadataScanCountOf(snapshot: Snap): number {
+ return snapshot?.metadataScan?.unscanned ?? 0;
+}
+
+export function untranscribedCountOf(snapshot: Snap): number {
+ return countActionable(snapshot, snapshot?.buckets.downloadedNoTranscript);
+}
+
+// Transcribed videos whose transcript is badly truncated (the audio download
+// stopped early).
+export function incompleteTranscriptCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.incompleteTranscript?.length ?? 0;
+}
+
+// Downloads the duration guard flagged as truncated at the source (short audio
+// kept on disk, not transcribed).
+export function shortAudioCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.shortAudio?.length ?? 0;
+}
+
+// Cleanup buckets are filtered by "do not clean" at snapshot-generation time,
+// so the length is the actionable count directly.
+export function cleanTranscribedCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.transcribedWithAudio?.length ?? 0;
+}
+
+export function cleanExtraFormatsCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.multipleAudioFormats?.length ?? 0;
+}
+
+// The digest band's `reachable`, per channel — read through digestWorkOf, the
+// registry's classification the runner uses (no transcript = blocked, stale
+// cues = deferred; neither is in it). buildBands.ts folds the same call.
+export function digestReachableCountOf(snapshot: Snap): number {
+ return digestWorkOf(snapshot).reachable;
+}
+
+// "The model produced something a human should look at", which includes the
+// total failures that write no section and so are invisible to any count of
+// files.
+export function digestWarningsCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.digestWarnings?.length ?? 0;
+}
+
+// Estimated bytes each cleanup would reclaim (0 for snapshots written before
+// cleanupBytes existed).
+export function cleanTranscribedBytesOf(snapshot: Snap): number {
+ return snapshot?.cleanupBytes?.transcribedWithAudio ?? 0;
+}
+
+export function cleanExtraFormatsBytesOf(snapshot: Snap): number {
+ return snapshot?.cleanupBytes?.multipleAudioFormats ?? 0;
+}
diff --git a/common/views/channelRow.test.ts b/common/views/channelRow.test.ts
@@ -0,0 +1,172 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { ChannelSnapshot } from "../controller/channelSnapshot";
+import type { ChannelConfig } from "../lib/channelConfig";
+import type { StorageLocation } from "../lib/storageLocations";
+import { normalizeBuckets } from "./pipeline/stageStatus";
+import {
+ buildChannelRowView,
+ channelVolumeOf,
+ neutralChannelPriority,
+ reportStateOf,
+ type ChannelRowInput,
+} from "./channelRow";
+import {
+ digestReachableCountOf,
+ undownloadedCountOf,
+ untranscribedCountOf,
+} from "./actionableCounts";
+
+function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot {
+ return {
+ generatedAt: "2026-08-01T00:00:00.000Z",
+ totals: { videos: 10, transcribed: 4, downloaded: 6 },
+ buckets: normalizeBuckets(undefined),
+ undownloadedIds: [],
+ ...patch,
+ };
+}
+
+function input(patch: Partial<ChannelRowInput> = {}): ChannelRowInput {
+ const config: ChannelConfig = {
+ handling: "transcribe",
+ url: "https://example.test/alpha",
+ name: "Alpha",
+ lastSyncedAt: "2026-07-01T00:00:00.000Z",
+ cookiesFile: "/secret/cookies.txt",
+ } as ChannelConfig;
+ return {
+ slug: "alpha",
+ config,
+ snapshot: snapshotOf(),
+ playlistCount: 12,
+ bands: [],
+ priority: neutralChannelPriority(),
+ media: null,
+ volume: { id: "internal", label: "Internal" },
+ ...patch,
+ };
+}
+
+test("a channel with no report: missing, no size, zero work", () => {
+ const row = buildChannelRowView(input({ snapshot: null }));
+ assert.deepEqual(row.report, { generatedAt: null, state: "missing" });
+ assert.equal(row.mediaBytes, null);
+ assert.deepEqual(row.work, {
+ videos: 0,
+ undownloaded: 0,
+ untranscribed: 0,
+ digestReachable: 0,
+ });
+ assert.equal(row.downloadCount, 0);
+ assert.equal(row.transcriptCount, 0);
+});
+
+test("stale when the last sync is newer than the report, else current", () => {
+ const stale = buildChannelRowView(
+ input({
+ config: { handling: "transcribe", lastSyncedAt: "2026-09-01T00:00:00Z" },
+ }),
+ );
+ assert.equal(stale.report.state, "stale");
+ const current = buildChannelRowView(input());
+ assert.equal(current.report.state, "current");
+ assert.equal(current.report.generatedAt, "2026-08-01T00:00:00.000Z");
+ // Never synced: nothing to be stale against.
+ assert.equal(
+ reportStateOf({ config: {}, snapshot: { generatedAt: "2026-01-01" } }),
+ "current",
+ );
+});
+
+test("work equals the census count helpers over the same snapshot", () => {
+ const snap = snapshotOf({
+ undownloadedIds: ["a", "b", "c"],
+ buckets: { ...normalizeBuckets(undefined), downloadedNoTranscript: ["x", "a"] },
+ excludedFromDownload: { membersOnly: ["a"], deleted: [], private: [] },
+ totalMediaBytes: 1234,
+ });
+ const row = buildChannelRowView(input({ snapshot: snap }));
+ assert.deepEqual(row.work, {
+ videos: 10,
+ undownloaded: undownloadedCountOf(snap),
+ untranscribed: untranscribedCountOf(snap),
+ digestReachable: digestReachableCountOf(snap),
+ });
+ assert.equal(row.work.undownloaded, 2);
+ assert.equal(row.mediaBytes, 1234);
+ assert.equal(row.downloadCount, 6);
+ assert.equal(row.transcriptCount, 4);
+});
+
+test("the view carries no config, only the fields a cell draws", () => {
+ const row = buildChannelRowView(input());
+ assert.equal("config" in row, false);
+ assert.equal(JSON.stringify(row).includes("cookies"), false);
+ assert.equal(row.name, "Alpha");
+ assert.equal(row.hasUrl, true);
+ assert.equal(row.excludeFromBuild, false);
+ assert.equal(row.lastSyncedAt, "2026-07-01T00:00:00.000Z");
+ assert.equal(row.playlistCount, 12);
+ const bare = buildChannelRowView(input({ config: { handling: "youtube" } }));
+ assert.equal(bare.name, "");
+ assert.equal(bare.hasUrl, false);
+ assert.equal(bare.lastSyncedAt, null);
+});
+
+test("media: in-place is dropped; the label is the volume's unless overridden", () => {
+ const inPlace = buildChannelRowView(
+ input({ media: { status: "in-place", target: "/x", detail: "" } }),
+ );
+ assert.equal(inPlace.media, null);
+ const moved = buildChannelRowView(
+ input({
+ media: { status: "ok", target: "/mnt/big/alpha/data", detail: "d" },
+ volume: { id: "big", label: "Big disk" },
+ }),
+ );
+ assert.equal(moved.media?.locationLabel, "Big disk");
+ assert.equal(moved.volumeId, "big");
+ const unnamed = buildChannelRowView(
+ input({
+ media: { status: "unreachable", target: "/mnt/x", detail: "d" },
+ volume: { id: "", label: "Elsewhere" },
+ }),
+ );
+ assert.equal(unnamed.media?.locationLabel, undefined);
+ const overridden = buildChannelRowView(
+ input({
+ media: { status: "ok", target: "/mnt/big/alpha/data", detail: "d" },
+ volume: { id: "big", label: "Big disk" },
+ mediaLocationLabel: undefined,
+ }),
+ );
+ assert.equal(overridden.media?.locationLabel, undefined);
+});
+
+test("channelVolumeOf: no dataDir is internal, a named root wins, else unnamed", () => {
+ const locations = [
+ { id: "big", label: "Big disk", root: "/mnt/big" },
+ { id: "bigger", label: "", root: "/mnt/big/inner" },
+ ] as StorageLocation[];
+ assert.deepEqual(channelVolumeOf(undefined, locations), {
+ id: "internal",
+ label: "Internal",
+ });
+ assert.deepEqual(channelVolumeOf(" ", locations), {
+ id: "internal",
+ label: "Internal",
+ });
+ assert.deepEqual(channelVolumeOf("/mnt/big/alpha/data", locations), {
+ id: "big",
+ label: "Big disk",
+ });
+ assert.deepEqual(channelVolumeOf("/mnt/big/inner/a/data", locations), {
+ id: "bigger",
+ label: "bigger",
+ });
+ assert.deepEqual(channelVolumeOf("/elsewhere/a/data", locations), {
+ id: "",
+ label: "Elsewhere",
+ });
+});
diff --git a/common/views/channelRow.ts b/common/views/channelRow.ts
@@ -0,0 +1,218 @@
+import type { ChannelConfig } from "../lib/channelConfig";
+import type { ChannelSnapshot } from "../controller/channelSnapshot";
+import type { ChannelMediaLocation } from "../lib/channelMedia";
+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<Record<PriorityOperation, StoredChannelTier>>;
+ 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;
+ // WHICH VOLUME, as an id a filter can name: a location id, "internal" for the
+ // corpus volume, or "" for a dataDir under a root nobody named.
+ volumeId: string;
+ volumeLabel: string;
+ // `snapshot.totalMediaBytes`. 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<ChannelMediaLocation, "status" | "target" | "detail"> | 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<ChannelConfig, "lastSyncedAt">;
+ snapshot: Pick<ChannelSnapshot, "generatedAt"> | 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.dataDir` under a location's root,
+// longest match wins) with one addition: no `dataDir` 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 `dataDir` 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(
+ dataDir: string | undefined,
+ locations: StorageLocation[],
+): ChannelVolumeId {
+ const trimmed = 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,
+ volumeId: i.volume.id,
+ volumeLabel: i.volume.label,
+ mediaBytes: snap?.totalMediaBytes ?? null,
+ };
+}
diff --git a/editor/app/components/MediaLocationBadge.tsx b/editor/app/components/MediaLocationBadge.tsx
@@ -1,7 +1,5 @@
-import type {
- ChannelMediaLocation,
- ChannelMediaStatus,
-} from "yt-dlp-transcript-common/lib/channelMedia";
+import type { ChannelMediaStatus } from "yt-dlp-transcript-common/lib/channelMedia";
+import type { ChannelRowMedia } from "yt-dlp-transcript-common/views/channelRow";
// THE ONE RENDERING OF "where is this channel's media, and can we reach it".
//
@@ -31,16 +29,10 @@ import type {
// The prop shape, deliberately narrower than ChannelMediaLocation: a row only
// needs what it draws, so a server page can project three fields onto a client
-// component instead of serializing a whole location per channel.
-export type MediaBadgeInput = 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 (see `mediaBadgeOf`). A fourth string is
- // still cheaper than shipping the location list to every table.
- locationLabel?: string;
-};
+// component instead of serializing a whole location per channel. It is the
+// channel row's own media field (common/views/channelRow.ts), which is where
+// the in-place filter and the location label are decided, once.
+export type MediaBadgeInput = ChannelRowMedia;
export type MediaBadgeTone = "neutral" | "danger";
diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts
@@ -3,11 +3,22 @@ import { cache } from "react";
import type { ChannelBrief } from "yt-dlp-transcript-common/controller/channels";
import type { WidgetActionableChannel } from "yt-dlp-transcript-common/views/widgetActionable";
import { getChannelBriefs } from "../requestCache";
+import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
+import { reportStateOf } from "yt-dlp-transcript-common/views/channelRow";
import {
- digestWorkOf,
- excludedDownloadIdSet,
- type ChannelSnapshot,
-} from "yt-dlp-transcript-common/controller/channelSnapshot";
+ cleanExtraFormatsBytesOf,
+ cleanExtraFormatsCountOf,
+ cleanTranscribedBytesOf,
+ cleanTranscribedCountOf,
+ digestReachableCountOf,
+ digestWarningsCountOf,
+ incompleteTranscriptCountOf,
+ metadataScanCountOf,
+ missingNeverFetchedCountOf,
+ shortAudioCountOf,
+ undownloadedCountOf,
+ untranscribedCountOf,
+} from "yt-dlp-transcript-common/views/actionableCounts";
export type ActionableRow = {
channel: ChannelBrief;
@@ -29,128 +40,44 @@ export type ActionableSummary = {
digestWarnings: ActionableRow[];
};
-// 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".
-//
-// Takes a brief rather than an ActionableRow because /channels holds briefs and
-// the two carry the same two fields; `isStaleOrMissing` is this function with
-// the two non-current states collapsed.
-export function reportStateOf(
- brief: Pick<ChannelBrief, "config" | "snapshot">,
-): "current" | "stale" | "missing" {
- 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";
-}
+// `reportStateOf` lives in common/views/channelRow.ts now, beside the row
+// builder that reads it; re-exported so every importer keeps its path.
+export { reportStateOf };
export function isStaleOrMissing(row: ActionableRow): boolean {
return reportStateOf(row.channel) !== "current";
}
-// Counts that drive the actionable lists exclude IDs that the availability
-// check has flagged as deleted / members-only / private — those videos
-// can't be acted on, so they shouldn't inflate "needs attention" totals.
-// `undownloadedIds` is already filtered at snapshot generation time, but we
-// apply the filter again so a stale snapshot can't surface excluded IDs.
-function countActionable(
- snapshot: ChannelSnapshot | null | undefined,
- ids: readonly string[] | undefined,
-): number {
- if (!snapshot || !ids) return 0;
- const excluded = excludedDownloadIdSet(snapshot);
- if (excluded.size === 0) return ids.length;
- let n = 0;
- for (const id of ids) if (!excluded.has(id)) n++;
- return n;
-}
-
-export function actionableUndownloadedCount(row: ActionableRow): number {
- return countActionable(row.snapshot, row.snapshot?.undownloadedIds);
-}
-
-// Videos the roster says we were told about, never downloaded, and that have
-// since left the listing. Deliberately NOT run through countActionable: the
-// availability exclusions are keyed on videos we have on disk, and these have no
-// dir at all. Default 0 for snapshots written before the bucket existed.
-export function actionableMissingNeverFetchedCount(row: ActionableRow): number {
- return row.snapshot?.missingNeverFetched?.length ?? 0;
-}
-
-// Listed videos the metadata scan has neither read nor recently failed on —
-// the scan operation's backlog, counted at snapshot-generation time so this
-// costs no extra read. Deliberately NOT run through countActionable: these
-// videos have no directory, so the availability exclusions (which are keyed on
-// what is on disk) cannot say anything about them. Default 0 for snapshots
-// written before the field existed.
-export function actionableMetadataScanCount(row: ActionableRow): number {
- return row.snapshot?.metadataScan?.unscanned ?? 0;
-}
-
-export function actionableUntranscribedCount(row: ActionableRow): number {
- return countActionable(
- row.snapshot,
- row.snapshot?.buckets.downloadedNoTranscript,
- );
-}
-
-// Transcribed videos whose transcript is badly truncated (the audio download
-// stopped early). Default 0 for snapshots written before the bucket existed.
-export function actionableIncompleteTranscriptCount(row: ActionableRow): number {
- return row.snapshot?.buckets.incompleteTranscript?.length ?? 0;
-}
-
-// Downloads the duration guard flagged as truncated at the source (short audio
-// kept on disk, not transcribed). Default 0 for snapshots predating the bucket.
-export function actionableShortAudioCount(row: ActionableRow): number {
- return row.snapshot?.buckets.shortAudio?.length ?? 0;
-}
-
-// Cleanup buckets are filtered by "do not clean" at snapshot-generation time,
-// so the length is the actionable count directly (default undefined → 0 for
-// snapshots written before the bucket existed).
-export function actionableCleanTranscribedCount(row: ActionableRow): number {
- return row.snapshot?.buckets.transcribedWithAudio?.length ?? 0;
-}
-
-export function actionableCleanExtraFormatsCount(row: ActionableRow): number {
- return row.snapshot?.buckets.multipleAudioFormats?.length ?? 0;
-}
-
-// The digest layer's two work lists. The first is "has no digest at the current
-// identity" — missing, stale or part-done. `digestWarnings` is "the model
-// produced something a human should look at", which includes the total failures
-// that write no section and so are invisible to any count of files.
-//
-// Read through digestWorkOf: the registry's classification is the one the runner
-// uses, and it excludes videos with no transcript (blocked) and videos whose
-// cues.json is stale (deferred) — work the old `noDigest` bucket offered here
-// and the runner then declined.
-//
-// This IS the digest band's `reachable`, per channel: buildBands.ts:63 folds
-// the same call. The name says so now, so nobody re-sources a number that was
-// already the right one.
-export function actionableDigestReachableCount(row: ActionableRow): number {
- return digestWorkOf(row.snapshot).reachable;
-}
-
-export function actionableDigestWarningsCount(row: ActionableRow): number {
- return row.snapshot?.buckets.digestWarnings?.length ?? 0;
-}
-
-// Estimated bytes each cleanup would reclaim (default 0 for snapshots written
-// before cleanupBytes existed).
-export function actionableCleanTranscribedBytes(row: ActionableRow): number {
- return row.snapshot?.cleanupBytes?.transcribedWithAudio ?? 0;
-}
-
-export function actionableCleanExtraFormatsBytes(row: ActionableRow): number {
- return row.snapshot?.cleanupBytes?.multipleAudioFormats ?? 0;
-}
+// THE COUNT HELPERS, as wrappers. The counting itself is
+// common/views/actionableCounts.ts — pure functions over one snapshot — which
+// the channel-row builder calls too, so a dashboard cell and this census cannot
+// count a channel two ways. See that module for what each one excludes.
+export const actionableUndownloadedCount = (row: ActionableRow): number =>
+ undownloadedCountOf(row.snapshot);
+export const actionableMissingNeverFetchedCount = (row: ActionableRow): number =>
+ missingNeverFetchedCountOf(row.snapshot);
+export const actionableMetadataScanCount = (row: ActionableRow): number =>
+ metadataScanCountOf(row.snapshot);
+export const actionableUntranscribedCount = (row: ActionableRow): number =>
+ untranscribedCountOf(row.snapshot);
+export const actionableIncompleteTranscriptCount = (row: ActionableRow): number =>
+ incompleteTranscriptCountOf(row.snapshot);
+export const actionableShortAudioCount = (row: ActionableRow): number =>
+ shortAudioCountOf(row.snapshot);
+export const actionableCleanTranscribedCount = (row: ActionableRow): number =>
+ cleanTranscribedCountOf(row.snapshot);
+export const actionableCleanExtraFormatsCount = (row: ActionableRow): number =>
+ cleanExtraFormatsCountOf(row.snapshot);
+// This IS the digest band's `reachable`, per channel: buildBands.ts folds the
+// same call.
+export const actionableDigestReachableCount = (row: ActionableRow): number =>
+ digestReachableCountOf(row.snapshot);
+export const actionableDigestWarningsCount = (row: ActionableRow): number =>
+ digestWarningsCountOf(row.snapshot);
+export const actionableCleanTranscribedBytes = (row: ActionableRow): number =>
+ cleanTranscribedBytesOf(row.snapshot);
+export const actionableCleanExtraFormatsBytes = (row: ActionableRow): number =>
+ cleanExtraFormatsBytesOf(row.snapshot);
// The census rows as the widget's "Needs work" strip counts them: one row per
// channel, through the three count helpers above. The filter and the sort are