Files
AeroFetch/src/main/buildArgs.ts
T
debont80 ba208de98b Fix L54/L61/L63: thumbnail error fallback, ipcMain.handle, audioQuality label
L54: DownloadBar preview image now tracks failed src in state (same pattern as
     MediaThumb); on load error shows the kind icon fallback instead of broken img.
L61: Convert taskbarProgress from ipcMain.on/ipcRenderer.send to ipcMain.handle/
     ipcRenderer.invoke so all IPC channels use the same request-response idiom.
     App.tsx calls void to explicitly discard the resolved promise.
L63: audioQuality() now has an explicit 'Best' case alongside the bitrate strings;
     unknown labels still fall back to '0' but the default branch is documented.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:45:45 -04:00

444 lines
19 KiB
TypeScript

/**
* 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 { 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|'
// 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
/** 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
/** 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
}
// 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) {
// 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 `customCommandEnabled` setting, leaving a trace
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
*
* - customCommandEnabled 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: {
customCommandEnabled: boolean
perDownloadExtraArgs: string | undefined
defaultTemplateId: string | null
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
/** the download URL, for urlPattern auto-matching */
url?: string
}): string[] {
if (!params.customCommandEnabled) 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 '.<ext>' 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:
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
* 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.
*/
export function collectionOutputTemplate(
outDir: string,
c: CollectionContext,
baseFilename: string
): string {
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())
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)
// 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
}
export function buildArgs(
opts: StartDownloadOptions,
outputTemplate: string,
o: DownloadOptions,
binDir: string,
access: AccessOptions,
extraArgs: string[] = []
): string[] {
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`,
'--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
}