/** * Shared yt-dlp stderr parsing. Used by both download.ts and probe.ts so the * error-extraction logic lives in one place (audit M1). */ /** Trim yt-dlp's noisy stderr down to the most useful trailing error line. */ export function cleanError(stderr: string): string { const lines = stderr .split('\n') .map((l) => l.trim()) .filter(Boolean) const errLine = [...lines].reverse().find((l) => /^error/i.test(l)) return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim() } // Lines that carry no diagnostic value as post-processing context: per-tick // download progress and the "Deleting original file" cleanup bookkeeping yt-dlp // prints between merge steps. const CONTEXT_NOISE = /^\[download\]|^Deleting original file/i // A single context line, capped so a long output path can't bloat the message. const CONTEXT_LINE_MAX = 200 /** * Richer error for the DOWNLOAD failure path (probe/indexer/updater keep the * concise cleanError line). * * yt-dlp collapses an ffmpeg post-processing failure to a bare * "ERROR: Postprocessing: Conversion failed!" that names neither the step that * died nor the reason — and without `--verbose` the underlying ffmpeg detail is * never emitted at all. What yt-dlp DOES print is a tagged progress line per * post-processor ([Merger], [ModifyChapters], [ExtractAudio], …; its phase tags * like [download]/[info] are lowercase). So for that generic wrapper we append * the last couple of non-noise trailing lines, which name the failing step (e.g. * the SponsorBlock re-encode) and surface any ffmpeg diagnostic when one is * present. Self-explanatory errors (a private video, a 403) are returned * unchanged. */ export function describeDownloadError(stderr: string): string { const headline = cleanError(stderr) if (!headline || !/conversion failed|postprocessing/i.test(headline)) return headline const lines = stderr .split('\n') .map((l) => l.trim()) .filter(Boolean) const context: string[] = [] for (const line of [...lines].reverse()) { if (context.length >= 2) break if (CONTEXT_NOISE.test(line) || /^error/i.test(line)) continue // Skip anything already conveyed by the headline (avoids echoing it back). if (headline.includes(line) || line.includes(headline)) continue const capped = line.length > CONTEXT_LINE_MAX ? `${line.slice(0, CONTEXT_LINE_MAX - 1)}…` : line if (!context.includes(capped)) context.unshift(capped) } return context.length > 0 ? `${headline} — ${context.join(' · ')}` : headline } /** * Full non-noise stderr, for the persisted error log's "Show details" expander * (Diagnostics card) — everything describeDownloadError condenses to one line, * kept in full (progress ticks and delete-bookkeeping still stripped) so a bug * report carries the real yt-dlp/ffmpeg transcript instead of a paraphrase. * Returns undefined when there's nothing beyond a single line — the headline * the caller already shows — so a plain, self-explanatory error doesn't grow a * details toggle with nothing new in it. */ export function stderrDetail(stderr: string): string | undefined { const lines = stderr .split('\n') .map((l) => l.trim()) .filter((l) => l && !CONTEXT_NOISE.test(l)) return lines.length > 1 ? lines.join('\n') : undefined }