import type { Paths } from "yt-dlp-transcript-common/lib/paths"; 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 { cleanExtraFormatsBytesOf, cleanExtraFormatsCountOf, cleanTranscribedBytesOf, cleanTranscribedCountOf, digestReachableCountOf, digestWarningsCountOf, incompleteTranscriptCountOf, metadataScanCountOf, missingNeverFetchedCountOf, shortAudioCountOf, undownloadedCountOf, untranscribedCountOf, } from "yt-dlp-transcript-common/views/actionableCounts"; export type ActionableRow = { channel: ChannelBrief; // Always `channel.snapshot` — kept as a sibling field because every count // helper and every consumer already reads `row.snapshot`. snapshot: ChannelSnapshot | null; }; export type ActionableSummary = { rows: ActionableRow[]; undownloaded: ActionableRow[]; metadataScan: ActionableRow[]; missingNeverFetched: ActionableRow[]; untranscribed: ActionableRow[]; incompleteTranscripts: ActionableRow[]; shortAudio: ActionableRow[]; cleanTranscribedAudio: ActionableRow[]; cleanExtraFormats: ActionableRow[]; digestWarnings: ActionableRow[]; }; // 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 // `buildWidgetActionablePayload`'s (common/views/widgetActionable.ts); this is // the half that knows the availability exclusions, so it stays beside them. // Called by the /api/view/widgetActionable handler AND by // plans/tools/phase3-view-numbers.ts, so the numbers tool measures the mapping // the endpoint actually serves rather than a copy of it. export function widgetActionableRows( rows: readonly ActionableRow[], ): WidgetActionableChannel[] { return rows.map((row) => ({ slug: row.channel.slug, undownloaded: actionableUndownloadedCount(row), untranscribed: actionableUntranscribedCount(row), digestReachable: actionableDigestReachableCount(row), })); } export async function loadActionableSummary( paths: Paths, ): Promise { const channels = await getChannelBriefs(paths); // The brief already read the snapshot; this used to read each one a second // time on top of a full corpus walk. const rows: ActionableRow[] = channels.map((channel) => ({ channel, snapshot: channel.snapshot, })); const undownloaded = rows .filter((r) => actionableUndownloadedCount(r) > 0) .sort( (a, b) => actionableUndownloadedCount(b) - actionableUndownloadedCount(a), ); const missingNeverFetched = rows .filter((r) => actionableMissingNeverFetchedCount(r) > 0) .sort( (a, b) => actionableMissingNeverFetchedCount(b) - actionableMissingNeverFetchedCount(a), ); // Only channels that HAVE a download filter: the scan is useful on any // channel, but it is only work that needs doing on one whose filter is waiting // on titles it has not read. Offering it everywhere would put every channel in // the corpus on this page permanently. const metadataScan = rows .filter( (r) => actionableMetadataScanCount(r) > 0 && Boolean( r.channel.config.downloadFilter?.include?.trim() || r.channel.config.downloadFilter?.exclude?.trim() || r.channel.config.downloadFilter?.includeLivestreams, ), ) .sort((a, b) => actionableMetadataScanCount(b) - actionableMetadataScanCount(a)); const untranscribed = rows .filter((r) => actionableUntranscribedCount(r) > 0) .sort( (a, b) => actionableUntranscribedCount(b) - actionableUntranscribedCount(a), ); const incompleteTranscripts = rows .filter((r) => actionableIncompleteTranscriptCount(r) > 0) .sort( (a, b) => actionableIncompleteTranscriptCount(b) - actionableIncompleteTranscriptCount(a), ); const shortAudio = rows .filter((r) => actionableShortAudioCount(r) > 0) .sort((a, b) => actionableShortAudioCount(b) - actionableShortAudioCount(a)); const cleanTranscribedAudio = rows .filter((r) => actionableCleanTranscribedCount(r) > 0) .sort( (a, b) => actionableCleanTranscribedCount(b) - actionableCleanTranscribedCount(a), ); const cleanExtraFormats = rows .filter((r) => actionableCleanExtraFormatsCount(r) > 0) .sort( (a, b) => actionableCleanExtraFormatsCount(b) - actionableCleanExtraFormatsCount(a), ); const digestWarnings = rows .filter((r) => actionableDigestWarningsCount(r) > 0) .sort( (a, b) => actionableDigestWarningsCount(b) - actionableDigestWarningsCount(a), ); return { rows, undownloaded, metadataScan, missingNeverFetched, untranscribed, incompleteTranscripts, shortAudio, cleanTranscribedAudio, cleanExtraFormats, digestWarnings, }; } // Per-request memoized. The dashboard renders this summary and several // components derived from it in one pass; see ../requestCache for why a // request is the only cache lifetime this data can safely have. export const getActionableSummary = cache((paths: Paths) => loadActionableSummary(paths), );