24c4c807a3
- YouTube reliability: Settings.youtubePlayerClient + youtubePoToken -> one --extractor-args "youtube:player_client=...;po_token=..." group (accessArgs, unit-tested); advanced fields in Settings -> Network. Automatic WebView PO-token minting deferred (documented). - docs/SMOKE-TEST.md: release-gate checklist for all shipped-but-untested OS wiring (cookies window, protocol/Send-to, scheduled sync, aria2c, proxy, tray/taskbar/pause-resume/terminal) — needs a human to run. - docs/SIGNING.md: code-signing guide (CSC_LINK/CSC_KEY_PASSWORD, OV/EV, CI, HSM hook, verification). Build is signing-ready; needs a cert. - i18n remains deferred (not faked). typecheck + test (195) + build all clean. Roadmap: Phase P code shipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
678 lines
24 KiB
TypeScript
678 lines
24 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
buildArgs,
|
|
parseExtraArgs,
|
|
parseTrimSections,
|
|
selectExtraArgs,
|
|
formatCommandLine,
|
|
sanitizeDirSegment,
|
|
collectionOutputTemplate,
|
|
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('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({
|
|
customCommandEnabled: false,
|
|
perDownloadExtraArgs: '--exec "calc.exe"',
|
|
defaultTemplateId: 'danger',
|
|
templates
|
|
})
|
|
).toEqual([])
|
|
})
|
|
|
|
it('returns [] when disabled even if a default template id is set', () => {
|
|
expect(
|
|
selectExtraArgs({
|
|
customCommandEnabled: false,
|
|
perDownloadExtraArgs: undefined,
|
|
defaultTemplateId: 'thumb',
|
|
templates
|
|
})
|
|
).toEqual([])
|
|
})
|
|
|
|
it('parses a per-download override when enabled', () => {
|
|
expect(
|
|
selectExtraArgs({
|
|
customCommandEnabled: 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({
|
|
customCommandEnabled: true,
|
|
perDownloadExtraArgs: '',
|
|
defaultTemplateId: 'thumb',
|
|
templates
|
|
})
|
|
).toEqual([])
|
|
})
|
|
|
|
it('falls back to the default template when no per-download override is given', () => {
|
|
expect(
|
|
selectExtraArgs({
|
|
customCommandEnabled: true,
|
|
perDownloadExtraArgs: undefined,
|
|
defaultTemplateId: 'thumb',
|
|
templates
|
|
})
|
|
).toEqual(['--write-thumbnail'])
|
|
})
|
|
|
|
it('returns [] when the default template id matches nothing', () => {
|
|
expect(
|
|
selectExtraArgs({
|
|
customCommandEnabled: 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 = {
|
|
customCommandEnabled: 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([])
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|