Archilyzer · Source

archilyzer

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

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:
Acommon/views/actionableCounts.test.ts | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/actionableCounts.ts | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/channelRow.test.ts | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/channelRow.ts | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/components/MediaLocationBadge.tsx | 20++++++--------------
Meditor/app/lib/actionable/loadActionable.ts | 169+++++++++++++++++++++++--------------------------------------------------------
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