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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* End-to-end verification that buildArgs' argv actually works against the real
|
||||
* bundled yt-dlp.exe + ffmpeg.exe — the part typecheck can't prove. It spawns
|
||||
* the binaries exactly the way src/main/download.ts does (argv array, no shell,
|
||||
* windowsHide) so the args travel the identical path, then inspects the output.
|
||||
*
|
||||
* Skipped by default (hits YouTube + the live SponsorBlock DB). To run it:
|
||||
* AEROFETCH_REAL_DOWNLOAD=1 npx vitest run test/real-download.integration.test.ts
|
||||
* (PowerShell: $env:AEROFETCH_REAL_DOWNLOAD=1; npx vitest run test/real-download.integration.test.ts)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { spawnSync } from 'child_process'
|
||||
import { mkdtempSync, existsSync, rmSync, statSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import { buildArgs } from '../src/main/buildArgs'
|
||||
import { DEFAULT_DOWNLOAD_OPTIONS, type StartDownloadOptions } from '@shared/ipc'
|
||||
|
||||
const RUN = process.env.AEROFETCH_REAL_DOWNLOAD === '1'
|
||||
|
||||
const BIN_DIR = resolve('resources/bin')
|
||||
const YTDLP = join(BIN_DIR, 'yt-dlp.exe')
|
||||
const FFMPEG = join(BIN_DIR, 'ffmpeg.exe')
|
||||
|
||||
interface RunResult {
|
||||
status: number | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
/** Spawn yt-dlp with a buildArgs-produced argv, mirroring download.ts. */
|
||||
function runYtdlp(argv: string[]): RunResult {
|
||||
const r = spawnSync(YTDLP, argv, {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
maxBuffer: 128 * 1024 * 1024
|
||||
})
|
||||
// download.ts reads the final path from the `path|<filepath>` print line.
|
||||
const line = (r.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.reverse()
|
||||
.find((l) => l.startsWith('path|'))
|
||||
return {
|
||||
status: r.status,
|
||||
stdout: r.stdout || '',
|
||||
stderr: r.stderr || '',
|
||||
filePath: line ? line.slice('path|'.length).trim() : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** ffmpeg prints container/stream info to stderr; we read it back as a probe. */
|
||||
function ffinfo(file: string): string {
|
||||
const r = spawnSync(FFMPEG, ['-hide_banner', '-i', file], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
})
|
||||
return r.stderr || ''
|
||||
}
|
||||
|
||||
function parseDurationSec(info: string): number | undefined {
|
||||
const m = info.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/)
|
||||
return m ? Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]) : undefined
|
||||
}
|
||||
|
||||
/** The attached cover image shows up as an mjpeg Video stream with WxH. */
|
||||
function parseCoverDims(info: string): { w: number; h: number } | undefined {
|
||||
const m = info.match(/Video:.*?,\s*(\d+)x(\d+)/)
|
||||
return m ? { w: Number(m[1]), h: Number(m[2]) } : undefined
|
||||
}
|
||||
|
||||
function probeSourceDuration(url: string): number | undefined {
|
||||
const r = spawnSync(YTDLP, ['--no-warnings', '--no-playlist', '--print', '%(duration)s', '--', url], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
})
|
||||
const n = Number((r.stdout || '').trim())
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
let outDir: string
|
||||
beforeAll(() => {
|
||||
if (!RUN) return
|
||||
expect(existsSync(YTDLP), `yt-dlp.exe missing at ${YTDLP}`).toBe(true)
|
||||
expect(existsSync(FFMPEG), `ffmpeg.exe missing at ${FFMPEG}`).toBe(true)
|
||||
outDir = mkdtempSync(join(tmpdir(), 'aerofetch-real-'))
|
||||
})
|
||||
afterAll(() => {
|
||||
if (RUN && outDir) rmSync(outDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
||||
it(
|
||||
'cropThumbnail: audio mp3 gets a SQUARE embedded cover (--ppa crop survives ffmpeg)',
|
||||
() => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'crop',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // "Me at the zoo", 19s
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: true,
|
||||
cropThumbnail: true
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR)
|
||||
console.log('[crop] argv:', JSON.stringify(argv))
|
||||
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[crop] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
expect(statSync(res.filePath!).size).toBeGreaterThan(1000)
|
||||
|
||||
const info = ffinfo(res.filePath!)
|
||||
const dims = parseCoverDims(info)
|
||||
console.log('[crop] output:', res.filePath, 'cover dims:', dims)
|
||||
expect(dims, 'embedded cover image should be present').toBeTruthy()
|
||||
expect(dims!.w, 'cover must be cropped to a square').toBe(dims!.h)
|
||||
},
|
||||
240_000
|
||||
)
|
||||
|
||||
it(
|
||||
'sponsorBlock remove: output is shorter than source by the removed segments',
|
||||
() => {
|
||||
const url = 'https://www.youtube.com/watch?v=kJQP7kiw5Fk' // has ~54s of music_offtopic
|
||||
const source = probeSourceDuration(url)
|
||||
console.log('[sb] source duration:', source)
|
||||
expect(source, 'should read source duration').toBeGreaterThan(60)
|
||||
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'sb',
|
||||
url,
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: false,
|
||||
sponsorBlock: true,
|
||||
sponsorBlockMode: 'remove' as const,
|
||||
sponsorBlockCategories: ['music_offtopic' as const]
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR)
|
||||
console.log('[sb] argv:', JSON.stringify(argv))
|
||||
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[sb] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath).toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
|
||||
const outDur = parseDurationSec(ffinfo(res.filePath!))
|
||||
console.log('[sb] output duration:', outDur, 'removed ≈', (source ?? 0) - (outDur ?? 0), 's')
|
||||
expect(outDur, 'should read output duration').toBeGreaterThan(0)
|
||||
// ~54s of segments are removed; require a clear, unambiguous shrink.
|
||||
expect(source! - outDur!).toBeGreaterThan(30)
|
||||
},
|
||||
240_000
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user