a822a2bb52
Phase C had been implemented in the working tree but never committed: named custom-command templates (CRUD + per-download override), command preview, and the in-app yt-dlp self-updater. Phase D adds: history search/kind-filter/multi-select/bulk-delete/ re-download; native OS notifications on completion/failure; a private/ incognito download mode that skips history; settings+template backup/ restore via JSON; and a persistent error log surfaced as a Diagnostics report in Settings. See ROADMAP.md for the full per-item breakdown. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
buildArgs,
|
|
parseExtraArgs,
|
|
formatCommandLine,
|
|
CROP_SQUARE_PPA,
|
|
ARIA2C_ARGS,
|
|
type AccessOptions
|
|
} 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'
|
|
const NO_ACCESS: AccessOptions = { proxy: '', rateLimit: '', restrictFilenames: false }
|
|
|
|
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,
|
|
access: AccessOptions = NO_ACCESS,
|
|
extraArgs: string[] = []
|
|
): string[] {
|
|
return buildArgs(o, OUT, d, BIN, access, extraArgs)
|
|
}
|
|
|
|
/** 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)
|
|
})
|
|
})
|
|
|
|
// --- Network: proxy / rate limit / aria2c -----------------------------------
|
|
|
|
describe('network options', () => {
|
|
it('emits nothing when proxy/rateLimit are empty and aria2c is unset', () => {
|
|
const argv = build(opts(), dlo(), NO_ACCESS)
|
|
expect(argv).not.toContain('--proxy')
|
|
expect(argv).not.toContain('--limit-rate')
|
|
expect(argv).not.toContain('--downloader')
|
|
})
|
|
|
|
it('emits --proxy with the trimmed value', () => {
|
|
const argv = build(opts(), dlo(), { ...NO_ACCESS, proxy: ' socks5://127.0.0.1:1080 ' })
|
|
expect(hasSeq(argv, '--proxy', 'socks5://127.0.0.1:1080')).toBe(true)
|
|
})
|
|
|
|
it('emits --limit-rate with the trimmed value', () => {
|
|
const argv = build(opts(), dlo(), { ...NO_ACCESS, rateLimit: ' 2M ' })
|
|
expect(hasSeq(argv, '--limit-rate', '2M')).toBe(true)
|
|
})
|
|
|
|
it('emits --downloader <path> --downloader-args when an aria2c path is given', () => {
|
|
const argv = build(opts(), dlo(), { ...NO_ACCESS, aria2cPath: 'C:/fake/bin/aria2c.exe' })
|
|
expect(hasSeq(argv, '--downloader', 'C:/fake/bin/aria2c.exe', '--downloader-args', ARIA2C_ARGS)).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
it('omits --downloader when aria2cPath is not set', () => {
|
|
const argv = build(opts(), dlo(), NO_ACCESS)
|
|
expect(argv).not.toContain('--downloader-args')
|
|
})
|
|
})
|
|
|
|
// --- Cookies: browser store vs. sign-in window's exported file --------------
|
|
|
|
describe('cookies', () => {
|
|
it('emits nothing when no cookie source is configured', () => {
|
|
const argv = build(opts(), dlo(), NO_ACCESS)
|
|
expect(argv).not.toContain('--cookies-from-browser')
|
|
expect(argv).not.toContain('--cookies')
|
|
})
|
|
|
|
it('emits --cookies-from-browser <name> when cookiesFromBrowser is set', () => {
|
|
const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFromBrowser: 'firefox' })
|
|
expect(hasSeq(argv, '--cookies-from-browser', 'firefox')).toBe(true)
|
|
expect(argv).not.toContain('--cookies')
|
|
})
|
|
|
|
it('emits --cookies <path> when only cookiesFile is set', () => {
|
|
const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFile: 'C:/userdata/cookies.txt' })
|
|
expect(hasSeq(argv, '--cookies', 'C:/userdata/cookies.txt')).toBe(true)
|
|
expect(argv).not.toContain('--cookies-from-browser')
|
|
})
|
|
|
|
it('prefers cookiesFromBrowser over cookiesFile when both are set', () => {
|
|
const argv = build(opts(), dlo(), {
|
|
...NO_ACCESS,
|
|
cookiesFromBrowser: 'chrome',
|
|
cookiesFile: 'C:/userdata/cookies.txt'
|
|
})
|
|
expect(hasSeq(argv, '--cookies-from-browser', 'chrome')).toBe(true)
|
|
expect(argv).not.toContain('--cookies')
|
|
})
|
|
})
|
|
|
|
// --- Restrict filenames / download archive -----------------------------------
|
|
|
|
describe('restrictFilenames / downloadArchivePath', () => {
|
|
it('emits --restrict-filenames when set', () => {
|
|
expect(build(opts(), dlo(), { ...NO_ACCESS, restrictFilenames: true })).toContain(
|
|
'--restrict-filenames'
|
|
)
|
|
})
|
|
|
|
it('omits --restrict-filenames by default', () => {
|
|
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--restrict-filenames')
|
|
})
|
|
|
|
it('emits --download-archive <path> when a path is given', () => {
|
|
const argv = build(opts(), dlo(), { ...NO_ACCESS, downloadArchivePath: 'C:/userdata/archive.txt' })
|
|
expect(hasSeq(argv, '--download-archive', 'C:/userdata/archive.txt')).toBe(true)
|
|
})
|
|
|
|
it('omits --download-archive when no path is given', () => {
|
|
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--download-archive')
|
|
})
|
|
})
|
|
|
|
// --- 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')
|
|
})
|
|
})
|
|
|
|
// --- Custom commands (Phase C): extra args + display formatting ------------
|
|
|
|
describe('parseExtraArgs', () => {
|
|
it('splits on whitespace', () => {
|
|
expect(parseExtraArgs('--write-thumbnail --no-mtime')).toEqual([
|
|
'--write-thumbnail',
|
|
'--no-mtime'
|
|
])
|
|
})
|
|
|
|
it('keeps a double-quoted span with spaces as one token', () => {
|
|
expect(parseExtraArgs('--proxy "http://example.com:8080"')).toEqual([
|
|
'--proxy',
|
|
'http://example.com:8080'
|
|
])
|
|
})
|
|
|
|
it('keeps a single-quoted span with spaces as one token', () => {
|
|
expect(parseExtraArgs("--output-na 'My Title - %(id)s.%(ext)s'")).toEqual([
|
|
'--output-na',
|
|
'My Title - %(id)s.%(ext)s'
|
|
])
|
|
})
|
|
|
|
it('returns an empty array for blank input', () => {
|
|
expect(parseExtraArgs('')).toEqual([])
|
|
expect(parseExtraArgs(' ')).toEqual([])
|
|
})
|
|
|
|
it('collapses repeated whitespace between tokens', () => {
|
|
expect(parseExtraArgs('--verbose --no-progress')).toEqual(['--verbose', '--no-progress'])
|
|
})
|
|
})
|
|
|
|
describe('formatCommandLine', () => {
|
|
it('joins the exe and args with spaces when nothing needs quoting', () => {
|
|
expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
|
|
'yt-dlp.exe -f best -- https://x.test/v'
|
|
)
|
|
})
|
|
|
|
it('quotes an arg containing a space', () => {
|
|
expect(formatCommandLine('yt-dlp.exe', ['--ppa', 'EmbedThumbnail+ffmpeg_o:-c:v mjpeg'])).toBe(
|
|
'yt-dlp.exe --ppa "EmbedThumbnail+ffmpeg_o:-c:v mjpeg"'
|
|
)
|
|
})
|
|
|
|
it('escapes embedded double quotes', () => {
|
|
expect(formatCommandLine('yt-dlp.exe', ['say "hi"'])).toBe('yt-dlp.exe "say \\"hi\\""')
|
|
})
|
|
|
|
it('renders an empty-string arg as ""', () => {
|
|
expect(formatCommandLine('yt-dlp.exe', [''])).toBe('yt-dlp.exe ""')
|
|
})
|
|
})
|
|
|
|
describe('buildArgs extraArgs', () => {
|
|
it('appends extraArgs after every other option, still before -- url', () => {
|
|
const argv = build(opts(), dlo(), NO_ACCESS, ['--write-thumbnail', '--no-mtime'])
|
|
expect(hasSeq(argv, '--write-thumbnail', '--no-mtime', '--', URL)).toBe(true)
|
|
})
|
|
|
|
it('omits nothing extra when extraArgs is empty', () => {
|
|
const withEmpty = build(opts(), dlo(), NO_ACCESS, [])
|
|
const withoutParam = build(opts(), dlo())
|
|
expect(withEmpty).toEqual(withoutParam)
|
|
})
|
|
})
|