import type { RosterSweep, SweepVerdict } from "./rosterStore"; // The gate every full enumeration passes through before it is allowed to // replace what we know about a channel. // // With the roster in place a bad fetch is already non-destructive — nothing is // ever removed from roster.json — so this guard's remaining job is narrow and // worth stating exactly: don't truncate the stored `playlist`, and don't flag // thousands of videos as missing, on the strength of one suspicious read. // // Part A only rejected a COMPLETELY empty listing. A fetch that returns 100 of // 10,795 entries — a throttled connection, a partial page walk, an expiring // cookie — sailed through and destroyed 10,695 entries. // // Two-observation confirmation is the whole design: a real mass deletion // repeats, a transient blip does not. It needs no UI, no manual override, and // costs at most one cadence period to accept a genuine deletion. // The floor matters as much as the percentage. A pure percentage trips on small // channels for ordinary churn: the live corpus has a channel with 55 listed // videos, where losing six is a 10.9% drop and entirely unremarkable. export const SHRINK_ABS_FLOOR = 25; export type ListingDecision = { // Whether the caller may act on this listing: rewrite `playlist`, rewrite // maybe-missing.json, and stamp lastFullSweepAt. accept: boolean; verdict: SweepVerdict; // One line for the job log, naming the numbers that drove the decision. reason: string; }; // How much shrink is tolerated against a reference count. Shared by the // accept test and the "is this the same reading as last time" test so the two // can't drift apart. function tolerance(reference: number, percent: number): number { return Math.max(SHRINK_ABS_FLOOR, Math.floor((reference * percent) / 100)); } export function acceptListing(input: { // Entry count of the enumeration just fetched. listedCount: number; // Size of the last ACCEPTED listing — in practice the stored `playlist` // file's length. 0 or null means there is nothing to compare against. previousListedCount: number | null; // The previous OBSERVATION, accepted or not (roster.lastSweep). This is what // makes the second reading confirmatory rather than just another suspect. lastSweep: RosterSweep | null; // syncScheduler.fullSweepShrinkGuardPercent. 0 disables the shrink test (the // empty-listing rejection is not optional). shrinkGuardPercent: number; }): ListingDecision { const { listedCount, previousListedCount, lastSweep, shrinkGuardPercent } = input; // An empty listing is never trustworthy enough to act on: it is what a // transient upstream failure, a cookie expiry and a clean 101 exit all look // like from here. if (listedCount <= 0) { return { accept: false, verdict: "empty", reason: "the channel listing came back empty — leaving the stored playlist and missing-video flags untouched, and not counting this as a sweep", }; } const previous = previousListedCount ?? 0; if (previous <= 0) { return { accept: true, verdict: "ok", reason: `${listedCount} listed, no previous listing to compare against`, }; } if (shrinkGuardPercent <= 0) { return { accept: true, verdict: "ok", reason: `${listedCount} listed (shrink guard off)`, }; } const dropped = previous - listedCount; const allowed = tolerance(previous, shrinkGuardPercent); if (dropped <= allowed) { return { accept: true, verdict: "ok", reason: `${listedCount} listed, was ${previous}`, }; } // A big drop. Accept it only on the SECOND consecutive reading that says // roughly the same thing. if (lastSweep && lastSweep.verdict === "shrink-suspect") { const reference = Math.max(listedCount, lastSweep.listedCount); const delta = Math.abs(listedCount - lastSweep.listedCount); if (delta <= tolerance(reference, shrinkGuardPercent)) { return { accept: true, verdict: "shrink-confirmed", reason: `${listedCount} listed, down from ${previous} — confirmed by a second enumeration (previous reading ${lastSweep.listedCount}), accepting the drop`, }; } } return { accept: false, verdict: "shrink-suspect", reason: `the listing came back with ${listedCount} entries, down ${dropped} from ${previous} (more than the ${allowed} this guard allows) — leaving the stored playlist and missing-video flags untouched, and not counting this as a sweep. A real deletion repeats: if the next enumeration agrees, it will be accepted`, }; }