Files
AeroFetch/test/buildArgs.test.ts
T
debont80 42b7b2e01f feat(roadmap): surface stale checkboxes + collection output-path setting
Command preview + incognito toggle were already wired end-to-end in the
DownloadBar (Show command button, Incognito checkbox) — flipped the two
stale ROADMAP.md checkboxes with notes; verified live in the preview.

Output-path setting (PINCHFLAT): new Settings.collectionOutputTemplate — a
raw yt-dlp -o template overriding the default Channel/Playlist/NNN-Title
layout for Library downloads (empty = default). collectionOutputTemplate()
takes an optional custom template (joined under outDir, left for yt-dlp to
expand); download.ts passes the setting; zod schema validates it as a safe
relative template; Settings -> Filenames field. Tests in buildArgs +
settingsSchema; live-verified the field renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:44:44 -04:00

800 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 aria2cs 116 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
})
})
// --- 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')
})
})