// Fetch ONE window of a video's source media into the corpus, politely. // // This is the managed replacement for umtool running yt-dlp itself. The // operator's rule is that no fetch happens by hand: a window pulled from here // inherits the channel's cookie policy and its `ytdlpExtraArgs`, the // per-platform 429 cooldown, the auth retry, and the job log every other // download writes. // // WHAT IT DELIBERATELY DOES NOT DO: write metadata.info.json. See // lib/clipWindow.ts — the index keys a video's presence on that file, so a // thirty-second window must not create one. That is why the argv is assembled // HERE, in full, instead of going through outputArgsForUrl or // downloadOneManaged's prefetch. import path from "node:path"; import { mkdir, readdir, rename, rm, stat } from "node:fs/promises"; import { AUTH_RETRY_CLASSES, classifyDownloadFailure, parseUnavailableFromStderr, type Availability, type DownloadFailureClass, } from "../lib/availability"; import { DEFAULT_COOKIE_MODE, alwaysCookies, authRetryCookies, type ResolvedCookiePolicy, } from "../lib/cookiePolicy"; import type { ChannelConfig } from "../lib/channelConfig"; import { detectPlatform } from "../lib/platform"; import type { Paths } from "../lib/paths"; import { clipsDirFor, clipWindowFile, clipWindowName, type ClipProvenance, } from "../lib/clipWindow"; import { clipWindowPath, findContainingClipWindow, writeClipProvenance, type ClipWindow, } from "../lib/clipWindow-server"; import { channelExtraArgs, configForVideoUrl } from "./channelArgs"; import { runOneYtdlp } from "./runOneYtdlp"; import { FULL_LOG_PROGRESS_ARGS } from "./downloadOneManaged"; import { clipFormatSelector } from "./downloadFormat"; // The source height a clip is worth fetching at. 720 is umtool's number and the // reason is the render: the deliverable is 1080p with a clip inset, so pixels // above 720 are thrown away after paying for them. export const DEFAULT_CLIP_MAX_HEIGHT = 720; // Rumble serves HLS whose segments are named `.tar`, and ffmpeg 8 refuses them // ("URL … is not in allowed_segment_extensions", exit 183) — every Rumble window // failed. `-extension_picky 0` lets them through, but it is an option of the HLS // DEMUXER: against a progressive URL (YouTube's googlevideo mp4) ffmpeg aborts // with "Option extension_picky not found". So it is a RETRY on exactly that // refusal — umtool's build-video.mjs does the same (umtool/docs/quirks.md) — // EXCEPT on a rumble.com URL, where it goes on the first try: every attempt // loads the Rumble page again, and Cloudflare 403s some share of those loads, // so a retry every Rumble window needs doubled the refusals. An old Rumble // upload served progressive is the mirror case, retried once without it. export const HLS_EXTENSION_REFUSED = /allowed_segment_extensions|allowed_extensions/; export const HLS_PICKY_REFUSED = /Option extension_picky not found/; export const HLS_PICKY_RETRY_ARGS = ["--downloader-args", "ffmpeg_i:-extension_picky 0"]; // The clip format selector lives with the download presets (the "video_720" // preset is built from it); re-exported so existing importers keep working. export { clipFormatSelector }; // A fetch that yt-dlp refused, with the refusal already classified. The // single-window job only shows the message; the batch (controller/fetchWindows) // reads `failureClass` to decide between carrying on, backing the platform off // and stopping — without parsing the message a second time. export class FetchWindowError extends Error { constructor( message: string, readonly failureClass: DownloadFailureClass, readonly availability: Availability, ) { super(message); this.name = "FetchWindowError"; } } export type FetchWindowProvenance = { requestedBy: string; manifest?: string; clipId?: string; reason?: string; pad?: number; requestedAt?: string; }; export type FetchWindowOpts = { channelSlug: string; channelConfig: ChannelConfig; paths: Paths; // channels//data/ — the window lands in its `clips/` subdirectory. videoDir: string; videoId: string; videoUrl: string; // Working directory for the yt-dlp process. Nothing it writes is // cwd-relative (`-o` is absolute and `--ignore-config` stops the operator's // own config redirecting anything), so this only decides where yt-dlp's own // scratch and any future cwd-relative trace land. The editor passes the // channel root, which is where every other invocation in this repo runs. cwd?: string; from: number; to: number; provenance: FetchWindowProvenance; cookiePolicy?: ResolvedCookiePolicy; maxHeight?: number; onLog: (s: string) => void; signal: AbortSignal; // Called when the failure classifies as a platform-level signal (a 429 or a // bot check). The caller owns the platform key, so it owns the cooldown. onPlatformBackoff?: ( failureClass: "rate_limit", ) => Promise | void; }; export type FetchWindowResult = { // Absolute path to the mp4 holding the window. file: string; // The window the returned FILE holds, which on a cache hit is WIDER than the // one asked for. Every consumer expresses cuts relative to it, so returning // the request instead would seek a caller into the wrong seconds. from: number; to: number; bytes: number; cached: boolean; provenance: ClipProvenance | null; }; // The argv a window was fetched with stays in the SIDECAR and does not ride // back out over HTTP: it is a debugging record for whoever is standing at the // disk, and it names this instance's cookie browser and the operator's own // extra args. A caller asking "is this cached" does not need either. function withoutArgs(p: ClipProvenance | null): ClipProvenance | null { if (!p?.ytdlp) return p; const { ytdlp: _ytdlp, ...rest } = p; return rest; } function cachedResult(hit: ClipWindow): FetchWindowResult { return { file: hit.path, from: hit.from, to: hit.to, bytes: hit.bytes, cached: true, provenance: withoutArgs(hit.provenance), }; } export async function fetchWindowManaged( opts: FetchWindowOpts, ): Promise { const { from, to, videoDir } = opts; const policy: ResolvedCookiePolicy = opts.cookiePolicy ?? { cookies: opts.channelConfig.cookiesFromBrowser, mode: DEFAULT_COOKIE_MODE, }; // ASK THE CACHE FIRST, and accept a WIDER file. A report cites the same // stream more than once; without containing-window reuse a generous fetch for // one clip is worthless to the neighbour it already covers. const hit = await findContainingClipWindow(videoDir, from, to); if (hit) { opts.onLog( `Window ${from.toFixed(2)}–${to.toFixed(2)} is already covered by ` + `clips/${hit.file}; nothing to fetch.\n`, ); return cachedResult(hit); } const clipsDir = clipsDirFor(videoDir); await mkdir(clipsDir, { recursive: true }); const dest = clipWindowPath(videoDir, from, to); // `-.part.mp4`, and BOTH halves of that name matter. // // `.part` in the middle is what stops a half-written window being read as a // finished one: parseClipWindowName anchors on `-` and the extra // segment fails it, so the listing skips the file until the rename. // // `.mp4` on the END is for yt-dlp, which infers the container from the output // extension. Given a name ending in `.part` it either refuses the merge or // appends the real extension itself — the stray-file case below — and the // whole point of --merge-output-format here is to avoid the VP9/webm trap. const part = path.join(clipsDir, `${clipWindowName(from, to)}.part.mp4`); await rm(part, { force: true }); const maxHeight = opts.maxHeight ?? DEFAULT_CLIP_MAX_HEIGHT; const argsWith = (cookies: string | undefined, retryArgs: string[] = []): string[] => [ // The operator's own yt-dlp config redirects output and attaches thumbnail // and metadata post-processors; without this the window lands elsewhere — // and a metadata post-processor is exactly what must not run here. "--ignore-config", "--no-playlist", // One request per second, the politeness this repo applies everywhere it // touches a source. "--sleep-requests", "1", "--download-sections", `*${from.toFixed(2)}-${to.toFixed(2)}`, // Without this the cut snaps to the nearest preceding keyframe, which can // be seconds early — fine for scrubbing, not fine when the clip IS the // citation. "--force-keyframes-at-cuts", "-f", clipFormatSelector(maxHeight), "--merge-output-format", "mp4", ...FULL_LOG_PROGRESS_ARGS, // The video's URL picks the platform args: a channel may hold another // platform's videos (configForVideoUrl). ...channelExtraArgs(configForVideoUrl(opts.channelConfig, opts.videoUrl), cookies), // THE NEGATIONS COME AFTER THE CHANNEL'S OWN ARGS, AND -o AFTER THOSE. // // `ytdlpExtraArgs` is free text an operator typed into a form; it is // validated as strings and nothing more. A channel carrying // `--write-info-json` (or --write-thumbnail / --write-description / // --write-subs / --download-archive / a second -o) would, appended after // the argv above, make a WINDOW fetch write metadata.info.json into an // undownloaded video's dir — and the index keys a video's presence on // exactly that file (see lib/clipWindow.ts). yt-dlp takes the LAST // occurrence of an option, so the only reliable place for the refusal is // after the operator's args, and for the output path likewise. // // This does not take the operator's settings away: --limit-rate, // --proxy, --extractor-args and the rest still apply. It refuses exactly // the six that would write a sidecar this path must never write. "--no-write-info-json", "--no-write-description", "--no-write-thumbnail", "--no-write-subs", "--no-write-auto-subs", "--no-download-archive", ...retryArgs, "-o", part, "--", opts.videoUrl, ]; const run = async (cookies: string | undefined, retryArgs: string[] = []) => runOneYtdlp( { ytdlpBin: opts.paths.ytdlpBin, onLog: opts.onLog, signal: opts.signal, }, opts.cwd ?? videoDir, argsWith(cookies, retryArgs), ); let cookiesUsed = alwaysCookies(policy); let picky = detectPlatform(opts.videoUrl) === "rumble" ? HLS_PICKY_RETRY_ARGS : []; let args = argsWith(cookiesUsed, picky); let outcome = await run(cookiesUsed, picky); let backedOff = false; if (outcome.exitCode !== 0) { const availability = parseUnavailableFromStderr(outcome.stderrTail); const failure = classifyDownloadFailure(outcome.stderrTail, availability); if (failure === "rate_limit") { // The 429 / bot-check cooldown the auto-download runner and a clicked // Sync both honour. Recorded before the throw so the NEXT request is // refused at the door rather than re-storming the source. await opts.onPlatformBackoff?.("rate_limit"); backedOff = true; } else if (AUTH_RETRY_CLASSES.has(availability)) { const retryCookies = authRetryCookies(policy); if (retryCookies) { opts.onLog( `Window fetch failed with ${availability}; retrying once with cookies.\n`, ); cookiesUsed = retryCookies; args = argsWith(retryCookies, picky); outcome = await run(retryCookies, picky); } } } if (outcome.exitCode !== 0 && !picky.length && HLS_EXTENSION_REFUSED.test(outcome.stderrTail)) { opts.onLog( `Window fetch: ffmpeg refused the HLS segment extension; retrying once with -extension_picky 0.\n`, ); await rm(part, { force: true }); picky = HLS_PICKY_RETRY_ARGS; args = argsWith(cookiesUsed, picky); outcome = await run(cookiesUsed, picky); } else if (outcome.exitCode !== 0 && picky.length && HLS_PICKY_REFUSED.test(outcome.stderrTail)) { opts.onLog( `Window fetch: the source is not HLS after all; retrying once without -extension_picky.\n`, ); await rm(part, { force: true }); picky = []; args = argsWith(cookiesUsed, picky); outcome = await run(cookiesUsed, picky); } if (outcome.exitCode !== 0) { await rm(part, { force: true }); const tail = outcome.stderrTail.trim().split("\n").slice(-4).join(" / "); // Classified from the LAST attempt, which is the one that decided. const availability = parseUnavailableFromStderr(outcome.stderrTail); const failureClass = classifyDownloadFailure(outcome.stderrTail, availability); // A cookie retry that ran into a 429 is a 429 like any other. if (failureClass === "rate_limit" && !backedOff) { await opts.onPlatformBackoff?.("rate_limit"); } throw new FetchWindowError( `yt-dlp failed fetching ${from.toFixed(2)}–${to.toFixed(2)} of ` + `${opts.videoId} (exit ${outcome.exitCode ?? "null"}): ${tail}`, failureClass, availability, ); } // A fallback branch of the selector can still force another container, in // which case yt-dlp writes ".". Adopt it rather than failing a // download that actually happened. let produced = part; let st = await stat(part).catch(() => null); if (!st?.isFile()) { const base = path.basename(part); const stray = (await readdir(clipsDir).catch(() => [] as string[])).find( (f) => f.startsWith(`${base}.`), ); if (!stray) { throw new Error( `yt-dlp reported success but produced no file for ` + `${from.toFixed(2)}–${to.toFixed(2)} of ${opts.videoId}`, ); } produced = path.join(clipsDir, stray); st = await stat(produced); } await rename(produced, dest); const provenance: ClipProvenance = { requestedBy: opts.provenance.requestedBy, ...(opts.provenance.manifest ? { manifest: opts.provenance.manifest } : {}), ...(opts.provenance.clipId ? { clipId: opts.provenance.clipId } : {}), ...(opts.provenance.reason ? { reason: opts.provenance.reason } : {}), requestedAt: opts.provenance.requestedAt ?? new Date().toISOString(), ...(typeof opts.provenance.pad === "number" ? { pad: opts.provenance.pad } : {}), bytes: st.size, fetchedAt: new Date().toISOString(), ytdlp: { args }, }; await writeClipProvenance(videoDir, from, to, provenance); opts.onLog( `Fetched ${from.toFixed(2)}–${to.toFixed(2)} of ${opts.videoId} into ` + `clips/${clipWindowFile(from, to)} (${st.size} bytes).\n`, ); return { file: dest, from, to, bytes: st.size, cached: false, provenance: withoutArgs(provenance), }; }