// What one finished auto-queue unit does to the lane's persisted pacing state — // the per-platform backoff and the per-video deferral. Pure: no I/O, `now` and // `rand` injected, so the whole decision is unit-testable (the runner's loop // that calls it is not exported). autoRunner.ts calls it once per unit, logs the // line, retires the video when told to, and persists. // // - `rate_limit` (HTTP 429 / throttle): the platform backs off exponentially // (nextBackoff) AND the video is deferred for VIDEO_RATE_LIMIT_DEFER_MS, so // after the cooldown the runner moves on to the NEXT video instead of // re-picking the same one. A 429 is per video on YouTube (the subtitle // fetch), and with `order: "listed"` the same video sits at the head of the // queue: without the deferral one video climbs `fails` to the 30-minute cap // and holds the whole platform (2026-09-24, one Short retried 12x). // - `network`: backoff only — the video is retried after the cooldown (today's // behaviour; a network error says nothing about the video). // - either: the video is NOT retired for the session (`markCompleted: false`). // - anything else retires it; a success (`transcribed`) also clears the // platform's cooldown. // - every branch prunes lapsed deferrals, keeping the map bounded. // // Release 17, slice RL (see platformBackoff.ts, "The adaptive pace"): // - `rate_limit` also doubles the platform's pace, and a backoff that has // failed at the cap `pacing.holdAfterFailsAtCap` times HOLDS the platform: // its next try is a probe `pacing.holdProbeMinutes` away. // - `subs_rate_limit` (the subtitle fetch alone answered 429 and the media came // down) retires the video like any success and defers its SUBTITLES — the // platform's backoff and pace are not touched; on a HELD platform it is the // clean probe that lifts the hold (review L1). // - the pace eases one step per hour with no rate limit (decayPaceByTime). // - a clean unit (`transcribed` with no subtitle 429) clears the backoff and // any hold — a clean probe is what lifts one — and counts toward the pace's // decay. import type { DownloadFailureClass } from "../lib/availability"; import { type PacingSettings, type PlatformBackoffState, type PlatformHoldState, type PlatformPaceState, type SubtitleDeferralState, type VideoDeferralState, SUBTITLE_HOLD_AFTER, decayPaceByTime, deferSubtitles, deferVideo, isPlatformHeld, escalatePlatform, pruneDeferred, pruneSubtitleDeferrals, settlePlatformClean, } from "./platformBackoff"; import { staticSleepRequestsSeconds } from "../ytdlp/platformArgs.mjs"; export type UnitOutcomeState = { platformBackoff: PlatformBackoffState; videoDeferrals: VideoDeferralState; platformPace: PlatformPaceState; platformHolds: PlatformHoldState; subtitleDeferrals: SubtitleDeferralState; }; export type FinishedUnit = { // The download platform the unit ran against; null on a non-download lane. platform: string | null; videoId: string; channelSlug: string; outcome: "transcribed" | "skipped" | "failed"; failureClass?: DownloadFailureClass; }; export type UnitOutcomeEffect = { // Retire the video for the session (the caller still skips this on an // operation lane, which retires (operation, video) pairs itself). markCompleted: boolean; // The line to log, or null when there is nothing to say. line: string | null; }; export function applyUnitOutcome( state: UnitOutcomeState, unit: FinishedUnit, now: number, rand: () => number = Math.random, pacing?: PacingSettings, ): UnitOutcomeEffect { pruneDeferred(state.videoDeferrals, now); pruneSubtitleDeferrals(state.subtitleDeferrals, now); decayPaceByTime(state.platformPace, now); const pf = unit.platform; if ( pf !== null && (unit.failureClass === "rate_limit" || unit.failureClass === "network") ) { const fx = escalatePlatform(state, pf, unit.failureClass, now, { baseSeconds: staticSleepRequestsSeconds(pf), rand, ...(pacing ? { pacing } : {}), }); const entry = fx.entry; const secs = Math.round((entry.until - now) / 1000); const head = fx.held ? `Auto-download: ${pf} ${unit.failureClass} — ${fx.enteredHold ? "held" : "still held"} after ${entry.fails} failures in a row; next probe in ${secs}s.` : `Auto-download: ${pf} ${unit.failureClass} — backing off ${secs}s (attempt ${entry.fails}).`; const pace = unit.failureClass === "rate_limit" ? ` Pace now ${fx.paceSeconds}s between requests.` : ""; if (unit.failureClass === "rate_limit") { deferVideo(state.videoDeferrals, unit.videoId, unit.channelSlug, now); return { markCompleted: false, line: `${head} ${unit.videoId} deferred 6h; next video after cooldown.${pace}`, }; } return { markCompleted: false, line: `${head} ${unit.videoId} will retry after cooldown.`, }; } if (unit.failureClass === "subs_rate_limit") { // The media came down; only the subtitle fetch was refused. Not a platform // signal (the timedtext 429 is per video): nothing platform-wide moves. const d = deferSubtitles(state.subtitleDeferrals, unit.videoId, unit.channelSlug, now); const left = d.count >= SUBTITLE_HOLD_AFTER ? "left alone for 7 days" : "deferred 6h for download-missing-subs"; // A HELD platform's probe whose media came down answered on every // platform-level request: that is the clean probe the hold waits for // (review L1). It does not count toward the pace's easing. if (pf !== null && isPlatformHeld(state.platformHolds, pf)) { settlePlatformClean(state, pf, { baseSeconds: staticSleepRequestsSeconds(pf), countForDecay: false, now, ...(pacing ? { pacing } : {}), }); return { markCompleted: true, line: `Auto-download: ${unit.videoId} downloaded; its subtitles were rate-limited (${d.count}×) — ${left}. The ${pf} probe's media came down: the hold and the backoff are cleared.`, }; } return { markCompleted: true, line: `Auto-download: ${unit.videoId} downloaded; its subtitles were rate-limited (${d.count}×) — ${left}. ${pf ?? "the platform"} is not backed off.`, }; } if (pf !== null && unit.outcome === "transcribed") { const fx = settlePlatformClean(state, pf, { baseSeconds: staticSleepRequestsSeconds(pf), now, ...(pacing ? { pacing } : {}), }); if (fx.releasedHold) { return { markCompleted: true, line: `Auto-download: ${pf} probe came back clean — the hold and the backoff are cleared.`, }; } if (fx.decayed) { return { markCompleted: true, line: `Auto-download: ${pf} pace eased to ${fx.paceSeconds}s between requests.`, }; } } return { markCompleted: true, line: null }; }