/** * Pure yt-dlp argv construction for a download. * * This module is deliberately free of any electron / node-runtime dependency * (it only imports types + the BEST_FORMAT_ID const from the shared contract) * so the argv it produces can be unit-tested without spinning up Electron. * `buildArgs` takes the ffmpeg/yt-dlp `binDir` as a parameter rather than * resolving it itself; the caller in download.ts passes `getBinDir()`. */ import { BEST_FORMAT_ID, type StartDownloadOptions, type DownloadOptions, type CookieBrowser } from '@shared/ipc' // --- Quality → yt-dlp selector mapping -------------------------------------- function videoFormat(quality: string): string { switch (quality) { case '1080p': return 'bv*[height<=1080]+ba/b[height<=1080]' case '720p': return 'bv*[height<=720]+ba/b[height<=720]' case '480p': return 'bv*[height<=480]+ba/b[height<=480]' case '360p': return 'bv*[height<=360]+ba/b[height<=360]' default: return 'bv*+ba/b' // Best available } } /** * The video format selector. A specific probed format_id wins; otherwise fall * back to the height-based preset. Video-only formats get +bestaudio so the * merge produces a file with sound (with a video-only fallback if no audio). */ function videoSelector(opts: StartDownloadOptions): string { if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) { return opts.formatHasAudio ? opts.formatId : `${opts.formatId}+bestaudio/${opts.formatId}` } return videoFormat(opts.quality) } function audioQuality(quality: string): string { switch (quality) { case '320 kbps': return '320K' case '192 kbps': return '192K' case '128 kbps': return '128K' default: return '0' // Best } } // The progress line yt-dlp emits (one per --newline tick). Note the leading // `download:` is the progress-template TYPE selector and is consumed by yt-dlp // (it does NOT appear in the output). The literal `prog|` that follows is our // own marker, so we can tell progress lines apart from the after-move filepath // print on the same stdout stream. export const PROGRESS_TEMPLATE = 'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' + '%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' + '%(progress.speed)s|%(progress.eta)s' // Crop embedded audio artwork to a centred square. The double-quote/single-quote // dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer // double quotes and leaving ffmpeg single-quoted crop expressions whose commas are // protected from ffmpeg's filtergraph separator. Side = min(width, height). export const CROP_SQUARE_PPA = 'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' + 'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"' /** * Phase B "Access & networking" settings — not per-download options, always * applied from the global settings (there's no per-download override for * these, unlike DownloadOptions). */ export interface AccessOptions { /** yt-dlp --proxy value; empty disables it */ proxy: string /** yt-dlp --limit-rate value, e.g. '2M'; empty means unlimited */ rateLimit: string /** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */ aria2cPath?: string /** read auth cookies from an installed browser's own cookie store */ cookiesFromBrowser?: CookieBrowser /** absolute path to a Netscape-format cookie file (the sign-in window's export) */ cookiesFile?: string /** sanitize output filenames to a portable ASCII-only subset */ restrictFilenames: boolean /** absolute path to a --download-archive file; omit to disable archive tracking */ downloadArchivePath?: string } // Tuned for typical CDN-served video: 16 connections split across 16 segments, // each at least 1MB so tiny files don't get split pointlessly. export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M' /** * Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command * templates) into argv tokens. Supports single- and double-quoted spans so a * flag value containing spaces (e.g. a --ppa recipe) can be typed as one token. * * LIMITATIONS (audit M2): there is no escape-sequence support (`\"`, `\'`) and * quotes cannot nest — a literal quote inside a value can't be expressed, and an * unterminated quote falls through to the `\S+` branch and captures the quote * char itself. For the yt-dlp use cases this targets (proxy URLs, filename * patterns, ffmpeg recipes), that's sufficient. If a future template needs * nested or escaped quotes, redesign this to a proper shlex or JSON-based format. */ export function parseExtraArgs(raw: string): string[] { const args: string[] = [] const re = /"([^"]*)"|'([^']*)'|(\S+)/g let m: RegExpExecArray | null while ((m = re.exec(raw)) !== null) { args.push(m[1] ?? m[2] ?? m[3]) } return args } // Wrap a single argv token for human-readable display only (Phase C command // preview) — never used to build the real argv that gets spawned. function quoteForDisplay(arg: string): string { if (arg === '') return '""' if (/[\s"]/.test(arg)) return `"${arg.replace(/"/g, '\\"')}"` return arg } /** Render a binary + argv as a single copy-pasteable command line. */ export function formatCommandLine(exe: string, args: string[]): string { return [exe, ...args].map(quoteForDisplay).join(' ') } function accessArgs(a: AccessOptions): string[] { const args: string[] = [] if (a.proxy.trim()) args.push('--proxy', a.proxy.trim()) if (a.rateLimit.trim()) args.push('--limit-rate', a.rateLimit.trim()) if (a.aria2cPath) args.push('--downloader', a.aria2cPath, '--downloader-args', ARIA2C_ARGS) // cookiesFromBrowser and cookiesFile are mutually exclusive sources — the UI // only ever sets one (driven by Settings.cookieSource), but browser wins if // a caller somehow sets both. if (a.cookiesFromBrowser) args.push('--cookies-from-browser', a.cookiesFromBrowser) else if (a.cookiesFile) args.push('--cookies', a.cookiesFile) if (a.restrictFilenames) args.push('--restrict-filenames') if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath) return args } /** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] { const args: string[] = [] // Subtitles — video only; audio extraction has nowhere to embed them. if (opts.kind === 'video' && o.embedSubtitles) { args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en') if (o.autoSubtitles) args.push('--write-auto-subs') // Normalise to srt first so embedding works across containers (mp4 → mov_text). args.push('--convert-subs', 'srt') } // SponsorBlock. if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) { const cats = o.sponsorBlockCategories.join(',') if (o.sponsorBlockMode === 'remove') { // Force keyframes at the cut points so segment boundaries are frame-accurate. args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts') } else { args.push('--sponsorblock-mark', cats) } } if (o.embedChapters) args.push('--embed-chapters') if (o.embedMetadata) args.push('--embed-metadata') return args } export function buildArgs( opts: StartDownloadOptions, outputTemplate: string, o: DownloadOptions, binDir: string, access: AccessOptions, extraArgs: string[] = [] ): string[] { const args = [ '--newline', '--no-color', '--no-playlist', // --print (below) implies --quiet, which would suppress progress; --progress // forces the progress template to emit anyway. '--progress', '--ffmpeg-location', binDir, '-o', outputTemplate, '--progress-template', PROGRESS_TEMPLATE, // Print the final path after post-processing/move so we can open it later. '--print', 'after_move:path|%(filepath)s', '--no-simulate' ] args.push(...accessArgs(access)) args.push(...postProcessArgs(opts, o)) if (opts.kind === 'audio') { args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality)) if (o.embedThumbnail) { args.push('--embed-thumbnail') if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA) } } else { args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer) // Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the // pick toward the chosen codec without overriding the requested quality. if (o.preferredVideoCodec !== 'any') { const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec args.push('-S', `res,fps,vcodec:${token}`) } // mp4/mkv carry a cover image; webm has no reliable way to embed one. if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail') } // Custom-command extra args go last, immediately before the `--` URL // terminator, so a user-supplied flag can override anything chosen above // (yt-dlp takes the last occurrence of most options). if (extraArgs.length > 0) args.push(...extraArgs) // `--` terminates option parsing so the URL can never be read as a flag. args.push('--', opts.url) return args }