// ONE yt-dlp invocation, with this repo's logging contract around it. // // Lifted verbatim out of ytdlp/downloadOneManaged.ts, which is still its // biggest caller and now delegates to it. It moved because the clip-window // fetch (ytdlp/fetchWindowManaged.ts) needs exactly this — the log tee, the // bounded stderr tail a failure is classified from, and the archive-marker // scrape — and the alternative was a second copy that would drift. // // Its dependencies are three values, not a ManagedDownloadOpts: the binary, a // log sink and an abort signal. That is the whole reason it lifts cleanly. import { execa } from "execa"; export const STDERR_TAIL_BYTES = 64 * 1024; // yt-dlp is asked to `--print` this marker after each video so the caller can // learn the archive line without re-reading the archive file. export const ARCHIVE_MARKER = "DLOM_ARCHIVE"; // A persist pass also `--print`s this marker after the video, with the format // yt-dlp actually downloaded (height, vcodec, format_id of // `requested_downloads[0]`). The info json on disk cannot say it: the prefetch // wrote it with --skip-download, and the download reuses it via // --load-info-json, so its top-level format is the prefetch's, and // `requested_downloads` is only set after yt-dlp has written the file. export const FORMAT_MARKER = "DLOM_FORMAT"; export type YtdlpRunOpts = { ytdlpBin: string; onLog: (s: string) => void; signal: AbortSignal; }; export type AttemptOutcome = { exitCode: number | null; // The last STDERR_TAIL_BYTES of stderr. Bounded because a failing download // can produce megabytes of it and the only consumer is a set of regexes // (lib/availability.ts classifyDownloadFailure). stderrTail: string; archiveLine: string | null; // The text after FORMAT_MARKER, when the invocation printed one. Optional so // an outcome assembled elsewhere (the audio-checked primary) need not name it. formatLine?: string | null; }; export async function runOneYtdlp( opts: YtdlpRunOpts, cwd: string, args: string[], ): Promise { opts.onLog(`$ ${opts.ytdlpBin} ${args.join(" ")}\n`); const child = execa(opts.ytdlpBin, args, { cwd, cancelSignal: opts.signal, all: false, buffer: false, reject: false, }); let stderrTail = ""; child.stderr?.on("data", (c: Buffer) => { const chunk = c.toString("utf8"); opts.onLog(chunk); stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_BYTES); }); let stdoutBuf = ""; let archiveLine: string | null = null; let formatLine: string | null = null; const scrape = (line: string) => { if (line.startsWith(`${ARCHIVE_MARKER} `)) { archiveLine = line.slice(ARCHIVE_MARKER.length + 1).trim(); } else if (line.startsWith(`${FORMAT_MARKER} `)) { formatLine = line.slice(FORMAT_MARKER.length + 1).trim(); } }; child.stdout?.on("data", (c: Buffer) => { const chunk = c.toString("utf8"); opts.onLog(chunk); stdoutBuf += chunk; // Pull whole lines out of the buffer; keep the trailing partial line. let nl: number; while ((nl = stdoutBuf.indexOf("\n")) !== -1) { const line = stdoutBuf.slice(0, nl).trim(); stdoutBuf = stdoutBuf.slice(nl + 1); scrape(line); } }); const result = await child; // Flush any final partial line. scrape(stdoutBuf.trim()); return { exitCode: result.exitCode ?? null, stderrTail, archiveLine, formatLine, }; }