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)
})
})