feat: gate download options on real-time yt-dlp video analysis

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>
This commit is contained in:
2026-08-11 14:13:38 +02:00
co-authored by Claude Sonnet 5
parent f949566f76
commit 27ba48d181
23 changed files with 897 additions and 111 deletions
+24 -12
View File
@@ -1,41 +1,53 @@
import { isRateLimited } from '../rate-limit'
import { createRateLimiter } from '../rate-limit'
beforeEach(() => {
jest.useFakeTimers()
jest.resetModules()
})
afterEach(() => {
jest.useRealTimers()
})
describe('isRateLimited', () => {
describe('createRateLimiter', () => {
it('allows requests under the limit', () => {
const limiter = createRateLimiter(5, 3_600_000)
const ip = '1.2.3.4'
for (let i = 0; i < 5; i++) {
expect(isRateLimited(ip)).toBe(false)
expect(limiter.isLimited(ip)).toBe(false)
}
})
it('blocks the 6th request within the window', () => {
const limiter = createRateLimiter(5, 3_600_000)
const ip = '10.0.0.1'
for (let i = 0; i < 5; i++) isRateLimited(ip)
expect(isRateLimited(ip)).toBe(true)
for (let i = 0; i < 5; i++) limiter.isLimited(ip)
expect(limiter.isLimited(ip)).toBe(true)
})
it('resets after the window expires', () => {
const limiter = createRateLimiter(5, 3_600_000)
const ip = '10.0.0.2'
for (let i = 0; i < 5; i++) isRateLimited(ip)
expect(isRateLimited(ip)).toBe(true)
for (let i = 0; i < 5; i++) limiter.isLimited(ip)
expect(limiter.isLimited(ip)).toBe(true)
jest.advanceTimersByTime(3_600_001)
expect(isRateLimited(ip)).toBe(false)
expect(limiter.isLimited(ip)).toBe(false)
})
it('tracks different IPs independently', () => {
const limiter = createRateLimiter(5, 3_600_000)
const ipA = '192.168.1.1'
const ipB = '192.168.1.2'
for (let i = 0; i < 5; i++) isRateLimited(ipA)
expect(isRateLimited(ipA)).toBe(true)
expect(isRateLimited(ipB)).toBe(false)
for (let i = 0; i < 5; i++) limiter.isLimited(ipA)
expect(limiter.isLimited(ipA)).toBe(true)
expect(limiter.isLimited(ipB)).toBe(false)
})
it('tracks separate limiter instances independently', () => {
const limiterA = createRateLimiter(1, 3_600_000)
const limiterB = createRateLimiter(1, 3_600_000)
const ip = '5.5.5.5'
expect(limiterA.isLimited(ip)).toBe(false)
expect(limiterA.isLimited(ip)).toBe(true)
expect(limiterB.isLimited(ip)).toBe(false)
})
})
+50
View File
@@ -0,0 +1,50 @@
const mockExistsSync = jest.fn()
jest.mock('fs', () => ({ existsSync: (...args: unknown[]) => mockExistsSync(...args) }))
import { buildYtdlpCommand } from '../ytdlp'
function setPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, 'platform', { value: platform })
}
describe('buildYtdlpCommand', () => {
const originalPlatform = process.platform
afterEach(() => {
setPlatform(originalPlatform)
mockExistsSync.mockReset()
})
it('runs the bundled binary directly on Linux', () => {
setPlatform('linux')
mockExistsSync.mockReturnValue(true)
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
expect(command).toContain('yt-dlp')
expect(args).toEqual(['-J', 'https://x.test'])
})
it('runs the bundled binary through python on Windows', () => {
setPlatform('win32')
mockExistsSync.mockReturnValue(true)
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
expect(command).toBe('python')
expect(args[0]).toContain('yt-dlp')
expect(args.slice(1)).toEqual(['-J', 'https://x.test'])
})
it('runs a system-wide yt-dlp directly on Windows when no bundled binary exists', () => {
setPlatform('win32')
mockExistsSync.mockReturnValue(false)
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
expect(command).toBe('yt-dlp')
expect(args).toEqual(['-J', 'https://x.test'])
})
it('runs a system-wide yt-dlp directly on Linux when no bundled binary exists', () => {
setPlatform('linux')
mockExistsSync.mockReturnValue(false)
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
expect(command).toBe('yt-dlp')
expect(args).toEqual(['-J', 'https://x.test'])
})
})
+76
View File
@@ -0,0 +1,76 @@
import { parseProbeOutput, buildProbeArgs, ProbeError } from '../ytdlp-probe'
function json(obj: unknown): string {
return JSON.stringify(obj)
}
describe('buildProbeArgs', () => {
it('dumps JSON without downloading and disables playlists', () => {
const args = buildProbeArgs('https://youtube.com/watch?v=abc')
expect(args).toContain('-J')
expect(args).toContain('--skip-download')
expect(args).toContain('--no-playlist')
expect(args[args.length - 1]).toBe('https://youtube.com/watch?v=abc')
})
})
describe('parseProbeOutput', () => {
it('throws a ProbeError on invalid JSON', () => {
expect(() => parseProbeOutput('not json')).toThrow(ProbeError)
})
it('detects a video with multiple qualities and subtitles', () => {
const result = parseProbeOutput(json({
title: 'Some video',
duration: 125,
formats: [
{ height: 360, vcodec: 'avc1' },
{ height: 720, vcodec: 'avc1' },
{ height: 1080, vcodec: 'avc1' },
{ vcodec: 'none', acodec: 'opus' },
],
subtitles: { en: [{}] },
automatic_captions: { fr: [{}], es: [{}] },
}))
expect(result.isAudioOnly).toBe(false)
expect(result.durationSeconds).toBe(125)
expect(result.availableQualities).toEqual(['best', '1080p', '720p', '480p', '360p'])
expect(result.subtitleLangs).toEqual(['fr', 'en', 'es'])
})
it('caps available qualities to the max height found', () => {
const result = parseProbeOutput(json({
formats: [
{ height: 360, vcodec: 'avc1' },
{ height: 480, vcodec: 'avc1' },
],
}))
expect(result.availableQualities).toEqual(['best', '480p', '360p'])
})
it('detects an audio-only source and hides quality options', () => {
const result = parseProbeOutput(json({
title: 'Some track',
duration: 200,
formats: [
{ vcodec: 'none', acodec: 'mp3' },
{ vcodec: 'none', acodec: 'opus' },
],
}))
expect(result.isAudioOnly).toBe(true)
expect(result.availableQualities).toEqual([])
})
it('returns an empty subtitle list when none are available', () => {
const result = parseProbeOutput(json({
formats: [{ height: 720, vcodec: 'avc1' }],
}))
expect(result.subtitleLangs).toEqual([])
})
it('defaults duration to null when missing', () => {
const result = parseProbeOutput(json({ formats: [] }))
expect(result.durationSeconds).toBeNull()
})
})
+77 -7
View File
@@ -6,6 +6,10 @@ const base = {
format: 'mp4',
quality: 'best',
subtitles: false,
subtitleLangs: null,
clipStart: null,
clipEnd: null,
audioQuality: null,
extraArgs: null,
}
@@ -22,23 +26,89 @@ describe('buildYtdlpArgs', () => {
expect(args[idx + 1]).toContain('%(ext)s')
})
it('adds format filter when quality is not "best"', () => {
const args = buildYtdlpArgs({ ...base, quality: '1080p' })
expect(args).toContain('-f')
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('does not add -f flag when quality is "best"', () => {
expect(buildYtdlpArgs(base)).not.toContain('-f')
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-sub')
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-sub')
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', () => {