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
+35 -5
View File
@@ -1,14 +1,16 @@
import { POST } from '../route'
import { prisma } from '@/lib/prisma'
import * as rateLimit from '@/lib/rate-limit'
import { downloadRateLimiter } from '@/lib/rate-limit'
jest.mock('@/lib/prisma', () => ({
prisma: { download: { create: jest.fn() } },
}))
jest.mock('@/lib/rate-limit')
jest.mock('@/lib/rate-limit', () => ({
downloadRateLimiter: { isLimited: jest.fn() },
}))
const mockCreate = prisma.download.create as jest.Mock
const mockIsRateLimited = rateLimit.isRateLimited as jest.Mock
const mockIsLimited = downloadRateLimiter.isLimited as jest.Mock
function req(body: object, ip = '1.2.3.4') {
return new Request('http://localhost/api/downloads', {
@@ -20,12 +22,12 @@ function req(body: object, ip = '1.2.3.4') {
describe('POST /api/downloads', () => {
beforeEach(() => {
mockIsRateLimited.mockReturnValue(false)
mockIsLimited.mockReturnValue(false)
mockCreate.mockResolvedValue({ uuid: 'test-uuid' })
})
it('returns 429 when rate limited', async () => {
mockIsRateLimited.mockReturnValue(true)
mockIsLimited.mockReturnValue(true)
const res = await POST(req({ url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false }))
expect(res.status).toBe(429)
})
@@ -40,6 +42,14 @@ describe('POST /api/downloads', () => {
expect(res.status).toBe(400)
})
it('returns 400 when clipEnd is not greater than clipStart', async () => {
const res = await POST(req({
url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false,
clipStart: 30, clipEnd: 10,
}))
expect(res.status).toBe(400)
})
it('returns 201 with uuid on success', async () => {
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc', format: 'mp4', quality: 'best', subtitles: false }))
expect(res.status).toBe(201)
@@ -58,4 +68,24 @@ describe('POST /api/downloads', () => {
}),
})
})
it('stores subtitleLangs as a comma-joined string', async () => {
await POST(req({
url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: true,
subtitleLangs: ['fr', 'en'],
}))
expect(mockCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ subtitleLangs: 'fr,en' }),
})
})
it('stores clip range and audio quality when provided', async () => {
await POST(req({
url: 'https://y.com', format: 'mp3', quality: 'best', subtitles: false,
clipStart: 10, clipEnd: 30, audioQuality: '192',
}))
expect(mockCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ clipStart: 10, clipEnd: 30, audioQuality: '192' }),
})
})
})