67133f13b5
Implements the remaining Phase B (Access & networking) items from ROADMAP.md: cookie sources (browser cookie-store import, or a built-in sign-in window that exports a Netscape cookie file via a persisted Electron session partition), the aria2c external downloader, proxy/rate-limit, restrict-filenames, and a download-archive to skip repeats. Wires download.ts to the buildArgs() module added in the previous commit (it wasn't hooked up yet) and renames its options param NetworkOptions -> AccessOptions to reflect the wider scope now that cookies and filename/archive flags joined proxy/rate-limit/aria2c. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
168 lines
6.1 KiB
TypeScript
168 lines
6.1 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 } 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')
|
|
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
|
|
}
|
|
|
|
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, 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 (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, 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
|
|
)
|
|
})
|