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
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, expect } from 'vitest'
import { buildArgs, CROP_SQUARE_PPA } from '../src/main/buildArgs'
import {
DEFAULT_DOWNLOAD_OPTIONS,
type DownloadOptions,
type StartDownloadOptions
} from '@shared/ipc'
// --- Fixtures ---------------------------------------------------------------
const BIN = 'C:/fake/bin'
const OUT = 'C:/out/%(title)s.%(ext)s'
const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
function opts(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides }
}
function audio(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
return opts({ kind: 'audio', quality: 'Best (MP3)', ...overrides })
}
function dlo(overrides: Partial<DownloadOptions> = {}): DownloadOptions {
return { ...DEFAULT_DOWNLOAD_OPTIONS, ...overrides }
}
function build(o: StartDownloadOptions, d: DownloadOptions): string[] {
return buildArgs(o, OUT, d, BIN)
}
/** True when `seq` appears as a contiguous run inside `argv`. */
function hasSeq(argv: string[], ...seq: string[]): boolean {
if (seq.length === 0) return true
for (let i = 0; i + seq.length <= argv.length; i++) {
if (seq.every((s, j) => argv[i + j] === s)) return true
}
return false
}
// --- Invariants shared by every recipe --------------------------------------
describe('buildArgs scaffolding', () => {
it('pins ffmpeg location to the supplied binDir', () => {
expect(hasSeq(build(opts(), dlo()), '--ffmpeg-location', BIN)).toBe(true)
})
it('terminates options with `--` immediately before the URL', () => {
const argv = build(opts(), dlo())
expect(argv[argv.length - 2]).toBe('--')
expect(argv[argv.length - 1]).toBe(URL)
})
})
// --- Audio extraction format ------------------------------------------------
describe('audio: -x --audio-format <fmt>', () => {
it.each(['mp3', 'opus', 'flac'] as const)('emits -x --audio-format %s', (fmt) => {
const argv = build(audio(), dlo({ audioFormat: fmt }))
expect(hasSeq(argv, '-x', '--audio-format', fmt)).toBe(true)
})
it('maps the quality label to an --audio-quality value', () => {
expect(hasSeq(build(audio({ quality: 'Best (MP3)' }), dlo()), '--audio-quality', '0')).toBe(true)
expect(hasSeq(build(audio({ quality: '320 kbps' }), dlo()), '--audio-quality', '320K')).toBe(
true
)
expect(hasSeq(build(audio({ quality: '128 kbps' }), dlo()), '--audio-quality', '128K')).toBe(
true
)
})
})
// --- Subtitles (video only) -------------------------------------------------
describe('embedSubtitles', () => {
it('emits write/embed/sub-langs/convert without auto-subs by default', () => {
const argv = build(opts(), dlo({ embedSubtitles: true }))
expect(
hasSeq(argv, '--write-subs', '--embed-subs', '--sub-langs', 'en', '--convert-subs', 'srt')
).toBe(true)
expect(argv).not.toContain('--write-auto-subs')
})
it('inserts --write-auto-subs before --convert-subs when autoSubtitles is on', () => {
const argv = build(opts(), dlo({ embedSubtitles: true, autoSubtitles: true }))
expect(
hasSeq(
argv,
'--write-subs',
'--embed-subs',
'--sub-langs',
'en',
'--write-auto-subs',
'--convert-subs',
'srt'
)
).toBe(true)
})
it('honours a custom subtitleLanguages selector', () => {
const argv = build(opts(), dlo({ embedSubtitles: true, subtitleLanguages: 'en.*,es' }))
expect(hasSeq(argv, '--sub-langs', 'en.*,es')).toBe(true)
})
it('never writes subtitles for an audio download', () => {
const argv = build(audio(), dlo({ embedSubtitles: true, autoSubtitles: true }))
expect(argv).not.toContain('--write-subs')
expect(argv).not.toContain('--embed-subs')
})
})
// --- SponsorBlock -----------------------------------------------------------
describe('sponsorBlock', () => {
it('remove → --sponsorblock-remove <cats> --force-keyframes-at-cuts', () => {
const argv = build(opts(), dlo({ sponsorBlock: true, sponsorBlockMode: 'remove' }))
expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor', '--force-keyframes-at-cuts')).toBe(true)
expect(argv).not.toContain('--sponsorblock-mark')
})
it('mark → --sponsorblock-mark <cats>, no keyframe forcing', () => {
const argv = build(opts(), dlo({ sponsorBlock: true, sponsorBlockMode: 'mark' }))
expect(hasSeq(argv, '--sponsorblock-mark', 'sponsor')).toBe(true)
expect(argv).not.toContain('--sponsorblock-remove')
expect(argv).not.toContain('--force-keyframes-at-cuts')
})
it('joins multiple categories with commas', () => {
const argv = build(
opts(),
dlo({ sponsorBlock: true, sponsorBlockCategories: ['sponsor', 'intro', 'outro'] })
)
expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor,intro,outro')).toBe(true)
})
it('emits nothing when sponsorBlock is off or category list is empty', () => {
expect(build(opts(), dlo({ sponsorBlock: false })).join(' ')).not.toContain('sponsorblock')
expect(
build(opts(), dlo({ sponsorBlock: true, sponsorBlockCategories: [] })).join(' ')
).not.toContain('sponsorblock')
})
})
// --- Preferred video codec (sort tiebreaker) --------------------------------
describe('preferredVideoCodec → -S res,fps,vcodec:<token>', () => {
it('maps av1 to the av01 codec token', () => {
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'av1' })), '-S', 'res,fps,vcodec:av01')).toBe(
true
)
})
it('passes h264 / vp9 through verbatim', () => {
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'h264' })), '-S', 'res,fps,vcodec:h264')).toBe(
true
)
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'vp9' })), '-S', 'res,fps,vcodec:vp9')).toBe(
true
)
})
it('omits -S entirely when codec preference is "any"', () => {
expect(build(opts(), dlo({ preferredVideoCodec: 'any' }))).not.toContain('-S')
})
})
// --- Cropped square cover art (the highest-risk ffmpeg quoting) --------------
describe('cropThumbnail → --ppa crop', () => {
it('emits --embed-thumbnail then the square-crop --ppa string', () => {
const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: true }))
expect(hasSeq(argv, '--embed-thumbnail', '--ppa', CROP_SQUARE_PPA)).toBe(true)
})
it('uses single-quoted, comma-protected crop expressions for ffmpeg', () => {
// The commas live inside gt(...) and MUST stay quoted so ffmpeg does not read
// them as filtergraph separators. The targeted-postprocessor key plus the
// ffmpeg_o output-args selector frame the whole value.
expect(CROP_SQUARE_PPA).toBe(
"EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
)
})
it('does not add --ppa when cropThumbnail is off', () => {
const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: false }))
expect(argv).not.toContain('--ppa')
expect(argv).toContain('--embed-thumbnail')
})
it('does not add --ppa when the thumbnail itself is not embedded', () => {
const argv = build(audio(), dlo({ embedThumbnail: false, cropThumbnail: true }))
expect(argv).not.toContain('--ppa')
expect(argv).not.toContain('--embed-thumbnail')
})
})