import path from "node:path"; import { readdir } from "node:fs/promises"; import { readChannelConfig } from "./channels"; import { extractVideoId, fetchFlatPlaylistUrls } from "../ytdlp/runYtdlp"; import { diffMaybeMissing, writeMaybeMissing } from "./maybeMissingStore"; import { mergeRosterFile, observationsFromUrls, seedRosterIfAbsent, } from "./rosterStore"; import type { Paths } from "../lib/paths"; // The record itself lives in the dependency-free ./maybeMissingStore so // runYtdlp's full sweep can write it too (this module imports runYtdlp, so the // dependency can't run the other way). Re-exported here because every existing // caller — availabilityActions, verifyBeforeClean, channelSnapshot, buildIndex — // already imports it from this path. export { MAYBE_MISSING_FILENAME, loadMaybeMissing, writeMaybeMissing, type MaybeMissingRecord, } from "./maybeMissingStore"; export type QuickAvailabilityCheckResult = { knownCount: number; freshCount: number; maybeMissingCount: number; ids: string[]; }; // Cheap first pass: fetch the channel's current flat playlist (one yt-dlp // spawn, no per-video metadata) and diff it against the videos we already have // on disk. Any known video absent from the fresh listing is "maybe missing" // (deleted, made private, or unlisted — unlisted videos legitimately drop out // of channel listings). The result is persisted to maybe-missing.json so the // targeted full check can resolve which is which. export async function runQuickAvailabilityCheck({ channelSlug, paths, onLog, signal, }: { channelSlug: string; paths: Paths; onLog?: (msg: string) => void; signal: AbortSignal; }): Promise { const log = onLog ?? ((m: string) => console.log(m)); const config = await readChannelConfig(paths, channelSlug); if (!config?.url) { throw new Error( `Channel "${channelSlug}" has no URL configured; cannot fetch a fresh playlist.`, ); } // Seed the roster before the fetch, from the state that exists right now — // the same protective migration every listing reader performs. const now = new Date().toISOString(); await seedRosterIfAbsent(paths, channelSlug, { now, onLog: (m) => log(m.trimEnd()), }); const freshUrls = await fetchFlatPlaylistUrls({ channelConfig: config, paths, channelSlug, onLog: log, signal, }); // ADD-ONLY. This check computes a missing set, but only the on-disk one — // maybe-missing, whose meaning is unchanged below. It is not a sweep and may // not decide that a roster entry has gone; a full, accepted enumeration is // the only thing allowed to do that (see controller/channelSets.ts). await mergeRosterFile( paths, channelSlug, observationsFromUrls(freshUrls), now, "listing", ); // Canonical, URL-derived ids — these match data-dir names like-for-like on // every platform. Drop nulls (a null would never match and would spuriously // flag every known video). const freshIds = new Set( freshUrls .map((u) => extractVideoId(u)) .filter((id): id is string => Boolean(id)), ); // Known set = data-dir names. A data-dir name *is* the canonical id; the // archive stores yt-dlp native ids which diverge on non-YouTube platforms. const dataDir = path.join(paths.channelsDir, channelSlug, "data"); const entries = await readdir(dataDir, { withFileTypes: true }).catch( () => [] as Awaited> & { length: 0 }, ); const knownIds = (entries as Array<{ isDirectory(): boolean; name: string }>) .filter((e) => e.isDirectory()) .map((e) => e.name); const maybeMissing = diffMaybeMissing(knownIds, freshIds); await writeMaybeMissing(paths, channelSlug, { checkedAt: new Date().toISOString(), freshPlaylistCount: freshIds.size, ids: maybeMissing, }); log( `Quick availability: ${knownIds.length} known, ${freshIds.size} in fresh playlist, ${maybeMissing.length} maybe-missing.`, ); return { knownCount: knownIds.length, freshCount: freshIds.size, maybeMissingCount: maybeMissing.length, ids: maybeMissing, }; }