5c88d9c8e0
Re-implements the sleepRequests/sleepInterval/maxSleepInterval feature (--sleep-requests / --sleep-interval / --max-sleep-interval) from the stale feat/binary-management-and-library-scale branch, ported onto current main's structure (zod settingsSchema, decomposed NetworkCard). All default to 0/off, preserving current behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
840 lines
31 KiB
TypeScript
840 lines
31 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import {
|
||
buildArgs,
|
||
parseExtraArgs,
|
||
parseTrimSections,
|
||
selectExtraArgs,
|
||
formatCommandLine,
|
||
sanitizeDirSegment,
|
||
collectionOutputTemplate,
|
||
CROP_SQUARE_PPA,
|
||
ARIA2C_ARGS,
|
||
aria2cArgs,
|
||
DEST_MARKER,
|
||
type AccessOptions
|
||
} from '../src/main/core/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', ...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({ opts: o, outputTemplate: OUT, options: d, binDir: 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)
|
||
})
|
||
|
||
it('prints the before_dl destination (dest|) so a cancel can locate partials (R4)', () => {
|
||
// download.ts's cancel cleanup depends on this print to know the output stem;
|
||
// removing it would silently disable orphan-.part deletion.
|
||
expect(hasSeq(build(opts(), dlo()), '--print', `before_dl:${DEST_MARKER}%(filename)s`)).toBe(
|
||
true
|
||
)
|
||
})
|
||
})
|
||
|
||
// --- 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')
|
||
})
|
||
|
||
it('threads a custom aria2c connection count into --downloader-args (L64)', () => {
|
||
const argv = build(opts(), dlo(), {
|
||
...NO_ACCESS,
|
||
aria2cPath: 'C:/fake/bin/aria2c.exe',
|
||
aria2cConnections: 4
|
||
})
|
||
expect(hasSeq(argv, '--downloader-args', 'aria2c:-x 4 -s 4 -k 1M')).toBe(true)
|
||
})
|
||
|
||
it('aria2cArgs clamps to aria2c’s 1–16 ceiling and defaults to 16 (L64)', () => {
|
||
expect(aria2cArgs()).toBe('aria2c:-x 16 -s 16 -k 1M')
|
||
expect(aria2cArgs(8)).toBe('aria2c:-x 8 -s 8 -k 1M')
|
||
expect(aria2cArgs(0)).toBe('aria2c:-x 1 -s 1 -k 1M') // below floor → 1
|
||
expect(aria2cArgs(99)).toBe('aria2c:-x 16 -s 16 -k 1M') // above ceiling → 16
|
||
expect(aria2cArgs(7.6)).toBe('aria2c:-x 8 -s 8 -k 1M') // rounds
|
||
expect(aria2cArgs(NaN)).toBe('aria2c:-x 16 -s 16 -k 1M') // non-finite → default
|
||
expect(ARIA2C_ARGS).toBe(aria2cArgs()) // the exported default stays in sync
|
||
})
|
||
})
|
||
|
||
// --- Request throttle: sleep-requests / sleep-interval ----------------------
|
||
|
||
describe('throttle options', () => {
|
||
it('emits no sleep flags when all throttle values are 0/unset', () => {
|
||
const argv = build(opts(), dlo(), NO_ACCESS)
|
||
expect(argv).not.toContain('--sleep-requests')
|
||
expect(argv).not.toContain('--sleep-interval')
|
||
expect(argv).not.toContain('--max-sleep-interval')
|
||
})
|
||
|
||
it('emits --sleep-requests when sleepRequests > 0', () => {
|
||
const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepRequests: 3 })
|
||
expect(hasSeq(argv, '--sleep-requests', '3')).toBe(true)
|
||
})
|
||
|
||
it('emits --sleep-interval when sleepInterval > 0', () => {
|
||
const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 5 })
|
||
expect(hasSeq(argv, '--sleep-interval', '5')).toBe(true)
|
||
expect(argv).not.toContain('--max-sleep-interval')
|
||
})
|
||
|
||
it('emits --max-sleep-interval only when it exceeds a non-zero sleepInterval', () => {
|
||
const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 5, maxSleepInterval: 10 })
|
||
expect(hasSeq(argv, '--sleep-interval', '5')).toBe(true)
|
||
expect(hasSeq(argv, '--max-sleep-interval', '10')).toBe(true)
|
||
})
|
||
|
||
it('drops --max-sleep-interval when sleepInterval is 0 (yt-dlp needs the min)', () => {
|
||
const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 0, maxSleepInterval: 10 })
|
||
expect(argv).not.toContain('--sleep-interval')
|
||
expect(argv).not.toContain('--max-sleep-interval')
|
||
})
|
||
|
||
it('drops --max-sleep-interval when it is not above the min', () => {
|
||
const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 8, maxSleepInterval: 5 })
|
||
expect(hasSeq(argv, '--sleep-interval', '8')).toBe(true)
|
||
expect(argv).not.toContain('--max-sleep-interval')
|
||
})
|
||
})
|
||
|
||
// --- 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' }), 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', sponsorBlockCategories: ['sponsor'] })
|
||
)
|
||
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', sponsorBlockCategories: ['sponsor'] })
|
||
)
|
||
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('targets the EmbedThumbnail post-processor and uses ffmpeg output args (L39)', () => {
|
||
// Structural test: verify the key invariants without locking in the exact string,
|
||
// so minor whitespace or quoting style tweaks don't break CI.
|
||
expect(CROP_SQUARE_PPA).toMatch(/^EmbedThumbnail\+ffmpeg_o:/)
|
||
expect(CROP_SQUARE_PPA).toContain('-c:v mjpeg')
|
||
expect(CROP_SQUARE_PPA).toMatch(/-vf\s+crop=/)
|
||
// Commas inside gt() MUST be inside quotes so ffmpeg doesn't split on them.
|
||
expect(CROP_SQUARE_PPA).toMatch(/crop="[^"]*,/)
|
||
})
|
||
|
||
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')
|
||
})
|
||
|
||
it('suppresses --embed-thumbnail for webm (not supported by the container, L38)', () => {
|
||
const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'webm' }))
|
||
expect(argv).not.toContain('--embed-thumbnail')
|
||
})
|
||
|
||
it('keeps --embed-thumbnail for mp4 container (L38)', () => {
|
||
const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'mp4' }))
|
||
expect(argv).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('selectExtraArgs — custom-command consent gate (audit F2)', () => {
|
||
const templates = [
|
||
{ id: 'thumb', args: '--write-thumbnail' },
|
||
{ id: 'danger', args: '--exec "calc.exe"' }
|
||
]
|
||
|
||
it('returns [] when custom commands are disabled, even with a per-download override', () => {
|
||
// The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
|
||
expect(
|
||
selectExtraArgs({
|
||
enableCustomCommands: false,
|
||
perDownloadExtraArgs: '--exec "calc.exe"',
|
||
defaultTemplateId: 'danger',
|
||
templates
|
||
})
|
||
).toEqual([])
|
||
})
|
||
|
||
it('returns [] when disabled even if a default template id is set', () => {
|
||
expect(
|
||
selectExtraArgs({
|
||
enableCustomCommands: false,
|
||
perDownloadExtraArgs: undefined,
|
||
defaultTemplateId: 'thumb',
|
||
templates
|
||
})
|
||
).toEqual([])
|
||
})
|
||
|
||
it('parses a per-download override when enabled', () => {
|
||
expect(
|
||
selectExtraArgs({
|
||
enableCustomCommands: true,
|
||
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
|
||
defaultTemplateId: null,
|
||
templates
|
||
})
|
||
).toEqual(['--write-thumbnail', '--no-mtime'])
|
||
})
|
||
|
||
it('an explicit empty override yields [] even with a default template set', () => {
|
||
expect(
|
||
selectExtraArgs({
|
||
enableCustomCommands: true,
|
||
perDownloadExtraArgs: '',
|
||
defaultTemplateId: 'thumb',
|
||
templates
|
||
})
|
||
).toEqual([])
|
||
})
|
||
|
||
it('falls back to the default template when no per-download override is given', () => {
|
||
expect(
|
||
selectExtraArgs({
|
||
enableCustomCommands: true,
|
||
perDownloadExtraArgs: undefined,
|
||
defaultTemplateId: 'thumb',
|
||
templates
|
||
})
|
||
).toEqual(['--write-thumbnail'])
|
||
})
|
||
|
||
it('returns [] when the default template id matches nothing', () => {
|
||
expect(
|
||
selectExtraArgs({
|
||
enableCustomCommands: true,
|
||
perDownloadExtraArgs: undefined,
|
||
defaultTemplateId: 'missing',
|
||
templates
|
||
})
|
||
).toEqual([])
|
||
})
|
||
})
|
||
|
||
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)
|
||
})
|
||
})
|
||
|
||
// --- Media-server sidecar files (Phase K) -----------------------------------
|
||
|
||
describe('sidecar files', () => {
|
||
it('emits --write-info-json / --write-thumbnail / --write-description when enabled', () => {
|
||
const argv = build(
|
||
opts(),
|
||
dlo({ writeInfoJson: true, writeThumbnailFile: true, writeDescription: true })
|
||
)
|
||
expect(argv).toContain('--write-info-json')
|
||
expect(argv).toContain('--write-thumbnail')
|
||
expect(argv).toContain('--write-description')
|
||
})
|
||
|
||
it('emits none of them by default', () => {
|
||
const argv = build(opts(), dlo())
|
||
expect(argv).not.toContain('--write-info-json')
|
||
expect(argv).not.toContain('--write-description')
|
||
// (--write-thumbnail is sidecar-only here; embedThumbnail uses --embed-thumbnail)
|
||
expect(argv).not.toContain('--write-thumbnail')
|
||
})
|
||
})
|
||
|
||
describe('YouTube extractor-args', () => {
|
||
it('combines player_client and po_token into one youtube: group', () => {
|
||
const argv = build(opts(), dlo(), {
|
||
...NO_ACCESS,
|
||
youtubePlayerClient: 'web_safari',
|
||
youtubePoToken: 'web.gvs+ABC'
|
||
})
|
||
expect(
|
||
hasSeq(argv, '--extractor-args', 'youtube:player_client=web_safari;po_token=web.gvs+ABC')
|
||
).toBe(true)
|
||
})
|
||
|
||
it('emits only the fields that are set', () => {
|
||
const argv = build(opts(), dlo(), { ...NO_ACCESS, youtubePlayerClient: 'tv' })
|
||
expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=tv')).toBe(true)
|
||
})
|
||
|
||
it('emits nothing when both are empty', () => {
|
||
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--extractor-args')
|
||
})
|
||
})
|
||
|
||
describe('format sorting (-S)', () => {
|
||
it('emits a raw -S string when formatSort is set, overriding the codec tiebreaker', () => {
|
||
const argv = build(
|
||
opts(),
|
||
dlo({ formatSort: 'res:1080,vcodec:av01', preferredVideoCodec: 'h264' })
|
||
)
|
||
expect(hasSeq(argv, '-S', 'res:1080,vcodec:av01')).toBe(true)
|
||
expect(hasSeq(argv, '-S', 'res,fps,vcodec:h264')).toBe(false)
|
||
})
|
||
|
||
it('falls back to the codec tiebreaker when formatSort is empty', () => {
|
||
const argv = build(opts(), dlo({ formatSort: '', preferredVideoCodec: 'vp9' }))
|
||
expect(hasSeq(argv, '-S', 'res,fps,vcodec:vp9')).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('selectExtraArgs url-pattern matching', () => {
|
||
const base = {
|
||
enableCustomCommands: true,
|
||
perDownloadExtraArgs: undefined,
|
||
defaultTemplateId: null as string | null
|
||
}
|
||
const templates = [
|
||
{ id: 'a', args: '--audio-quality 0', urlPattern: 'soundcloud\\.com' },
|
||
{ id: 'b', args: '--write-thumbnail' }
|
||
]
|
||
|
||
it('auto-applies a template whose urlPattern matches the URL', () => {
|
||
const out = selectExtraArgs({ ...base, templates, url: 'https://soundcloud.com/x/y' })
|
||
expect(out).toEqual(['--audio-quality', '0'])
|
||
})
|
||
|
||
it('does not apply a urlPattern template to a non-matching URL', () => {
|
||
const out = selectExtraArgs({ ...base, templates, url: 'https://youtube.com/watch?v=x' })
|
||
expect(out).toEqual([])
|
||
})
|
||
|
||
it('urlPattern match takes precedence over the global default template', () => {
|
||
const out = selectExtraArgs({
|
||
...base,
|
||
defaultTemplateId: 'b',
|
||
templates,
|
||
url: 'https://soundcloud.com/x'
|
||
})
|
||
expect(out).toEqual(['--audio-quality', '0'])
|
||
})
|
||
|
||
it('a bad regex never matches (and is skipped safely)', () => {
|
||
const out = selectExtraArgs({
|
||
...base,
|
||
templates: [{ id: 'c', args: '--x', urlPattern: '(' }],
|
||
url: 'https://soundcloud.com/x'
|
||
})
|
||
expect(out).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('chapters', () => {
|
||
it('emits --split-chapters when splitChapters is on', () => {
|
||
expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters')
|
||
})
|
||
|
||
it('does not emit --split-chapters by default', () => {
|
||
expect(build(opts(), dlo())).not.toContain('--split-chapters')
|
||
})
|
||
|
||
it('emits --split-chapters independently of --embed-chapters', () => {
|
||
const argv = build(opts(), dlo({ splitChapters: true, embedChapters: false }))
|
||
expect(argv).toContain('--split-chapters')
|
||
expect(argv).not.toContain('--embed-chapters')
|
||
})
|
||
})
|
||
|
||
describe('parseTrimSections', () => {
|
||
it('normalises plain time ranges to *START-END specs', () => {
|
||
expect(parseTrimSections('1:30-2:00')).toEqual(['*1:30-2:00'])
|
||
expect(parseTrimSections('90-120')).toEqual(['*90-120'])
|
||
expect(parseTrimSections('0:00:10.5-0:00:20')).toEqual(['*0:00:10.5-0:00:20'])
|
||
})
|
||
|
||
it('splits on commas and newlines and trims whitespace', () => {
|
||
expect(parseTrimSections(' 0:30-1:00 , 2:00-2:30 \n 3:00-3:30 ')).toEqual([
|
||
'*0:30-1:00',
|
||
'*2:00-2:30',
|
||
'*3:00-3:30'
|
||
])
|
||
})
|
||
|
||
it('keeps an existing leading * but does not double it', () => {
|
||
expect(parseTrimSections('*1:00-2:00')).toEqual(['*1:00-2:00'])
|
||
})
|
||
|
||
it('drops malformed tokens so garbage never reaches the argv', () => {
|
||
expect(parseTrimSections('not-a-range')).toEqual([])
|
||
expect(parseTrimSections('1:30')).toEqual([]) // no end
|
||
expect(parseTrimSections('')).toEqual([])
|
||
expect(parseTrimSections(undefined)).toEqual([])
|
||
})
|
||
|
||
it('rejects times with more than two colon groups (L146)', () => {
|
||
// H:MM:SS is the deepest valid form; a fourth component is nonsense and used
|
||
// to slip through to yt-dlp, failing there instead of being rejected here.
|
||
expect(parseTrimSections('1:2:3:4-5:6:7:8')).toEqual([])
|
||
expect(parseTrimSections('0:00:00:01-0:00:00:02')).toEqual([])
|
||
// The deepest valid form still passes.
|
||
expect(parseTrimSections('1:02:03-1:02:04')).toEqual(['*1:02:03-1:02:04'])
|
||
})
|
||
})
|
||
|
||
describe('trim (--download-sections)', () => {
|
||
it('emits a --download-sections spec per range plus --force-keyframes-at-cuts', () => {
|
||
const argv = build(opts({ trim: '0:30-1:00, 2:00-2:30' }), dlo())
|
||
expect(hasSeq(argv, '--download-sections', '*0:30-1:00')).toBe(true)
|
||
expect(hasSeq(argv, '--download-sections', '*2:00-2:30')).toBe(true)
|
||
expect(argv).toContain('--force-keyframes-at-cuts')
|
||
})
|
||
|
||
it('emits nothing when there is no trim', () => {
|
||
const argv = build(opts(), dlo())
|
||
expect(argv).not.toContain('--download-sections')
|
||
})
|
||
|
||
it('does not double --force-keyframes-at-cuts when SponsorBlock-remove is also on', () => {
|
||
const argv = build(
|
||
opts({ trim: '0:30-1:00' }),
|
||
dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] })
|
||
)
|
||
expect(argv.filter((a) => a === '--force-keyframes-at-cuts')).toHaveLength(1)
|
||
})
|
||
})
|
||
|
||
// --- Collection folder paths (Phase G, media-manager) -----------------------
|
||
|
||
describe('sanitizeDirSegment', () => {
|
||
it('keeps an ordinary name (incl. spaces and hyphens) intact', () => {
|
||
expect(sanitizeDirSegment('Linus Tech Tips')).toBe('Linus Tech Tips')
|
||
expect(sanitizeDirSegment('Two-Minute Papers')).toBe('Two-Minute Papers')
|
||
})
|
||
|
||
it('replaces Windows-illegal characters with a space and collapses runs', () => {
|
||
expect(sanitizeDirSegment('A/B\\C:D*E?F')).toBe('A B C D E F')
|
||
expect(sanitizeDirSegment('Best of 2024 <Live>')).toBe('Best of 2024 Live')
|
||
})
|
||
|
||
it('strips leading/trailing dots and spaces, neutralising `..` traversal', () => {
|
||
expect(sanitizeDirSegment('..')).toBe('Untitled')
|
||
expect(sanitizeDirSegment('../../etc')).toBe('etc')
|
||
expect(sanitizeDirSegment(' trailing. ')).toBe('trailing')
|
||
expect(sanitizeDirSegment('My Playlist.')).toBe('My Playlist')
|
||
})
|
||
|
||
it('prefixes reserved Windows device names', () => {
|
||
expect(sanitizeDirSegment('CON')).toBe('_CON')
|
||
expect(sanitizeDirSegment('com1')).toBe('_com1')
|
||
})
|
||
|
||
it('prefixes a reserved device name even when it carries an extension (audit T3)', () => {
|
||
expect(sanitizeDirSegment('CON.txt')).toBe('_CON.txt')
|
||
expect(sanitizeDirSegment('nul.mp4')).toBe('_nul.mp4')
|
||
expect(sanitizeDirSegment('LPT1.foo.bar')).toBe('_LPT1.foo.bar')
|
||
// a non-reserved name that merely starts with similar letters is left alone
|
||
expect(sanitizeDirSegment('console.log')).toBe('console.log')
|
||
})
|
||
|
||
it('falls back to Untitled for empty / all-illegal input', () => {
|
||
expect(sanitizeDirSegment('')).toBe('Untitled')
|
||
expect(sanitizeDirSegment('???')).toBe('Untitled')
|
||
})
|
||
|
||
it('caps length to keep the overall path bounded', () => {
|
||
expect(sanitizeDirSegment('x'.repeat(200)).length).toBe(80)
|
||
})
|
||
})
|
||
|
||
describe('collectionOutputTemplate', () => {
|
||
const ctx = { channel: 'DevChannel', playlist: 'Full Course', index: 3 }
|
||
|
||
it('files into <out>/<channel>/<playlist>/<NNN> - <filename>', () => {
|
||
const t = collectionOutputTemplate('C:/out', ctx, '%(title)s.%(ext)s')
|
||
// normalise separators so the assertion is OS-independent
|
||
expect(t.replace(/\\/g, '/')).toBe('C:/out/DevChannel/Full Course/003 - %(title)s.%(ext)s')
|
||
})
|
||
|
||
it('zero-pads the index to three digits and clamps bad values to 001', () => {
|
||
expect(
|
||
collectionOutputTemplate('C:/o', { ...ctx, index: 42 }, 'f').replace(/\\/g, '/')
|
||
).toContain('/042 - f')
|
||
expect(
|
||
collectionOutputTemplate('C:/o', { ...ctx, index: 0 }, 'f').replace(/\\/g, '/')
|
||
).toContain('/001 - f')
|
||
expect(
|
||
collectionOutputTemplate('C:/o', { ...ctx, index: NaN }, 'f').replace(/\\/g, '/')
|
||
).toContain('/001 - f')
|
||
})
|
||
|
||
it('sanitizes the channel and playlist folder names', () => {
|
||
const t = collectionOutputTemplate(
|
||
'C:/out',
|
||
{ channel: 'A/B', playlist: '..', index: 1 },
|
||
'%(title)s.%(ext)s'
|
||
)
|
||
expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s')
|
||
})
|
||
|
||
it('a custom template overrides the built-in layout and is left for yt-dlp to expand', () => {
|
||
const t = collectionOutputTemplate(
|
||
'C:/out',
|
||
ctx,
|
||
'%(title)s.%(ext)s',
|
||
'%(uploader)s/%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s'
|
||
)
|
||
expect(t.replace(/\\/g, '/')).toBe(
|
||
'C:/out/%(uploader)s/%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s'
|
||
)
|
||
})
|
||
|
||
it('a blank/whitespace custom template falls back to the built-in layout', () => {
|
||
expect(collectionOutputTemplate('C:/o', ctx, 'f', '').replace(/\\/g, '/')).toBe(
|
||
'C:/o/DevChannel/Full Course/003 - f'
|
||
)
|
||
expect(collectionOutputTemplate('C:/o', ctx, 'f', ' ').replace(/\\/g, '/')).toBe(
|
||
'C:/o/DevChannel/Full Course/003 - f'
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('metadata overrides (--replace-in-metadata)', () => {
|
||
it('emits --embed-metadata and --replace-in-metadata for each set field', () => {
|
||
const argv = build(opts(), dlo({ embedMetadata: false, metadataTitle: 'My Track' }))
|
||
expect(argv).toContain('--embed-metadata')
|
||
expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'My Track')).toBe(true)
|
||
})
|
||
|
||
it('emits all three override fields when all are set', () => {
|
||
const argv = build(opts(), dlo({ metadataTitle: 'T', metadataArtist: 'A', metadataAlbum: 'B' }))
|
||
expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'T')).toBe(true)
|
||
expect(hasSeq(argv, '--replace-in-metadata', 'artist', '^.*$', 'A')).toBe(true)
|
||
expect(hasSeq(argv, '--replace-in-metadata', 'album', '^.*$', 'B')).toBe(true)
|
||
})
|
||
|
||
it('passes a value containing a colon as a single arg without splitting', () => {
|
||
const argv = build(opts(), dlo({ metadataTitle: 'Foo: Bar' }))
|
||
expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'Foo: Bar')).toBe(true)
|
||
})
|
||
|
||
it('escapes backslashes in the replacement string', () => {
|
||
const argv = build(opts(), dlo({ metadataTitle: 'A\\B' }))
|
||
expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'A\\\\B')).toBe(true)
|
||
})
|
||
|
||
it('skips blank or whitespace-only values', () => {
|
||
const argv = build(opts(), dlo({ metadataTitle: '', metadataArtist: ' ' }))
|
||
expect(argv).not.toContain('--replace-in-metadata')
|
||
})
|
||
|
||
it('forces --embed-metadata even when embedMetadata is false and an override is set', () => {
|
||
const argv = build(opts(), dlo({ embedMetadata: false, metadataAlbum: 'Soundtrack' }))
|
||
expect(argv).toContain('--embed-metadata')
|
||
})
|
||
|
||
it('emits no override flags when no fields are set', () => {
|
||
const argv = build(opts(), dlo({ embedMetadata: true }))
|
||
expect(argv).not.toContain('--replace-in-metadata')
|
||
})
|
||
})
|