Bundle ffprobe.exe and complete the real-download smoke-test pass

A live smoke test of buildArgs' argv against the bundled yt-dlp + ffmpeg found
that ffprobe.exe was never bundled (resources/bin/ shipped only ffmpeg.exe).
yt-dlp resolves both from --ffmpeg-location, so duration-aware post-processing
failed at runtime for every user with "ffprobe not found": --sponsorblock-remove,
--force-keyframes-at-cuts, and --split-chapters (CODE-AUDIT C1).

- Bundle ffprobe.exe (matching n8.1.2 LGPL build, sha256-verified against the
  already-bundled ffmpeg.exe) and document it in resources/bin/README.md as a
  required binary; correct the stale "not committed to git" note.
- Guard startDownload against a missing ffmpeg.exe/ffprobe.exe up front so the
  failure is a clear AeroFetch error, not a cryptic yt-dlp postprocessing one
  (new getFfprobePath() in binaries.ts).
- Expand test/real-download.integration.test.ts from 2 to 8 live cases: crop,
  sponsorblock-remove, audio opus re-encode, mkv+vp9 merge, subtitle+chapter
  embed, restrict-filenames, download-archive skip, and Phase C extra-args.
  All 8 pass against live yt-dlp + ffmpeg.
- ROADMAP: mark the Phase A/B/C smoke-test caveats done. CODE-AUDIT: add C1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 10:57:12 -04:00
parent b08d76a986
commit 18e1d9a24d
7 changed files with 305 additions and 21 deletions
+224 -2
View File
@@ -10,10 +10,10 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { spawnSync } from 'child_process'
import { mkdtempSync, existsSync, rmSync, statSync } from 'fs'
import { mkdtempSync, existsSync, rmSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
import { join, resolve } from 'path'
import { buildArgs } from '../src/main/buildArgs'
import { buildArgs, parseExtraArgs } from '../src/main/buildArgs'
import { DEFAULT_DOWNLOAD_OPTIONS, type StartDownloadOptions } from '@shared/ipc'
const RUN = process.env.AEROFETCH_REAL_DOWNLOAD === '1'
@@ -21,6 +21,7 @@ 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 {
@@ -79,11 +80,47 @@ function probeSourceDuration(url: string): number | undefined {
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(() => {
@@ -164,4 +201,189 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
},
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, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, 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, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, 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, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, 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, join(outDir, '%(title)s.%(ext)s'), options, 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(
'downloadArchive: a second run of the same URL is skipped via the archive',
() => {
const archive = join(outDir, 'archive.txt')
const mk = (id: string): string[] =>
buildArgs(
{ id, url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' },
join(outDir, 'arch-%(id)s.%(ext)s'),
{ ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false },
BIN_DIR,
{ ...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, join(outDir, 'extra-%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS, 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
)
})