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:
@@ -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' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user