Options (quality, subtitles) are now derived from a yt-dlp -J probe of the submitted URL instead of a static list, so users can't pick combinations the source video doesn't actually support. Submission is blocked until the probe succeeds. - New /api/probe endpoint + src/lib/ytdlp-probe.ts, with its own rate limiter (rate-limit.ts refactored into a createRateLimiter factory). - New options: subtitle language selection, clip start/end trim, MP3 audio quality. - Fixed buildYtdlpArgs: format=mp3 never triggered audio extraction (-x/--audio-format), and quality=best never applied --merge-output-format, so the chosen container had no real effect. - Cross-platform yt-dlp invocation: the bundled bin/yt-dlp is a Python zipapp relying on a shebang, which Windows' spawn() can't run directly. buildYtdlpCommand() runs it through `python` on Windows and execs it directly on Linux/o2switch, where the shebang works natively. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
134 lines
4.4 KiB
TypeScript
134 lines
4.4 KiB
TypeScript
import { buildYtdlpArgs } from '../ytdlp'
|
|
|
|
const base = {
|
|
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
uuid: 'abc-123',
|
|
format: 'mp4',
|
|
quality: 'best',
|
|
subtitles: false,
|
|
subtitleLangs: null,
|
|
clipStart: null,
|
|
clipEnd: null,
|
|
audioQuality: null,
|
|
extraArgs: null,
|
|
}
|
|
|
|
describe('buildYtdlpArgs', () => {
|
|
it('always includes --no-playlist', () => {
|
|
expect(buildYtdlpArgs(base)).toContain('--no-playlist')
|
|
})
|
|
|
|
it('sets output path with uuid and %(ext)s template', () => {
|
|
const args = buildYtdlpArgs(base)
|
|
const idx = args.indexOf('-o')
|
|
expect(idx).toBeGreaterThan(-1)
|
|
expect(args[idx + 1]).toContain('abc-123')
|
|
expect(args[idx + 1]).toContain('%(ext)s')
|
|
})
|
|
|
|
it('always sets --merge-output-format to the chosen video format', () => {
|
|
const args = buildYtdlpArgs(base)
|
|
const idx = args.indexOf('--merge-output-format')
|
|
expect(idx).toBeGreaterThan(-1)
|
|
expect(args[idx + 1]).toBe('mp4')
|
|
})
|
|
|
|
it('adds a height filter to -f when quality is not "best"', () => {
|
|
const args = buildYtdlpArgs({ ...base, quality: '1080p' })
|
|
const idx = args.indexOf('-f')
|
|
expect(idx).toBeGreaterThan(-1)
|
|
expect(args[idx + 1]).toContain('height<=?1080')
|
|
})
|
|
|
|
it('does not add a height filter when quality is "best"', () => {
|
|
const args = buildYtdlpArgs(base)
|
|
const idx = args.indexOf('-f')
|
|
expect(args[idx + 1]).not.toContain('height<=?')
|
|
})
|
|
|
|
it('extracts audio with -x and --audio-format when format is mp3', () => {
|
|
const args = buildYtdlpArgs({ ...base, format: 'mp3' })
|
|
expect(args).toContain('-x')
|
|
const idx = args.indexOf('--audio-format')
|
|
expect(args[idx + 1]).toBe('mp3')
|
|
expect(args).not.toContain('--merge-output-format')
|
|
})
|
|
|
|
it('sets --audio-quality to 0 for best mp3 quality', () => {
|
|
const args = buildYtdlpArgs({ ...base, format: 'mp3', audioQuality: 'best' })
|
|
const idx = args.indexOf('--audio-quality')
|
|
expect(args[idx + 1]).toBe('0')
|
|
})
|
|
|
|
it('sets --audio-quality to a bitrate when a specific mp3 quality is given', () => {
|
|
const args = buildYtdlpArgs({ ...base, format: 'mp3', audioQuality: '192' })
|
|
const idx = args.indexOf('--audio-quality')
|
|
expect(args[idx + 1]).toBe('192K')
|
|
})
|
|
|
|
it('adds subtitle flags when subtitles is true', () => {
|
|
const args = buildYtdlpArgs({ ...base, subtitles: true })
|
|
expect(args).toContain('--write-subs')
|
|
expect(args).toContain('--write-auto-subs')
|
|
expect(args).toContain('--sub-lang')
|
|
})
|
|
|
|
it('does not add subtitle flags when subtitles is false', () => {
|
|
expect(buildYtdlpArgs(base)).not.toContain('--write-subs')
|
|
})
|
|
|
|
it('defaults subtitle languages to fr,en when none are given', () => {
|
|
const args = buildYtdlpArgs({ ...base, subtitles: true })
|
|
const idx = args.indexOf('--sub-lang')
|
|
expect(args[idx + 1]).toBe('fr,en')
|
|
})
|
|
|
|
it('uses the given subtitle languages when provided', () => {
|
|
const args = buildYtdlpArgs({ ...base, subtitles: true, subtitleLangs: ['es', 'it'] })
|
|
const idx = args.indexOf('--sub-lang')
|
|
expect(args[idx + 1]).toBe('es,it')
|
|
})
|
|
|
|
it('adds --download-sections when a clip range is given', () => {
|
|
const args = buildYtdlpArgs({ ...base, clipStart: 10, clipEnd: 30 })
|
|
const idx = args.indexOf('--download-sections')
|
|
expect(args[idx + 1]).toBe('*10-30')
|
|
})
|
|
|
|
it('defaults clip start to 0 when only clipEnd is given', () => {
|
|
const args = buildYtdlpArgs({ ...base, clipEnd: 30 })
|
|
const idx = args.indexOf('--download-sections')
|
|
expect(args[idx + 1]).toBe('*0-30')
|
|
})
|
|
|
|
it('leaves the clip end open when only clipStart is given', () => {
|
|
const args = buildYtdlpArgs({ ...base, clipStart: 10 })
|
|
const idx = args.indexOf('--download-sections')
|
|
expect(args[idx + 1]).toBe('*10-')
|
|
})
|
|
|
|
it('does not add --download-sections when no clip range is given', () => {
|
|
expect(buildYtdlpArgs(base)).not.toContain('--download-sections')
|
|
})
|
|
|
|
it('appends extra args from a JSON array string', () => {
|
|
const args = buildYtdlpArgs({
|
|
...base,
|
|
extraArgs: '["--sponsorblock-remove","all"]',
|
|
})
|
|
expect(args).toContain('--sponsorblock-remove')
|
|
expect(args).toContain('all')
|
|
})
|
|
|
|
it('ignores malformed extraArgs JSON', () => {
|
|
expect(() =>
|
|
buildYtdlpArgs({ ...base, extraArgs: 'not-json' })
|
|
).not.toThrow()
|
|
})
|
|
|
|
it('url is the last argument', () => {
|
|
const args = buildYtdlpArgs(base)
|
|
expect(args[args.length - 1]).toBe(base.url)
|
|
})
|
|
})
|