9134e7d216
CC12: renderer views/ (5 screens + Onboarding + settings cards) + lib/ (theme/thumb/datetime/id/qualityOptions/useClipboardLink/queueStats); main core/ (buildArgs/validation/indexerCore/ytdlpPolicy). git mv preserved history; all src+test imports updated. Build emits view chunks from views/, 344 tests + typecheck green, live probe rendered all screens. CC7: wc/onProgress is the leading arg everywhere — downloadAppUpdate(wc,url), indexSource(onProgress,url,signal), indexSourceCancelable(onProgress,url). CC10: closed by-design — jsonStore backs all records; settings stay in electron-store for DPAPI secret encryption (a deliberate two-store split). Also closes the UI24/W4 context-menu cross-reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
/**
|
|
* 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/core/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|<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
|
|
}
|
|
|
|
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)
|
|
})
|