/** * 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 `binDir` as a parameter (used only for `--ffmpeg-location`) * rather than resolving it itself; download.ts passes `getFfmpegDir()` — the managed * userData/bin where the first-run download installs ffmpeg/ffprobe. */ import { join } from 'path' import { BEST_FORMAT_ID, type StartDownloadOptions, type DownloadOptions, type CollectionContext, type CookieBrowser, type CommandTemplate } 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' case 'Best': return '0' default: // Unrecognised label — fall back to best (0) so the download still works. return '0' } } // Stdout line markers, shared by the emit side (the --print templates here) and // the parse side (download.ts). Defined once so changing a marker can't silently // break the parser that splits on it (CL1). export const PROGRESS_MARKER = 'prog|' export const FILEPATH_MARKER = 'path|' // Emitted once at the before_dl stage carrying the resolved FINAL output path, so // the engine knows the destination stem while the download is still in flight — // used to delete orphaned .part / .fNNN intermediates when a download is canceled // (R4). `%(filepath)s` is still unresolved this early (prints 'NA'); `%(filename)s` // already resolves to the full output path. export const DEST_MARKER = 'dest|' // 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 PROGRESS_MARKER that follows is our // own marker, so we can tell progress lines apart from the after-move filepath // print on the same stdout stream. const PROGRESS_TEMPLATE = `download:${PROGRESS_MARKER}%(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 /** `--sleep-requests` seconds (pause between extraction requests); 0/omitted = off */ sleepRequests?: number /** `--sleep-interval` seconds (min pause before each download); 0/omitted = off */ sleepInterval?: number /** `--max-sleep-interval` seconds (max of the random pre-download pause); applies only with a non-zero sleepInterval */ maxSleepInterval?: number /** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */ aria2cPath?: string /** aria2c parallel connections (-x/-s), clamped to 1–16; omitted = 16 (L64) */ aria2cConnections?: number /** 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 /** YouTube extraction client override (e.g. 'web_safari'); empty/omitted = default */ youtubePlayerClient?: string /** manually-supplied YouTube Proof-of-Origin token; empty/omitted = none */ youtubePoToken?: string } /** * The yt-dlp `--downloader-args` value for aria2c: N connections split across N * segments, each at least 1MB so tiny files don't get split pointlessly. The * connection count is user-tunable (Settings → Network, L64) and clamped here to * aria2c's own -x/-s ceiling of 16 — settings validation clamps too, but this is * pure-module defence for any other caller. Non-finite input → the default 16. */ export function aria2cArgs(connections?: number): string { const n = connections !== undefined && Number.isFinite(connections) ? Math.min(16, Math.max(1, Math.round(connections))) : 16 return `aria2c:-x ${n} -s ${n} -k 1M` } /** The default tuning (16 connections) — kept for tests/docs that reference it. */ export const ARIA2C_ARGS = aria2cArgs() /** * 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) { // Exactly one of the three alternation groups matches; the '' fallback only // satisfies the type checker (noUncheckedIndexedAccess) and never fires. args.push(m[1] ?? m[2] ?? m[3] ?? '') } return args } /** * Resolve the extra yt-dlp args for a download, enforcing the custom-command * consent gate (audit F2). * * Extra args are powerful enough to run arbitrary code (e.g. `--exec`), so they * are honoured ONLY when custom commands are explicitly enabled in settings — * the same persisted flag the Settings UI and backup-import treat as consent. A * per-download override wins over the persisted default template, but NEITHER is * applied while the gate is off. This keeps a compromised renderer from smuggling * code-exec flags through a lone `startDownload({ extraArgs })` call: it would * first have to flip the visible `enableCustomCommands` setting, leaving a trace * (the same defence-in-depth posture as the main-side maxConcurrent cap). * * - enableCustomCommands off → [] (always) * - perDownloadExtraArgs defined (even '') → those args * - else a template whose urlPattern matches → that template's args (most specific) * - else a matching defaultTemplateId → that template's args * - else → [] */ export function selectExtraArgs(params: { enableCustomCommands: boolean perDownloadExtraArgs: string | undefined defaultTemplateId: string | null templates: Pick[] /** the download URL, for urlPattern auto-matching */ url?: string }): string[] { if (!params.enableCustomCommands) return [] if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs) // A template whose urlPattern matches this URL auto-applies, ahead of the global // default — it's the more specific choice. if (params.url) { const matched = params.templates.find( (t) => t.urlPattern && matchesUrl(t.urlPattern, params.url!) ) if (matched) return parseExtraArgs(matched.args) } if (params.defaultTemplateId) { const tpl = params.templates.find((t) => t.id === params.defaultTemplateId) if (tpl) return parseExtraArgs(tpl.args) } return [] } /** Case-insensitive regex test of a template's urlPattern; a bad pattern never matches. */ export function matchesUrl(pattern: string, url: string): boolean { try { return new RegExp(pattern, 'i').test(url) } catch { return false } } /** * Parse a raw trim string (one or more time ranges, comma- or newline-separated) * into yt-dlp `--download-sections` specs. Each range is normalised to the * `*START-END` time-range form yt-dlp expects (the leading `*` distinguishes a * timestamp range from a chapter-title regex). Tokens that don't look like a * numeric time range are dropped, so malformed input can't reach yt-dlp's argv. * * Accepts H:MM:SS / M:SS / SS with optional fractional seconds, e.g. '1:30-2:00', * '90-120', '0:00:10.5-0:00:20'. A token may already carry the leading `*`. */ export function parseTrimSections(raw: string | undefined): string[] { if (!raw) return [] // A time is SS, M:SS, or H:MM:SS — at most two colon-separated groups. The old // `*` quantifier accepted nonsense like `1:2:3:4`, which then failed inside // yt-dlp's --download-sections rather than being rejected up front (L146). const TIME = String.raw`\d+(?::\d{1,2}){0,2}(?:\.\d+)?` const RANGE = new RegExp(`^${TIME}-${TIME}$`) const out: string[] = [] for (const piece of raw.split(/[,\n]/)) { let t = piece.trim() if (!t) continue if (t.startsWith('*')) t = t.slice(1).trim() if (RANGE.test(t)) out.push(`*${t}`) } return out } // 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(' ') } // --- Collection (media-manager) folder paths -------------------------------- /** * Sanitize one path segment (a channel or playlist name) into a Windows-safe * directory name. This matters because, unlike the filename yt-dlp itself writes * (which `--restrict-filenames` can clean), these directory segments are built by * AeroFetch from untrusted channel/playlist titles and joined onto the output * dir — so they must be neutered for both illegal characters AND path traversal. * * - illegal chars (`< > : " / \ | ? *`) and control chars → space * - leading/trailing dots and spaces stripped (illegal / invisible on Windows), * which also turns a bare `..` traversal segment into nothing * - reserved device names (CON, PRN, NUL, COM1…) get an underscore prefix * - length-capped so the full path stays well under MAX_PATH * - empty result falls back to 'Untitled' */ export function sanitizeDirSegment(name: string): string { // C0 control chars (charCode < 0x20) and Windows-illegal chars both become a // space. The control-char filter is done by char code so no literal control // byte ever appears in this source file. let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('') s = s.replace(/[<>:"/\\|?*]/g, ' ') s = s .replace(/\s+/g, ' ') .trim() .replace(/[. ]+$/, '') .replace(/^[. ]+/, '') // Reserved device names are reserved even WITH an extension ('CON.txt' is still // the CON device), so match an optional trailing '.' too (audit T3). if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}` s = s.slice(0, 80).trim() return s || 'Untitled' } /** * Build the `-o` output template for a collection download. The default layout is * /// - * where NNN is the 1-based playlist index zero-padded to three digits and * baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they * still expand. Channel/playlist are sanitized via sanitizeDirSegment. * * A power user can override the folder layout with a raw yt-dlp `-o` template * (`Settings.collectionOutputTemplate`, PINCHFLAT output-path setting) — passed * as `customTemplate`. When present, it's joined under `outDir` and left for * yt-dlp to expand against ITS own metadata fields (`%(uploader)s`, * `%(playlist)s`, `%(playlist_index)s`, `%(title)s`, `%(ext)s`, …), so the raw * template escapes AeroFetch's fixed three-segment layout entirely. It's * validated as a safe relative template (no absolute path, no `..`) before it * ever reaches here. */ export function collectionOutputTemplate( outDir: string, c: CollectionContext, baseFilename: string, customTemplate?: string ): string { const custom = customTemplate?.trim() if (custom) return join(outDir, custom) const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1 const nnn = String(n).padStart(3, '0') const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)] return join(outDir, ...segments, `${nnn} - ${baseFilename}`) } 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()) // Request throttle — the antidote to YouTube's rate-limit/403 wall on big // channel pulls. `--sleep-requests` spaces out the extraction requests; the // sleep-interval pair brackets a random pause before each download. yt-dlp // requires --sleep-interval to be present for --max-sleep-interval to be // meaningful, and a max below the min is nonsensical, so max is gated on both. const sleepRequests = a.sleepRequests ?? 0 if (sleepRequests > 0) args.push('--sleep-requests', String(sleepRequests)) const sleepInterval = a.sleepInterval ?? 0 if (sleepInterval > 0) { args.push('--sleep-interval', String(sleepInterval)) const maxSleep = a.maxSleepInterval ?? 0 if (maxSleep > sleepInterval) args.push('--max-sleep-interval', String(maxSleep)) } if (a.aria2cPath) { args.push('--downloader', a.aria2cPath, '--downloader-args', aria2cArgs(a.aria2cConnections)) } // 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) // YouTube reliability: combine the player-client + PO-token overrides into one // `youtube:` extractor-args group (semicolon-separated, as yt-dlp expects). const yt: string[] = [] if (a.youtubePlayerClient?.trim()) yt.push(`player_client=${a.youtubePlayerClient.trim()}`) if (a.youtubePoToken?.trim()) yt.push(`po_token=${a.youtubePoToken.trim()}`) if (yt.length > 0) args.push('--extractor-args', `youtube:${yt.join(';')}`) 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. let forcedKeyframes = false 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') forcedKeyframes = true } else { args.push('--sponsorblock-mark', cats) } } // Trim — download only the given time ranges (works for video + audio). Force // keyframes at the cut points for frame-accurate boundaries, unless // SponsorBlock-remove already requested it (the flag is idempotent, but we // avoid emitting it twice). const sections = parseTrimSections(opts.trim) for (const sec of sections) args.push('--download-sections', sec) if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts') if (o.embedChapters) args.push('--embed-chapters') // Split into one file per chapter. yt-dlp also keeps the full file; the // per-chapter files use yt-dlp's default `chapter:` output template. if (o.splitChapters) args.push('--split-chapters') // Metadata embedding + optional per-field overrides. // --replace-in-metadata FIELD REGEX REPLACE is used instead of --parse-metadata // FROM:TO because the replacement arg is a separate element (no colon-splitting // on values like "Foo: Bar"). ^.*$ matches any string including empty. The only // escaping Python's re.sub needs in the replacement string is backslash. const metaOverrides: [string, string | undefined][] = [ ['title', o.metadataTitle], ['artist', o.metadataArtist], ['album', o.metadataAlbum] ] const hasOverrides = metaOverrides.some(([, v]) => v?.trim()) if (o.embedMetadata || hasOverrides) args.push('--embed-metadata') const escRepl = (s: string): string => s.replace(/\\/g, '\\\\') for (const [field, val] of metaOverrides) { if (val?.trim()) args.push('--replace-in-metadata', field, '^.*$', escRepl(val.trim())) } // Media-server sidecar files (Phase K) — written next to the output file so // Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description. if (o.writeInfoJson) args.push('--write-info-json') if (o.writeThumbnailFile) args.push('--write-thumbnail') if (o.writeDescription) args.push('--write-description') return args } /** * Everything buildArgs needs to construct a yt-dlp argv. A single options object * (rather than six positional params) so callers can't transpose `opts`/`options` * or the two path-like strings, and new inputs can be added without churning every * call site (CL2). */ export interface BuildArgsInput { /** The per-download request (url, kind, quality, chosen format, trim, …). */ opts: StartDownloadOptions /** The resolved `-o` output template (flat filename or collection folder tree). */ outputTemplate: string /** The post-processing options group (per-download override or the persisted default). */ options: DownloadOptions /** ffmpeg/yt-dlp bin dir, passed in so this module stays free of path resolution. */ binDir: string /** Global access/networking settings (proxy, cookies, rate limit, …). */ access: AccessOptions /** Custom-command extra args, already consent-gated by the caller. */ extraArgs?: string[] } export function buildArgs(input: BuildArgsInput): string[] { const { opts, outputTemplate, options: o, binDir, access, extraArgs = [] } = input const args = [ '--newline', '--no-color', '--no-playlist', // Bound a dead/hung connection so yt-dlp aborts (and retries, then exits) a // stalled socket instead of hanging forever and pinning a concurrency slot // with no recovery. The app-side idle watchdog in download.ts is the backstop // for a fully-wedged process. (B1) '--socket-timeout', '30', // --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:${FILEPATH_MARKER}%(filepath)s`, // Print the resolved destination up front (before_dl) so a cancel can find and // delete this download's orphaned partials by stem, mid-flight (R4). '--print', `before_dl:${DEST_MARKER}%(filename)s`, '--no-simulate' ] args.push(...accessArgs(access)) args.push(...postProcessArgs(opts, o)) if (opts.kind === 'audio') { // --audio-quality is a bitrate selector meaningful only for lossy re-encodes. // For lossless formats (flac/wav) it is silently ignored by yt-dlp (M21). const lossless = o.audioFormat === 'flac' || o.audioFormat === 'wav' args.push('-x', '--audio-format', o.audioFormat) if (!lossless) args.push('--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) // A raw format-sort string (advanced) wins outright; otherwise the codec // preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward // the chosen codec without overriding the requested quality. const sort = o.formatSort.trim() if (sort) { args.push('-S', sort) } else 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 }