/** * 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, readFileSync } from 'fs' import { tmpdir } from 'os' import { join, resolve } from 'path' import { buildArgs, parseExtraArgs } 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') const FFPROBE = join(BIN_DIR, 'ffprobe.exe') const NO_ACCESS = { proxy: '', rateLimit: '', restrictFilenames: false } 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|` 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 } interface ProbeStream { codec_type?: string codec_name?: string width?: number height?: number } /** Structured ffprobe read of the output file: streams + container + chapters. */ function ffprobeJson(file: string): { streams?: ProbeStream[] chapters?: Array<{ tags?: { title?: string } }> format?: { format_name?: string } } { const r = spawnSync( FFPROBE, [ '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', '-show_chapters', file ], { encoding: 'utf8', windowsHide: true, maxBuffer: 32 * 1024 * 1024 } ) try { return JSON.parse(r.stdout || '{}') } catch { return {} } } /** All streams of a given codec_type ('video' | 'audio' | 'subtitle'). */ function streamsOfType(file: string, type: string): ProbeStream[] { return (ffprobeJson(file).streams ?? []).filter((s) => s.codec_type === type) } /** Basename of a path, splitting on either separator (output paths are Windows). */ function baseName(p: string): string { return p.split(/[\\/]/).pop() ?? p } 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) expect(existsSync(FFPROBE), `ffprobe.exe missing at ${FFPROBE}`).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' } const options = { ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: true, cropThumbnail: true } const argv = buildArgs({ opts, outputTemplate: join(outDir, '%(id)s.%(ext)s'), options, binDir: BIN_DIR, access: NO_ACCESS }) 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' } 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, outputTemplate: join(outDir, '%(id)s.%(ext)s'), options, binDir: BIN_DIR, access: NO_ACCESS }) 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) it('audioFormat opus: --audio-format re-encodes to a real .opus stream', () => { const opts: StartDownloadOptions = { id: 'opus', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' } const options = { ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'opus' as const, embedThumbnail: false } const argv = buildArgs({ opts, outputTemplate: join(outDir, '%(id)s.%(ext)s'), options, binDir: BIN_DIR, access: NO_ACCESS }) const res = runYtdlp(argv) if (res.status !== 0) console.error('[opus] 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(res.filePath!.toLowerCase().endsWith('.opus'), 'output ext should be .opus').toBe(true) const audio = streamsOfType(res.filePath!, 'audio') console.log( '[opus] audio codecs:', audio.map((s) => s.codec_name) ) expect(audio.length, 'should have one audio stream').toBeGreaterThan(0) expect(audio[0].codec_name, '--audio-format opus should produce opus').toBe('opus') }, 240_000) it('video mkv + vp9 preference: merged .mkv carries a vp9 video stream + audio', () => { const opts: StartDownloadOptions = { id: 'mkv-vp9', url: 'https://www.youtube.com/watch?v=kJQP7kiw5Fk', // serves both vp9 + av01 at 360p kind: 'video', quality: '360p' } const options = { ...DEFAULT_DOWNLOAD_OPTIONS, videoContainer: 'mkv' as const, preferredVideoCodec: 'vp9' as const, embedThumbnail: false } const argv = buildArgs({ opts, outputTemplate: join(outDir, '%(id)s.%(ext)s'), options, binDir: BIN_DIR, access: NO_ACCESS }) console.log('[mkv-vp9] argv:', JSON.stringify(argv)) const res = runYtdlp(argv) if (res.status !== 0) console.error('[mkv-vp9] 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(res.filePath!.toLowerCase().endsWith('.mkv'), 'output ext should be .mkv').toBe(true) const video = streamsOfType(res.filePath!, 'video') const audio = streamsOfType(res.filePath!, 'audio') console.log( '[mkv-vp9] video:', video.map((s) => s.codec_name), 'audio:', audio.map((s) => s.codec_name) ) expect(video.length, 'should have a video stream').toBeGreaterThan(0) expect(audio.length, 'merge should pull in a separate audio stream').toBeGreaterThan(0) expect(video[0].codec_name, 'the vcodec sort should steer to vp9').toBe('vp9') }, 240_000) it('embed auto-subtitles + chapters (mp4): mov_text subtitle stream and all 3 chapters', () => { const opts: StartDownloadOptions = { id: 'subs-ch', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // has en auto-captions + 3 chapters kind: 'video', quality: '360p' } const options = { ...DEFAULT_DOWNLOAD_OPTIONS, videoContainer: 'mp4' as const, embedSubtitles: true, autoSubtitles: true, subtitleLanguages: 'en', embedChapters: true, embedThumbnail: false } const argv = buildArgs({ opts, outputTemplate: join(outDir, '%(id)s.%(ext)s'), options, binDir: BIN_DIR, access: NO_ACCESS }) console.log('[subs-ch] argv:', JSON.stringify(argv)) const res = runYtdlp(argv) if (res.status !== 0) console.error('[subs-ch] stderr:\n' + res.stderr) expect(res.status, 'yt-dlp should exit 0').toBe(0) expect(res.filePath, 'after_move path should be printed').toBeTruthy() const info = ffprobeJson(res.filePath!) const subs = (info.streams ?? []).filter((s) => s.codec_type === 'subtitle') const chapters = info.chapters ?? [] console.log( '[subs-ch] subtitles:', subs.map((s) => s.codec_name), 'chapters:', chapters.length ) expect(subs.length, 'an en auto-caption should be embedded').toBeGreaterThan(0) expect(subs[0].codec_name, 'mp4 subtitles convert to mov_text').toBe('mov_text') expect(chapters.length, 'all 3 source chapters should be embedded').toBe(3) }, 240_000) it('restrictFilenames: output basename is sanitized to a portable ASCII subset', () => { const opts: StartDownloadOptions = { id: 'restrict', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // title "Me at the zoo" kind: 'audio', quality: 'Best' } const options = { ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false } const access = { ...NO_ACCESS, restrictFilenames: true } // %(title)s so the spaces in "Me at the zoo" actually exercise the sanitizer. const argv = buildArgs({ opts, outputTemplate: join(outDir, '%(title)s.%(ext)s'), options, binDir: BIN_DIR, access }) const res = runYtdlp(argv) if (res.status !== 0) console.error('[restrict] stderr:\n' + res.stderr) expect(res.status, 'yt-dlp should exit 0').toBe(0) expect(res.filePath, 'after_move path should be printed').toBeTruthy() const base = baseName(res.filePath!) console.log('[restrict] basename:', base) expect(base, 'a restricted filename has no spaces').not.toMatch(/\s/) expect(base, 'restricted to [A-Za-z0-9._-]').toMatch(/^[A-Za-z0-9._-]+$/) }, 240_000) it('useDownloadArchive: a second run of the same URL is skipped via the archive', () => { const archive = join(outDir, 'archive.txt') const mk = (id: string): string[] => buildArgs({ opts: { id, url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' }, outputTemplate: join(outDir, 'arch-%(id)s.%(ext)s'), options: { ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false }, binDir: BIN_DIR, access: { ...NO_ACCESS, downloadArchivePath: archive } }) const first = runYtdlp(mk('a1')) if (first.status !== 0) console.error('[archive] first stderr:\n' + first.stderr) expect(first.status, 'first run should download').toBe(0) expect(first.filePath, 'first run should produce a file').toBeTruthy() expect(existsSync(archive), 'archive file should be written').toBe(true) const second = runYtdlp(mk('a2')) expect(second.status, 'second run should still exit 0').toBe(0) // The id is in the archive, so the second run downloads nothing — and thus // prints no after_move `path|` line. (yt-dlp's "has already been recorded" // notice is itself silenced because buildArgs' --print implies --quiet, so // we assert on the archive contents + the absence of a fresh download.) expect(second.filePath, 'second run should be skipped (no new download)').toBeFalsy() const archiveBody = readFileSync(archive, 'utf8') console.log('[archive] archive.txt:', JSON.stringify(archiveBody.trim())) expect(archiveBody, 'the archive should record the downloaded video id').toContain( 'jNQXAC9IVRw' ) }, 240_000) it('extraArgs (custom command): parseExtraArgs tokens reach yt-dlp (--write-info-json sidecar)', () => { const opts: StartDownloadOptions = { id: 'extra', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' } const options = { ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false } const extra = parseExtraArgs('--write-info-json --no-mtime') expect(extra, 'parseExtraArgs splits into discrete tokens').toEqual([ '--write-info-json', '--no-mtime' ]) const argv = buildArgs({ opts, outputTemplate: join(outDir, 'extra-%(id)s.%(ext)s'), options, binDir: BIN_DIR, access: NO_ACCESS, extraArgs: extra }) const res = runYtdlp(argv) if (res.status !== 0) console.error('[extra] stderr:\n' + res.stderr) expect(res.status, 'yt-dlp should exit 0').toBe(0) expect(res.filePath, 'after_move path should be printed').toBeTruthy() const infoJson = join(outDir, 'extra-jNQXAC9IVRw.info.json') console.log('[extra] info.json exists:', existsSync(infoJson)) expect(existsSync(infoJson), '--write-info-json should write a sidecar').toBe(true) }, 240_000) })