import path from "node:path"; import fs from "fs-extra"; import type { Paths } from "../lib/paths"; import { mapConcurrent } from "../lib/concurrency"; import { loadSavedVideo } from "../lib/savedVideo-server"; import { savedVideoPath, type SavedVideoPointer } from "../lib/savedVideo"; import { isDriveNotAnswering, onDrive, stalledLocationForPath, } from "../lib/storageHealth"; const { pathExists, readdir, readlink } = fs; // Channels are read a few at a time so the per-video fan-out inside each one // still dominates; the product is the real ceiling on open descriptors. const CHANNEL_CONCURRENCY = 8; const VIDEO_CONCURRENCY = 8; // One persisted source video, located via its data-dir pointer. Shared by the // backup/verify controllers (Phase 4) and the saved-videos UI counts (Phase 5). export type SavedVideoEntry = { slug: string; videoId: string; // The per-video data dir holding the pointer (channels//data/). videoDir: string; // Absolute path to the stored container in the saved-video store. storedPath: string; pointer: SavedVideoPointer; }; async function listChannelSlugs(paths: Paths): Promise { try { const entries = await readdir(paths.channelsDir, { withFileTypes: true }); return entries.filter((e) => e.isDirectory()).map((e) => e.name); } catch { return []; } } // Enumerate every persisted source video, across all channels (or a single // channel when channelSlug is given), by following the saved-video.json pointers // in each data dir. The pointer's `dir` is absolute, so per-channel store // overrides resolve correctly without consulting channel config here. // // `notAnswering`, for a PAGE: pass an array and every channel whose `data/` // links onto a storage location whose drive is not answering // (lib/storageHealth.ts) is skipped — its pointers are one read per video dir, // each of which would wait on the drive — and its slug is pushed there, so the // page can say which channels it did not read. The link is read, not followed: // it is on the corpus disk. A relocated channel's reads then go through // `onDrive`'s watchdog, so a drive that stops answering mid-list is skipped and // named the same way. Omitted (the backup job), nothing is skipped or raced. export async function listSavedVideos({ paths, channelSlug, notAnswering, }: { paths: Paths; channelSlug?: string; notAnswering?: string[]; }): Promise { const slugs = channelSlug ? [channelSlug] : await listChannelSlugs(paths); // One pointer read per video dir — 78,350 of them across the corpus. Done one // at a time inside a nested loop this took seconds; bounded-concurrent it is a // fraction of that, and the cap keeps it from exhausting file descriptors. const perChannel = await mapConcurrent( slugs, CHANNEL_CONCURRENCY, async (slug): Promise => { const dataDir = path.join(paths.channelsDir, slug, "data"); let drive = ""; if (notAnswering) { drive = await readlink(dataDir).catch(() => ""); if (drive && stalledLocationForPath(drive)) { notAnswering.push(slug); return []; } } const through = (call: () => Promise): Promise => drive ? onDrive(drive, call) : call(); try { if (!(await through(() => pathExists(dataDir)))) return []; const ids = await through(() => readdir(dataDir).catch(() => [] as string[]), ); const entries = await mapConcurrent( ids, VIDEO_CONCURRENCY, async (videoId): Promise => { const videoDir = path.join(dataDir, videoId); const pointer = await through(() => loadSavedVideo(videoDir)); if (!pointer) return null; return { slug, videoId, videoDir, storedPath: savedVideoPath(pointer), pointer, }; }, ); return entries.filter((e): e is SavedVideoEntry => e !== null); } catch (err) { if (!notAnswering || !isDriveNotAnswering(err)) throw err; notAnswering.push(slug); return []; } }, ); const out = perChannel.flat(); out.sort( (a, b) => a.slug.localeCompare(b.slug) || a.videoId.localeCompare(b.videoId), ); return out; } export type SavedVideoTotals = { count: number; bytes: number; }; // Aggregate counts/sizes for the saved-video store (or one channel). Cheap: it // reads only the pointers, not the containers themselves. export async function savedVideoTotals(opts: { paths: Paths; channelSlug?: string; notAnswering?: string[]; }): Promise { const entries = await listSavedVideos(opts); let bytes = 0; for (const e of entries) bytes += e.pointer.bytes; return { count: entries.length, bytes }; }