Add a pure, unit-tested buildArgs() module

Extracts yt-dlp argv construction out of download.ts into its own
electron-free module (src/main/buildArgs.ts) so it can be exercised by
vitest without spinning up Electron, plus a real-download integration
test (skipped by default; opt in with AEROFETCH_REAL_DOWNLOAD=1) that
spawns the bundled yt-dlp.exe/ffmpeg.exe against its argv. download.ts
isn't wired up to it yet — that lands with the next commit.

This was done in an earlier local session but never actually committed,
even though download.ts has imported from it since the Phase A commit
(bb5dd6c) — the build had been relying on an uncommitted local file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:52:23 -04:00
parent bb5dd6c438
commit 26cd7fc7b0
6 changed files with 898 additions and 3 deletions
+156
View File
@@ -0,0 +1,156 @@
/**
* 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
} 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)\'"'
/** 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
): 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(...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')
}
// `--` terminates option parsing so the URL can never be read as a flag.
args.push('--', opts.url)
return args
}