Harden audit findings: correctness, type-safety, Windows conventions & polish
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified — typecheck + 234 tests + eslint + prettier green). Correctness / bugs: - B3: match the release checksum to the asset's filename line (no wrong-hash verify) - B4: newline-safe metadata probe (one --print with a unit-separator delimiter) - B5 / L88: guard the meta event against canceled items; progress no longer promotes a queued item outside pump() - B7: cookie-login promise always resolves (handles destroy-without-close) - L146: trim parser rejects >2 colon-group times; M36: Library selection counts only actionable rows - L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message, schedule picker min, dead-code/comment cleanup Type safety: - Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed) Resilience / Windows / metadata: - R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth) - W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window; L147 dead macOS branches removed - CL1: shared stdout markers; package/builder metadata (license, homepage, repository, copyright, tsbuildinfo glob) Copy / docs / tests: - M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled - New unit tests for L35 (isValidMediaItem) and L36 (compareVersions) This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard work it builds on: background running + auto-download, library clipboard detection, tray, binary management & library scale, credential encryption at rest, the shared jsonStore and ui/ primitives, and the eslint/prettier tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -72,10 +72,14 @@ function parseCoverDims(info: string): { w: number; h: number } | undefined {
|
||||
}
|
||||
|
||||
function probeSourceDuration(url: string): number | undefined {
|
||||
const r = spawnSync(YTDLP, ['--no-warnings', '--no-playlist', '--print', '%(duration)s', '--', url], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
})
|
||||
const r = spawnSync(
|
||||
YTDLP,
|
||||
['--no-warnings', '--no-playlist', '--print', '%(duration)s', '--', url],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
const n = Number((r.stdout || '').trim())
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
@@ -95,7 +99,16 @@ function ffprobeJson(file: string): {
|
||||
} {
|
||||
const r = spawnSync(
|
||||
FFPROBE,
|
||||
['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', '-show_chapters', file],
|
||||
[
|
||||
'-v',
|
||||
'quiet',
|
||||
'-print_format',
|
||||
'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
'-show_chapters',
|
||||
file
|
||||
],
|
||||
{ encoding: 'utf8', windowsHide: true, maxBuffer: 32 * 1024 * 1024 }
|
||||
)
|
||||
try {
|
||||
@@ -128,262 +141,260 @@ afterAll(() => {
|
||||
})
|
||||
|
||||
describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
||||
it(
|
||||
'cropThumbnail: audio mp3 gets a SQUARE embedded cover (--ppa crop survives ffmpeg)',
|
||||
() => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'crop',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // "Me at the zoo", 19s
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: true,
|
||||
cropThumbnail: true
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[crop] argv:', JSON.stringify(argv))
|
||||
it('cropThumbnail: audio mp3 gets a SQUARE embedded cover (--ppa crop survives ffmpeg)', () => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'crop',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // "Me at the zoo", 19s
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: true,
|
||||
cropThumbnail: true
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[crop] argv:', JSON.stringify(argv))
|
||||
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[crop] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
expect(statSync(res.filePath!).size).toBeGreaterThan(1000)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[crop] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
expect(statSync(res.filePath!).size).toBeGreaterThan(1000)
|
||||
|
||||
const info = ffinfo(res.filePath!)
|
||||
const dims = parseCoverDims(info)
|
||||
console.log('[crop] output:', res.filePath, 'cover dims:', dims)
|
||||
expect(dims, 'embedded cover image should be present').toBeTruthy()
|
||||
expect(dims!.w, 'cover must be cropped to a square').toBe(dims!.h)
|
||||
},
|
||||
240_000
|
||||
)
|
||||
const info = ffinfo(res.filePath!)
|
||||
const dims = parseCoverDims(info)
|
||||
console.log('[crop] output:', res.filePath, 'cover dims:', dims)
|
||||
expect(dims, 'embedded cover image should be present').toBeTruthy()
|
||||
expect(dims!.w, 'cover must be cropped to a square').toBe(dims!.h)
|
||||
}, 240_000)
|
||||
|
||||
it(
|
||||
'sponsorBlock remove: output is shorter than source by the removed segments',
|
||||
() => {
|
||||
const url = 'https://www.youtube.com/watch?v=kJQP7kiw5Fk' // has ~54s of music_offtopic
|
||||
const source = probeSourceDuration(url)
|
||||
console.log('[sb] source duration:', source)
|
||||
expect(source, 'should read source duration').toBeGreaterThan(60)
|
||||
it('sponsorBlock remove: output is shorter than source by the removed segments', () => {
|
||||
const url = 'https://www.youtube.com/watch?v=kJQP7kiw5Fk' // has ~54s of music_offtopic
|
||||
const source = probeSourceDuration(url)
|
||||
console.log('[sb] source duration:', source)
|
||||
expect(source, 'should read source duration').toBeGreaterThan(60)
|
||||
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'sb',
|
||||
url,
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: false,
|
||||
sponsorBlock: true,
|
||||
sponsorBlockMode: 'remove' as const,
|
||||
sponsorBlockCategories: ['music_offtopic' as const]
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[sb] argv:', JSON.stringify(argv))
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'sb',
|
||||
url,
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: false,
|
||||
sponsorBlock: true,
|
||||
sponsorBlockMode: 'remove' as const,
|
||||
sponsorBlockCategories: ['music_offtopic' as const]
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[sb] argv:', JSON.stringify(argv))
|
||||
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[sb] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath).toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[sb] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath).toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
|
||||
const outDur = parseDurationSec(ffinfo(res.filePath!))
|
||||
console.log('[sb] output duration:', outDur, 'removed ≈', (source ?? 0) - (outDur ?? 0), 's')
|
||||
expect(outDur, 'should read output duration').toBeGreaterThan(0)
|
||||
// ~54s of segments are removed; require a clear, unambiguous shrink.
|
||||
expect(source! - outDur!).toBeGreaterThan(30)
|
||||
},
|
||||
240_000
|
||||
)
|
||||
const outDur = parseDurationSec(ffinfo(res.filePath!))
|
||||
console.log('[sb] output duration:', outDur, 'removed ≈', (source ?? 0) - (outDur ?? 0), 's')
|
||||
expect(outDur, 'should read output duration').toBeGreaterThan(0)
|
||||
// ~54s of segments are removed; require a clear, unambiguous shrink.
|
||||
expect(source! - outDur!).toBeGreaterThan(30)
|
||||
}, 240_000)
|
||||
|
||||
it(
|
||||
'audioFormat opus: --audio-format re-encodes to a real .opus stream',
|
||||
() => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'opus',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
kind: 'audio',
|
||||
quality: 'Best'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'opus' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[opus] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(res.filePath!.toLowerCase().endsWith('.opus'), 'output ext should be .opus').toBe(true)
|
||||
it('audioFormat opus: --audio-format re-encodes to a real .opus stream', () => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'opus',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
kind: 'audio',
|
||||
quality: 'Best'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'opus' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[opus] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(res.filePath!.toLowerCase().endsWith('.opus'), 'output ext should be .opus').toBe(true)
|
||||
|
||||
const audio = streamsOfType(res.filePath!, 'audio')
|
||||
console.log('[opus] audio codecs:', audio.map((s) => s.codec_name))
|
||||
expect(audio.length, 'should have one audio stream').toBeGreaterThan(0)
|
||||
expect(audio[0].codec_name, '--audio-format opus should produce opus').toBe('opus')
|
||||
},
|
||||
240_000
|
||||
)
|
||||
const audio = streamsOfType(res.filePath!, 'audio')
|
||||
console.log(
|
||||
'[opus] audio codecs:',
|
||||
audio.map((s) => s.codec_name)
|
||||
)
|
||||
expect(audio.length, 'should have one audio stream').toBeGreaterThan(0)
|
||||
expect(audio[0].codec_name, '--audio-format opus should produce opus').toBe('opus')
|
||||
}, 240_000)
|
||||
|
||||
it(
|
||||
'video mkv + vp9 preference: merged .mkv carries a vp9 video stream + audio',
|
||||
() => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'mkv-vp9',
|
||||
url: 'https://www.youtube.com/watch?v=kJQP7kiw5Fk', // serves both vp9 + av01 at 360p
|
||||
kind: 'video',
|
||||
quality: '360p'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
videoContainer: 'mkv' as const,
|
||||
preferredVideoCodec: 'vp9' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[mkv-vp9] argv:', JSON.stringify(argv))
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[mkv-vp9] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(res.filePath!.toLowerCase().endsWith('.mkv'), 'output ext should be .mkv').toBe(true)
|
||||
it('video mkv + vp9 preference: merged .mkv carries a vp9 video stream + audio', () => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'mkv-vp9',
|
||||
url: 'https://www.youtube.com/watch?v=kJQP7kiw5Fk', // serves both vp9 + av01 at 360p
|
||||
kind: 'video',
|
||||
quality: '360p'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
videoContainer: 'mkv' as const,
|
||||
preferredVideoCodec: 'vp9' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[mkv-vp9] argv:', JSON.stringify(argv))
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[mkv-vp9] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(res.filePath!.toLowerCase().endsWith('.mkv'), 'output ext should be .mkv').toBe(true)
|
||||
|
||||
const video = streamsOfType(res.filePath!, 'video')
|
||||
const audio = streamsOfType(res.filePath!, 'audio')
|
||||
console.log('[mkv-vp9] video:', video.map((s) => s.codec_name), 'audio:', audio.map((s) => s.codec_name))
|
||||
expect(video.length, 'should have a video stream').toBeGreaterThan(0)
|
||||
expect(audio.length, 'merge should pull in a separate audio stream').toBeGreaterThan(0)
|
||||
expect(video[0].codec_name, 'the vcodec sort should steer to vp9').toBe('vp9')
|
||||
},
|
||||
240_000
|
||||
)
|
||||
const video = streamsOfType(res.filePath!, 'video')
|
||||
const audio = streamsOfType(res.filePath!, 'audio')
|
||||
console.log(
|
||||
'[mkv-vp9] video:',
|
||||
video.map((s) => s.codec_name),
|
||||
'audio:',
|
||||
audio.map((s) => s.codec_name)
|
||||
)
|
||||
expect(video.length, 'should have a video stream').toBeGreaterThan(0)
|
||||
expect(audio.length, 'merge should pull in a separate audio stream').toBeGreaterThan(0)
|
||||
expect(video[0].codec_name, 'the vcodec sort should steer to vp9').toBe('vp9')
|
||||
}, 240_000)
|
||||
|
||||
it(
|
||||
'embed auto-subtitles + chapters (mp4): mov_text subtitle stream and all 3 chapters',
|
||||
() => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'subs-ch',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // has en auto-captions + 3 chapters
|
||||
kind: 'video',
|
||||
quality: '360p'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
videoContainer: 'mp4' as const,
|
||||
embedSubtitles: true,
|
||||
autoSubtitles: true,
|
||||
subtitleLanguages: 'en',
|
||||
embedChapters: true,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[subs-ch] argv:', JSON.stringify(argv))
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[subs-ch] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
it('embed auto-subtitles + chapters (mp4): mov_text subtitle stream and all 3 chapters', () => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'subs-ch',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // has en auto-captions + 3 chapters
|
||||
kind: 'video',
|
||||
quality: '360p'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
videoContainer: 'mp4' as const,
|
||||
embedSubtitles: true,
|
||||
autoSubtitles: true,
|
||||
subtitleLanguages: 'en',
|
||||
embedChapters: true,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[subs-ch] argv:', JSON.stringify(argv))
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[subs-ch] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
|
||||
const info = ffprobeJson(res.filePath!)
|
||||
const subs = (info.streams ?? []).filter((s) => s.codec_type === 'subtitle')
|
||||
const chapters = info.chapters ?? []
|
||||
console.log('[subs-ch] subtitles:', subs.map((s) => s.codec_name), 'chapters:', chapters.length)
|
||||
expect(subs.length, 'an en auto-caption should be embedded').toBeGreaterThan(0)
|
||||
expect(subs[0].codec_name, 'mp4 subtitles convert to mov_text').toBe('mov_text')
|
||||
expect(chapters.length, 'all 3 source chapters should be embedded').toBe(3)
|
||||
},
|
||||
240_000
|
||||
)
|
||||
const info = ffprobeJson(res.filePath!)
|
||||
const subs = (info.streams ?? []).filter((s) => s.codec_type === 'subtitle')
|
||||
const chapters = info.chapters ?? []
|
||||
console.log(
|
||||
'[subs-ch] subtitles:',
|
||||
subs.map((s) => s.codec_name),
|
||||
'chapters:',
|
||||
chapters.length
|
||||
)
|
||||
expect(subs.length, 'an en auto-caption should be embedded').toBeGreaterThan(0)
|
||||
expect(subs[0].codec_name, 'mp4 subtitles convert to mov_text').toBe('mov_text')
|
||||
expect(chapters.length, 'all 3 source chapters should be embedded').toBe(3)
|
||||
}, 240_000)
|
||||
|
||||
it(
|
||||
'restrictFilenames: output basename is sanitized to a portable ASCII subset',
|
||||
() => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'restrict',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // title "Me at the zoo"
|
||||
kind: 'audio',
|
||||
quality: 'Best'
|
||||
}
|
||||
const options = { ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false }
|
||||
const access = { ...NO_ACCESS, restrictFilenames: true }
|
||||
// %(title)s so the spaces in "Me at the zoo" actually exercise the sanitizer.
|
||||
const argv = buildArgs(opts, join(outDir, '%(title)s.%(ext)s'), options, BIN_DIR, access)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[restrict] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
it('restrictFilenames: output basename is sanitized to a portable ASCII subset', () => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'restrict',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // title "Me at the zoo"
|
||||
kind: 'audio',
|
||||
quality: 'Best'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const access = { ...NO_ACCESS, restrictFilenames: true }
|
||||
// %(title)s so the spaces in "Me at the zoo" actually exercise the sanitizer.
|
||||
const argv = buildArgs(opts, join(outDir, '%(title)s.%(ext)s'), options, BIN_DIR, access)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[restrict] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
|
||||
const base = baseName(res.filePath!)
|
||||
console.log('[restrict] basename:', base)
|
||||
expect(base, 'a restricted filename has no spaces').not.toMatch(/\s/)
|
||||
expect(base, 'restricted to [A-Za-z0-9._-]').toMatch(/^[A-Za-z0-9._-]+$/)
|
||||
},
|
||||
240_000
|
||||
)
|
||||
const base = baseName(res.filePath!)
|
||||
console.log('[restrict] basename:', base)
|
||||
expect(base, 'a restricted filename has no spaces').not.toMatch(/\s/)
|
||||
expect(base, 'restricted to [A-Za-z0-9._-]').toMatch(/^[A-Za-z0-9._-]+$/)
|
||||
}, 240_000)
|
||||
|
||||
it(
|
||||
'downloadArchive: a second run of the same URL is skipped via the archive',
|
||||
() => {
|
||||
const archive = join(outDir, 'archive.txt')
|
||||
const mk = (id: string): string[] =>
|
||||
buildArgs(
|
||||
{ id, url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' },
|
||||
join(outDir, 'arch-%(id)s.%(ext)s'),
|
||||
{ ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false },
|
||||
BIN_DIR,
|
||||
{ ...NO_ACCESS, downloadArchivePath: archive }
|
||||
)
|
||||
it('downloadArchive: a second run of the same URL is skipped via the archive', () => {
|
||||
const archive = join(outDir, 'archive.txt')
|
||||
const mk = (id: string): string[] =>
|
||||
buildArgs(
|
||||
{ id, url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' },
|
||||
join(outDir, 'arch-%(id)s.%(ext)s'),
|
||||
{ ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false },
|
||||
BIN_DIR,
|
||||
{ ...NO_ACCESS, downloadArchivePath: archive }
|
||||
)
|
||||
|
||||
const first = runYtdlp(mk('a1'))
|
||||
if (first.status !== 0) console.error('[archive] first stderr:\n' + first.stderr)
|
||||
expect(first.status, 'first run should download').toBe(0)
|
||||
expect(first.filePath, 'first run should produce a file').toBeTruthy()
|
||||
expect(existsSync(archive), 'archive file should be written').toBe(true)
|
||||
const first = runYtdlp(mk('a1'))
|
||||
if (first.status !== 0) console.error('[archive] first stderr:\n' + first.stderr)
|
||||
expect(first.status, 'first run should download').toBe(0)
|
||||
expect(first.filePath, 'first run should produce a file').toBeTruthy()
|
||||
expect(existsSync(archive), 'archive file should be written').toBe(true)
|
||||
|
||||
const second = runYtdlp(mk('a2'))
|
||||
expect(second.status, 'second run should still exit 0').toBe(0)
|
||||
// The id is in the archive, so the second run downloads nothing — and thus
|
||||
// prints no after_move `path|` line. (yt-dlp's "has already been recorded"
|
||||
// notice is itself silenced because buildArgs' --print implies --quiet, so
|
||||
// we assert on the archive contents + the absence of a fresh download.)
|
||||
expect(second.filePath, 'second run should be skipped (no new download)').toBeFalsy()
|
||||
const archiveBody = readFileSync(archive, 'utf8')
|
||||
console.log('[archive] archive.txt:', JSON.stringify(archiveBody.trim()))
|
||||
expect(archiveBody, 'the archive should record the downloaded video id').toContain('jNQXAC9IVRw')
|
||||
},
|
||||
240_000
|
||||
)
|
||||
const second = runYtdlp(mk('a2'))
|
||||
expect(second.status, 'second run should still exit 0').toBe(0)
|
||||
// The id is in the archive, so the second run downloads nothing — and thus
|
||||
// prints no after_move `path|` line. (yt-dlp's "has already been recorded"
|
||||
// notice is itself silenced because buildArgs' --print implies --quiet, so
|
||||
// we assert on the archive contents + the absence of a fresh download.)
|
||||
expect(second.filePath, 'second run should be skipped (no new download)').toBeFalsy()
|
||||
const archiveBody = readFileSync(archive, 'utf8')
|
||||
console.log('[archive] archive.txt:', JSON.stringify(archiveBody.trim()))
|
||||
expect(archiveBody, 'the archive should record the downloaded video id').toContain(
|
||||
'jNQXAC9IVRw'
|
||||
)
|
||||
}, 240_000)
|
||||
|
||||
it(
|
||||
'extraArgs (custom command): parseExtraArgs tokens reach yt-dlp (--write-info-json sidecar)',
|
||||
() => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'extra',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
kind: 'audio',
|
||||
quality: 'Best'
|
||||
}
|
||||
const options = { ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false }
|
||||
const extra = parseExtraArgs('--write-info-json --no-mtime')
|
||||
expect(extra, 'parseExtraArgs splits into discrete tokens').toEqual([
|
||||
'--write-info-json',
|
||||
'--no-mtime'
|
||||
])
|
||||
const argv = buildArgs(opts, join(outDir, 'extra-%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS, extra)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[extra] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
it('extraArgs (custom command): parseExtraArgs tokens reach yt-dlp (--write-info-json sidecar)', () => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'extra',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
kind: 'audio',
|
||||
quality: 'Best'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const extra = parseExtraArgs('--write-info-json --no-mtime')
|
||||
expect(extra, 'parseExtraArgs splits into discrete tokens').toEqual([
|
||||
'--write-info-json',
|
||||
'--no-mtime'
|
||||
])
|
||||
const argv = buildArgs(
|
||||
opts,
|
||||
join(outDir, 'extra-%(id)s.%(ext)s'),
|
||||
options,
|
||||
BIN_DIR,
|
||||
NO_ACCESS,
|
||||
extra
|
||||
)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[extra] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
|
||||
const infoJson = join(outDir, 'extra-jNQXAC9IVRw.info.json')
|
||||
console.log('[extra] info.json exists:', existsSync(infoJson))
|
||||
expect(existsSync(infoJson), '--write-info-json should write a sidecar').toBe(true)
|
||||
},
|
||||
240_000
|
||||
)
|
||||
const infoJson = join(outDir, 'extra-jNQXAC9IVRw.info.json')
|
||||
console.log('[extra] info.json exists:', existsSync(infoJson))
|
||||
expect(existsSync(infoJson), '--write-info-json should write a sidecar').toBe(true)
|
||||
}, 240_000)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user