import path from "node:path"; import type { Paths } from "../lib/paths"; import { isPermanentlyGone, type Availability } from "../lib/availability"; import { resolveEffectiveAvailability } from "../lib/availability-server"; import { loadDoNotClean, setDoNotClean } from "../lib/doNotClean-server"; import { readChannelConfig } from "./channels"; import { runAvailabilityCheck } from "./checkAvailability"; import { runQuickAvailabilityCheck } from "./quickAvailabilityCheck"; // Availability gate for the transcribed-audio cleanup sweep. // // Cleaning deletes a video's source audio irreversibly (no trash, no undo), so // a video that has since been removed / privated / put behind a membership must // NOT be cleaned: our copy has become the only copy. This module decides, per // candidate, whether the sweep is allowed to delete. // // Three tiers, cheapest first, so a typical run costs ONE yt-dlp spawn per // channel plus a handful for suspects — not one spawn per cleanable video: // // A. cached verdict resolveEffectiveAvailability() 0 spawns // Already-known-gone videos are pinned without touching the network. This // is also the only tier that catches members_only: a members-only video // stays listed in its channel's flat playlist, so tier B never flags it. // B. fresh listing runQuickAvailabilityCheck() 1 spawn/channel // Candidates absent from the channel's current flat playlist are suspects. // C. confirm suspects runAvailabilityCheck(onlyIds) 1 spawn/suspect // Resolves deleted/private (pin) from unlisted (clean — an unlisted video // legitimately drops out of the listing but still resolves by URL). // // Anything the gate cannot resolve is `unverified`: skipped, NO marker written, // retried on the next sweep. The sweep never deletes on incomplete information. export type CleanVerdicts = { // Newly marked do-not-clean this run (gone from source). pinned: Map; // Gone from source but a marker was already present — left untouched so a // hand-written note survives. Structurally rare: the sweep filters marked // videos out before the gate runs. alreadyGone: Set; // Probe inconclusive (error / needs_auth / never resolved). Skip, no marker. unverified: Set; }; export type VerifyBeforeCleanOptions = { channelSlug: string; paths: Paths; candidateIds: string[]; onLog?: (msg: string) => void; signal?: AbortSignal; }; function emptyVerdicts(): CleanVerdicts { return { pinned: new Map(), alreadyGone: new Set(), unverified: new Set() }; } // Every id the sweep must NOT delete, across all three verdict kinds. export function excludedIds(verdicts: CleanVerdicts): Set { return new Set([ ...verdicts.pinned.keys(), ...verdicts.alreadyGone, ...verdicts.unverified, ]); } export async function verifyBeforeClean({ channelSlug, paths, candidateIds, onLog, signal, }: VerifyBeforeCleanOptions): Promise { const log = onLog ?? ((m: string) => console.log(m)); const verdicts = emptyVerdicts(); if (candidateIds.length === 0) return verdicts; const dataDir = path.join(paths.channelsDir, channelSlug, "data"); // Pin a video as do-not-clean, preserving any marker (and note) already there. const pin = async (id: string, availability: Availability): Promise => { const videoDir = path.join(dataDir, id); if (await loadDoNotClean(videoDir)) { verdicts.alreadyGone.add(id); return; } await setDoNotClean( videoDir, true, `deleted from source (pre-clean check): ${availability}`, ); verdicts.pinned.set(id, availability); log(`Pinned ${id} as do-not-clean (${availability}) — gone from source.`); }; // --- Tier A: cached verdicts, no spawns ----------------------------------- const remaining: string[] = []; for (const id of candidateIds) { if (signal?.aborted) return verdicts; const availability = await resolveEffectiveAvailability( path.join(dataDir, id), ); if (isPermanentlyGone(availability)) { await pin(id, availability); continue; } remaining.push(id); } if (remaining.length === 0 || signal?.aborted) return verdicts; // --- Tier B: one flat-playlist spawn -------------------------------------- // A channel with no URL is structurally unverifiable — there is no source to // diff against and there never will be. Fail OPEN: cleaning an imported or // hand-built archive must not require flipping a global setting off. const config = await readChannelConfig(paths, channelSlug); if (!config?.url) { log( `Verify before clean: ${channelSlug} has no URL — no source to check against; cleaning the remaining ${remaining.length} candidate(s) unverified.`, ); return verdicts; } // Ids known on disk but ABSENT from the channel's current flat playlist. let maybeMissing: Set; try { const quick = await runQuickAvailabilityCheck({ channelSlug, paths, onLog: log, // runQuickAvailabilityCheck requires a signal; the sweep's is optional. signal: signal ?? new AbortController().signal, }); maybeMissing = new Set(quick.ids); } catch (err) { // We know this channel is checkable and we got no answer (network, rate // limit, extractor break). Fail CLOSED — decision #4 at channel granularity. const reason = (err as Error)?.message ?? String(err); for (const id of remaining) verdicts.unverified.add(id); log( `Verify before clean: could not verify availability (${reason}) — nothing cleaned this run.`, ); return verdicts; } const suspects = remaining.filter((id) => maybeMissing.has(id)); if (suspects.length === 0) { log( `Verify before clean: all ${remaining.length} candidate(s) still listed on the source.`, ); return verdicts; } if (signal?.aborted) return verdicts; // --- Tier C: one spawn per suspect ---------------------------------------- log( `Verify before clean: ${suspects.length} candidate(s) missing from the fresh listing — confirming individually…`, ); let check: Awaited>; try { check = await runAvailabilityCheck({ channelSlug, paths, mode: "recheck-all", onlyIds: suspects, // Tier A already pinned everything known-gone; belt and braces. skipExpectedAbsent: true, // `suspects` is the authority — without this a saved shard-availability // slice on disk would replace it wholesale (see CheckAvailabilityOpts). ignoreShard: true, // Deliberately 1. The clean-audio job runs on the CHANNEL queue while the // availability actions run on the PLATFORM queue precisely so probes share // the per-source rate-limit budget with downloads. Probing from in here // bypasses that serialization, so keep the extra load to a trickle — do // NOT "optimize" this upward. concurrency: 1, onLog: log, signal, }); } catch (err) { const reason = (err as Error)?.message ?? String(err); for (const id of suspects) verdicts.unverified.add(id); log( `Verify before clean: confirmation probe failed (${reason}) — leaving ${suspects.length} candidate(s) unverified.`, ); return verdicts; } // NOTHING THE CHECK DID NOT PROBE IS JUDGED (release 10, L2 review). A // suspect the check never asked about still has whatever `availability.json` // it already had — which can be months old and say `public`. Read below, // that stale record would clear the video for an irreversible delete. So // every suspect missing from `probedIds` is `unverified`: skipped, no // marker, retried on the next sweep. Two ways to be missing: the check // stopped at a rate-limited probe before reaching it (`blocked`), or it was // skipped — no `webpage_url` in its metadata, so there was nothing to probe. // The second was judged on its old record before this; no video on the live // corpus was in that state (2026-09-26 review scan), and none can be now. const probed = new Set(check.probedIds); const unprobed = new Set(suspects.filter((id) => !probed.has(id))); if (unprobed.size > 0) { log( (check.blocked ? `Verify before clean: the source rate-limited the confirmation — ` : `Verify before clean: the confirmation could not probe every suspect — `) + `${unprobed.size} suspect(s) it did not reach are left unverified, not judged on an old record.`, ); } for (const id of suspects) { if (signal?.aborted) break; if (unprobed.has(id)) { verdicts.unverified.add(id); continue; } const availability = await resolveEffectiveAvailability( path.join(dataDir, id), ); if (isPermanentlyGone(availability)) { await pin(id, availability); continue; } // null = the probe never resolved this video (no webpage_url to probe, or // it was skipped). That is not "available" — it is "we don't know", which // is exactly the case this gate exists to refuse to delete on. if (availability === null) { verdicts.unverified.add(id); log(`Left ${id} unverified (no availability could be resolved).`); continue; } if (availability === "error" || availability === "needs_auth") { verdicts.unverified.add(id); log(`Left ${id} unverified (${availability}) — will retry next sweep.`); continue; } // public / unlisted → still there, safe to clean. } return verdicts; }