// Client-safe types and constants for the per-video download outcome sidecar. // Mirrors availability.ts: server-only I/O lives in downloadOutcome-server.ts. import type { Availability, DownloadFailureClass } from "./availability"; import type { ChannelHandling } from "./channelConfig"; export type DownloadOutcomeStatus = | "ok" | "ok-with-cookies" | "ok-auto-transcribed" | "ok-audio-checked" | "failed" | "failed-corrupt-source" // A download that COMPLETED (yt-dlp exit 0, all bytes) but whose final audio // integrity probe is malformed even after one re-download. Terminal and // NOT retried: re-downloading a complete file yields identical bytes. The // downloaded container is KEPT on disk for inspection. Distinct from // failed-corrupt-source, which means the download never finished. | "corrupt-full-source" // A download that COMPLETED (yt-dlp exit 0) but whose audio is far shorter // than the video's metadata duration — the source served a truncated stream // (e.g. a CDN-truncated HLS rung). Caught by the download-time duration guard // BEFORE transcription, so whisper never runs on the stub. The short audio is // KEPT on disk (so it isn't silently re-downloaded into a loop) and surfaced // for a manual re-download with a different format. See `shortAudio` below. | "failed-short-audio" // The app-level filter pass (e.g. skip-live) declined to download this video. // Not a failure and not archived — the next sync/download-missing retries it // once the filter no longer matches (e.g. a live stream becomes a VOD). | "skipped-filtered" // The download filter rejected this livestream and the channel's // `rejectedLivestreams` mode is "chat-only": the MEDIA was not fetched, the // live chat was. The directory holds metadata.info.json, // transcript.live_chat.json and its live_chat.cues.json (plus this sidecar // and download.log) — no media and no transcript — so the video is indexable // as a chat track with no captions while every "is it downloaded?" predicate // — all of which test whisper/VTT/audio — still answers no. Terminal on the operator's // terms, like skipped-filtered, and equally re-decided by editing the filter. | "chat-only"; export const DOWNLOAD_OUTCOME_STATUS_VALUES: ReadonlyArray = [ "ok", "ok-with-cookies", "ok-auto-transcribed", "ok-audio-checked", "failed", "failed-corrupt-source", "corrupt-full-source", "failed-short-audio", "skipped-filtered", "chat-only", ]; export type DownloadAttemptKind = | "primary" | "auth-retry" | "no-subs-fallback" | "audio-checked-primary" // The metadata-only pass that runs before the real download so app-level // filters can decide, and so the download can reuse it via --load-info-json. | "metadata-prefetch" // A cookie re-run of a metadata prefetch that failed with an auth/age error // (cookie mode "always"/"when-required" with a cookie value configured). // Recorded with n: 0 alongside the failed prefetch it retries. | "metadata-prefetch-auth-retry" // The live-chat pass a "chat-only" filter verdict runs INSTEAD of a download: // --skip-download --write-subs --sub-langs live_chat, no media, no archive // line. Recorded with n: 1, since it is the only real attempt there is. | "live-chat-only" // The primary again, with every subtitle refused, after a primary that // failed ONLY on its subtitle fetch (a 429): the media is what the download // is for, and the subtitles are deferred (release 17, slice RL). Recorded // with n: 1 beside the primary it re-runs. | "primary-without-subs" // An archive.org record's file fetched without yt-dlp // (controller/archiveOrgDownload.ts): over BitTorrent with aria2c, or as a // plain download from archive.org. `ytdlpExitCode` is 0 for a fetch that // delivered a verified file and 1 for one that did not. | "archiveorg-torrent" | "archiveorg-direct"; export type AudioCheckProbeVerdict = "clean" | "partial" | "malformed"; export type AudioCheckAttemptStats = { checkpoints: number; rollbacks: number; restarts: number; finalProbeVerdict?: AudioCheckProbeVerdict; }; export type DownloadAttempt = { // 0 = the metadata-prefetch pass; 1..3 = the real download attempts. n: number; kind: DownloadAttemptKind; handling: ChannelHandling; usedCookies: boolean; ytdlpExitCode: number | null; availabilityClass?: Availability; error?: string; audioCheck?: AudioCheckAttemptStats; }; export type DownloadOutcomeRecord = { videoId: string; webpageUrl?: string; status: DownloadOutcomeStatus; startedAt: string; finishedAt: string; attempts: DownloadAttempt[]; // Set on a failed download: the failure classified against the FULL stderr // tail of the last attempt (not the truncated per-attempt `error`), so a // rate-limit that yt-dlp logs as a WARNING before failing with a different // final line (e.g. Odysee "HTTP Error 429" → "No video formats found") is // still recognised. Drives the auto-runner's per-platform backoff. Undefined // on success / skipped-filtered — except `subs_rate_limit`, which is set on a // SUCCESS whose subtitle fetch alone answered 429: the media came down, and // the video's subtitles are deferred (release 17, slice RL). failureClass?: DownloadFailureClass; fellBackToTranscribe?: boolean; // Set when status is "failed-short-audio": the measured shortfall, so the UI // can explain it (e.g. "4 min of 114 min") without re-probing. shortAudio?: { audioDurationSec: number; expectedDurationSec: number; coverage: number; }; // Set when status is "skipped-filtered": which app-level filter declined the // download and why. Recorded so the UI/log can explain the skip. // // DELIBERATELY CARRIES NO VERDICT. A filter skip is never itself terminal: // whether a video is SETTLED by the channel's download filter is derived from // the metadata-scan store plus the channel's current patterns // (controller/metadataScanStore.ts:settledByTitleFilterIds), never stored per // video. Writing the verdict here would mean editing a regex has to find and // rewrite ~1,800 sidecars before the change takes effect. filter?: { name: string; reason: string }; }; export const DOWNLOAD_OUTCOME_FILENAME = "download-outcome.json";