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' }),
})
})
})
+22 -4
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { isRateLimited } from '@/lib/rate-limit'
import { downloadRateLimiter } from '@/lib/rate-limit'
import { getClientIp } from '@/lib/request-ip'
export async function POST(req: NextRequest) {
const ip =
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '0.0.0.0'
const ip = getClientIp(req)
if (isRateLimited(ip)) {
if (downloadRateLimiter.isLimited(ip)) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
}
@@ -24,12 +24,30 @@ export async function POST(req: NextRequest) {
)
}
const clipStart = body.clipStart != null ? Number(body.clipStart) : null
const clipEnd = body.clipEnd != null ? Number(body.clipEnd) : null
if (clipStart != null && clipEnd != null && clipEnd <= clipStart) {
return NextResponse.json(
{ error: 'Invalid clip range: clipEnd must be greater than clipStart' },
{ status: 400 }
)
}
const subtitleLangs: string[] | null = Array.isArray(body.subtitleLangs) && body.subtitleLangs.length > 0
? body.subtitleLangs.map(String)
: null
const download = await prisma.download.create({
data: {
url: String(body.url),
format: String(body.format),
quality: String(body.quality),
subtitles: Boolean(body.subtitles),
subtitleLangs: subtitleLangs ? subtitleLangs.join(',') : null,
clipStart,
clipEnd,
audioQuality: body.audioQuality ? String(body.audioQuality) : null,
extraArgs: body.extraArgs ?? null,
ipAddress: ip,
},
+63
View File
@@ -0,0 +1,63 @@
import { POST } from '../route'
import { probeRateLimiter } from '@/lib/rate-limit'
import { runYtdlpProbe } from '@/lib/ytdlp-probe'
jest.mock('@/lib/rate-limit', () => ({
probeRateLimiter: { isLimited: jest.fn() },
}))
jest.mock('@/lib/ytdlp-probe', () => ({
runYtdlpProbe: jest.fn(),
}))
const mockIsLimited = probeRateLimiter.isLimited as jest.Mock
const mockRunProbe = runYtdlpProbe as jest.Mock
function req(body: object, ip = '1.2.3.4') {
return new Request('http://localhost/api/probe', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify(body),
})
}
describe('POST /api/probe', () => {
beforeEach(() => {
mockIsLimited.mockReturnValue(false)
})
it('returns 429 when rate limited', async () => {
mockIsLimited.mockReturnValue(true)
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc' }))
expect(res.status).toBe(429)
})
it('returns 400 when url is missing', async () => {
const res = await POST(req({}))
expect(res.status).toBe(400)
})
it('returns 400 when url is not a valid URL', async () => {
const res = await POST(req({ url: 'not-a-url' }))
expect(res.status).toBe(400)
})
it('returns 200 with capabilities on success', async () => {
const capabilities = {
title: 'Some video',
durationSeconds: 100,
isAudioOnly: false,
availableQualities: ['best', '720p'],
subtitleLangs: ['en'],
}
mockRunProbe.mockResolvedValue(capabilities)
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc' }))
expect(res.status).toBe(200)
expect(await res.json()).toEqual(capabilities)
})
it('returns 422 when the probe fails', async () => {
mockRunProbe.mockRejectedValue(new Error('boom'))
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc' }))
expect(res.status).toBe(422)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { probeRateLimiter } from '@/lib/rate-limit'
import { getClientIp } from '@/lib/request-ip'
import { runYtdlpProbe } from '@/lib/ytdlp-probe'
export async function POST(req: NextRequest) {
const ip = getClientIp(req)
if (probeRateLimiter.isLimited(ip)) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
}
const body = await req.json().catch(() => null)
if (!body?.url || typeof body.url !== 'string') {
return NextResponse.json({ error: 'Missing required field: url' }, { status: 400 })
}
try {
new URL(body.url)
} catch {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
}
try {
const capabilities = await runYtdlpProbe(body.url)
return NextResponse.json(capabilities)
} catch {
return NextResponse.json({ error: 'PROBE_FAILED' }, { status: 422 })
}
}
+235 -50
View File
@@ -1,15 +1,28 @@
'use client'
import { useState, FormEvent } from 'react'
import { useTranslations } from 'next-intl'
import { useEffect, useRef, useState, FormEvent } from 'react'
import { useTranslations, useLocale } from 'next-intl'
import { useRouter } from '@/navigation'
import { FORMATS, AUDIO_QUALITIES } from '@/lib/ytdlp-options'
const FORMATS = ['mp4', 'mp3', 'webm', 'mkv']
const QUALITIES = ['best', '1080p', '720p', '480p', '360p']
type ProbeCapabilities = {
title: string
durationSeconds: number | null
isAudioOnly: boolean
availableQualities: string[]
subtitleLangs: string[]
}
function Spinner() {
type ProbeState =
| { status: 'idle' }
| { status: 'debouncing' }
| { status: 'probing' }
| { status: 'ready'; data: ProbeCapabilities }
| { status: 'error' }
function Spinner({ className = 'w-4 h-4' }: { className?: string }) {
return (
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden>
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
@@ -19,19 +32,100 @@ function Spinner() {
const selectClass =
'rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
const numberInputClass =
'w-24 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
function isValidUrl(value: string): boolean {
try {
new URL(value)
return true
} catch {
return false
}
}
function langLabel(code: string, locale: string): string {
try {
return new Intl.DisplayNames([locale], { type: 'language' }).of(code) ?? code
} catch {
return code
}
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string } = {}) {
const t = useTranslations('home')
const locale = useLocale()
const router = useRouter()
const [url, setUrl] = useState('')
const [probe, setProbe] = useState<ProbeState>({ status: 'idle' })
const [retryToken, setRetryToken] = useState(0)
const [format, setFormat] = useState('mp4')
const [quality, setQuality] = useState('best')
const [subtitles, setSubtitles] = useState(false)
const [subtitleLangs, setSubtitleLangs] = useState<string[]>([])
const [audioQuality, setAudioQuality] = useState('best')
const [clipStart, setClipStart] = useState('')
const [clipEnd, setClipEnd] = useState('')
const [extraArgs, setExtraArgs] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
abortRef.current?.abort()
if (!isValidUrl(url)) {
setProbe({ status: 'idle' })
return
}
setProbe({ status: 'debouncing' })
const debounce = setTimeout(() => {
const controller = new AbortController()
abortRef.current = controller
setProbe({ status: 'probing' })
fetch('/api/probe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url }),
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) throw new Error('probe failed')
const data = (await res.json()) as ProbeCapabilities
setProbe({ status: 'ready', data })
if (data.isAudioOnly) {
setFormat('mp3')
}
setQuality((prev) => (data.availableQualities.includes(prev) ? prev : 'best'))
setSubtitleLangs(data.subtitleLangs)
})
.catch((err) => {
if (err.name === 'AbortError') return
setProbe({ status: 'error' })
})
}, 500)
return () => clearTimeout(debounce)
}, [url, retryToken])
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (probe.status !== 'ready') return
setError(null)
setLoading(true)
@@ -43,6 +137,10 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
format,
quality,
subtitles,
subtitleLangs: subtitles && subtitleLangs.length ? subtitleLangs : null,
audioQuality: format === 'mp3' ? audioQuality : null,
clipStart: clipStart !== '' ? Number(clipStart) : null,
clipEnd: clipEnd !== '' ? Number(clipEnd) : null,
extraArgs: extraArgs.trim() || null,
}),
})
@@ -63,6 +161,9 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
router.push(`/status/${uuid}`)
}
const capabilities = probe.status === 'ready' ? probe.data : null
const submitDisabled = loading || probe.status !== 'ready'
return (
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
{/* URL input with inline submit button */}
@@ -80,7 +181,7 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
/>
<button
type="submit"
disabled={loading}
disabled={submitDisabled}
className="flex items-center gap-2 px-5 py-3 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white text-sm font-medium transition-colors disabled:opacity-60 cursor-pointer disabled:cursor-not-allowed"
>
{loading && <Spinner />}
@@ -89,53 +190,137 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
</div>
</div>
{/* Options row */}
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
<label className="flex items-center gap-2">
<span>{t('format')}</span>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className={selectClass}
{(probe.status === 'debouncing' || probe.status === 'probing') && (
<p className="mt-3 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Spinner className="w-3.5 h-3.5" />
{t('analyzing')}
</p>
)}
{probe.status === 'error' && (
<div className="mt-3 flex items-center gap-3 text-sm text-red-600 dark:text-red-400">
<span>{t('probeError')}</span>
<button
type="button"
onClick={() => setRetryToken((n) => n + 1)}
className="underline hover:no-underline"
>
{FORMATS.map((f) => <option key={f}>{f}</option>)}
</select>
</label>
{t('retry')}
</button>
</div>
)}
<label className="flex items-center gap-2">
<span>{t('quality')}</span>
<select
value={quality}
onChange={(e) => setQuality(e.target.value)}
className={selectClass}
>
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
</select>
</label>
{capabilities && (
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
{capabilities.isAudioOnly ? (
<span className="rounded-lg bg-gray-100 dark:bg-slate-800 px-3 py-1 text-xs">
{t('audioOnlySource')}
</span>
) : (
<>
<label className="flex items-center gap-2">
<span>{t('format')}</span>
<select value={format} onChange={(e) => setFormat(e.target.value)} className={selectClass}>
{FORMATS.map((f) => <option key={f}>{f}</option>)}
</select>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={subtitles}
onChange={(e) => setSubtitles(e.target.checked)}
className="accent-violet-600 w-4 h-4"
/>
<span>{t('subtitles')}</span>
</label>
<label className="flex items-center gap-2">
<span>{t('quality')}</span>
<select value={quality} onChange={(e) => setQuality(e.target.value)} className={selectClass}>
{capabilities.availableQualities.map((q) => <option key={q}>{q}</option>)}
</select>
</label>
</>
)}
<details className="w-full mt-1">
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
{t('advanced')}
</summary>
<input
type="text"
value={extraArgs}
onChange={(e) => setExtraArgs(e.target.value)}
placeholder='["--sponsorblock-remove","all"]'
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
/>
</details>
</div>
{format === 'mp3' && (
<label className="flex items-center gap-2">
<span>{t('audioQuality')}</span>
<select value={audioQuality} onChange={(e) => setAudioQuality(e.target.value)} className={selectClass}>
{AUDIO_QUALITIES.map((q) => <option key={q}>{q}</option>)}
</select>
</label>
)}
{capabilities.subtitleLangs.length > 0 && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={subtitles}
onChange={(e) => setSubtitles(e.target.checked)}
className="accent-violet-600 w-4 h-4"
/>
<span>{t('subtitles')}</span>
</label>
)}
{subtitles && capabilities.subtitleLangs.length > 0 && (
<div className="w-full flex flex-wrap items-center gap-x-4 gap-y-1 pl-1">
<span className="text-xs text-gray-400 dark:text-slate-500">{t('subtitleLanguages')}:</span>
{capabilities.subtitleLangs.map((lang) => (
<label key={lang} className="flex items-center gap-1.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={subtitleLangs.includes(lang)}
onChange={(e) =>
setSubtitleLangs((prev) =>
e.target.checked ? [...prev, lang] : prev.filter((l) => l !== lang)
)
}
className="accent-violet-600 w-3.5 h-3.5"
/>
{langLabel(lang, locale)}
</label>
))}
</div>
)}
<div className="w-full flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xs text-gray-400 dark:text-slate-500">{t('clipRange')}</span>
<label className="flex items-center gap-1.5">
<span className="text-xs">{t('clipStart')}</span>
<input
type="number"
min={0}
max={capabilities.durationSeconds ?? undefined}
value={clipStart}
onChange={(e) => setClipStart(e.target.value)}
className={numberInputClass}
/>
</label>
<label className="flex items-center gap-1.5">
<span className="text-xs">{t('clipEnd')}</span>
<input
type="number"
min={0}
max={capabilities.durationSeconds ?? undefined}
value={clipEnd}
onChange={(e) => setClipEnd(e.target.value)}
className={numberInputClass}
/>
</label>
{capabilities.durationSeconds != null && (
<span className="text-xs text-gray-400 dark:text-slate-500">
{t('videoLength', { duration: formatDuration(capabilities.durationSeconds) })}
</span>
)}
</div>
<details className="w-full mt-1">
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
{t('advanced')}
</summary>
<input
type="text"
value={extraArgs}
onChange={(e) => setExtraArgs(e.target.value)}
placeholder='["--sponsorblock-remove","all"]'
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
/>
</details>
</div>
)}
{error && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
+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', () => {
+27 -12
View File
@@ -1,19 +1,34 @@
import { config } from '../../config/app.config'
type Entry = { count: number; resetAt: number }
const store = new Map<string, Entry>()
export function isRateLimited(ip: string): boolean {
const now = Date.now()
const entry = store.get(ip)
export function createRateLimiter(max: number, windowMs: number) {
const store = new Map<string, Entry>()
if (!entry || now > entry.resetAt) {
store.set(ip, { count: 1, resetAt: now + config.RATE_LIMIT_WINDOW_MS })
return false
return {
isLimited(ip: string): boolean {
const now = Date.now()
const entry = store.get(ip)
if (!entry || now > entry.resetAt) {
store.set(ip, { count: 1, resetAt: now + windowMs })
return false
}
if (entry.count >= max) return true
entry.count++
return false
},
}
if (entry.count >= config.RATE_LIMIT_MAX) return true
entry.count++
return false
}
export const downloadRateLimiter = createRateLimiter(
config.RATE_LIMIT_MAX,
config.RATE_LIMIT_WINDOW_MS
)
export const probeRateLimiter = createRateLimiter(
config.PROBE_RATE_LIMIT_MAX,
config.PROBE_RATE_LIMIT_WINDOW_MS
)
+5
View File
@@ -0,0 +1,5 @@
import { NextRequest } from 'next/server'
export function getClientIp(req: NextRequest): string {
return req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '0.0.0.0'
}
+3
View File
@@ -0,0 +1,3 @@
export const FORMATS = ['mp4', 'mp3', 'webm', 'mkv'] as const
export const QUALITIES = ['best', '1080p', '720p', '480p', '360p'] as const
export const AUDIO_QUALITIES = ['best', '192', '128'] as const
+125
View File
@@ -0,0 +1,125 @@
import { spawn } from 'child_process'
import { config } from '../../config/app.config'
import { buildYtdlpCommand } from './ytdlp'
import { QUALITIES } from './ytdlp-options'
export type ProbeCapabilities = {
title: string
durationSeconds: number | null
isAudioOnly: boolean
availableQualities: string[]
subtitleLangs: string[]
}
type YtdlpFormat = {
height?: number | null
vcodec?: string | null
}
type YtdlpProbeJson = {
title?: string
duration?: number | null
formats?: YtdlpFormat[]
subtitles?: Record<string, unknown>
automatic_captions?: Record<string, unknown>
}
export class ProbeError extends Error {}
export function buildProbeArgs(url: string): string[] {
return ['--no-warnings', '--skip-download', '--no-playlist', '-J', url]
}
const LANG_PRIORITY = ['fr', 'en']
export function parseProbeOutput(stdout: string): ProbeCapabilities {
let json: YtdlpProbeJson
try {
json = JSON.parse(stdout)
} catch {
throw new ProbeError('yt-dlp returned invalid JSON')
}
const formats = json.formats ?? []
const videoFormats = formats.filter((f) => f.vcodec && f.vcodec !== 'none')
const isAudioOnly = formats.length > 0 && videoFormats.length === 0
const heights = videoFormats
.map((f) => f.height)
.filter((h): h is number => typeof h === 'number')
const maxHeight = heights.length ? Math.max(...heights) : null
const availableQualities = isAudioOnly
? []
: QUALITIES.filter(
(q) => q === 'best' || (maxHeight != null && Number(q.replace('p', '')) <= maxHeight)
)
const langs = new Set<string>([
...Object.keys(json.subtitles ?? {}),
...Object.keys(json.automatic_captions ?? {}),
])
const subtitleLangs = [
...LANG_PRIORITY.filter((l) => langs.has(l)),
...[...langs].filter((l) => !LANG_PRIORITY.includes(l)).sort(),
]
return {
title: json.title ?? '',
durationSeconds: typeof json.duration === 'number' ? json.duration : null,
isAudioOnly,
availableQualities,
subtitleLangs,
}
}
export function runYtdlpProbe(
url: string,
timeoutMs: number = config.PROBE_TIMEOUT_MS
): Promise<ProbeCapabilities> {
return new Promise((resolve, reject) => {
const { command, args } = buildYtdlpCommand(buildProbeArgs(url))
const proc = spawn(command, args)
let stdout = ''
let stderr = ''
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
proc.kill('SIGKILL')
reject(new ProbeError('yt-dlp probe timed out'))
}, timeoutMs)
proc.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString()
})
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
})
proc.on('error', (err) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(new ProbeError(err.message))
})
proc.on('close', (code) => {
if (settled) return
settled = true
clearTimeout(timer)
if (code !== 0) {
reject(new ProbeError(stderr || `yt-dlp exited with code ${code}`))
return
}
try {
resolve(parseProbeOutput(stdout))
} catch (err) {
reject(err)
}
})
})
}
+52 -5
View File
@@ -8,11 +8,49 @@ export type YtdlpParams = {
format: string
quality: string
subtitles: boolean
subtitleLangs: string[] | null
clipStart: number | null
clipEnd: number | null
audioQuality: string | null
extraArgs: string | null
}
export function resolveYtdlpBin(): string {
const local = path.join(config.BIN_DIR, 'yt-dlp')
return existsSync(local) ? local : 'yt-dlp'
}
export type YtdlpCommand = { command: string; args: string[] }
// The bundled bin/yt-dlp is a Python zipapp relying on a `#!/usr/bin/env python3`
// shebang: Linux (o2switch prod) execs it natively, but Windows' spawn() has no
// shebang support and fails with ENOENT, so it must be run through `python` there.
// A system-wide `yt-dlp` (PATH fallback) already has a proper platform launcher
// on both OSes and is always run directly.
export function buildYtdlpCommand(args: string[]): YtdlpCommand {
const bin = resolveYtdlpBin()
const isBundledScript = bin !== 'yt-dlp'
if (process.platform === 'win32' && isBundledScript) {
return { command: 'python', args: [bin, ...args] }
}
return { command: bin, args }
}
export function buildYtdlpArgs(params: YtdlpParams): string[] {
const { url, uuid, format, quality, subtitles, extraArgs } = params
const {
url,
uuid,
format,
quality,
subtitles,
subtitleLangs,
clipStart,
clipEnd,
audioQuality,
extraArgs,
} = params
const outputTemplate = path.join(config.STORAGE_PATH, `${uuid}.%(ext)s`)
const args: string[] = ['--no-playlist', '-o', outputTemplate]
@@ -22,13 +60,22 @@ export function buildYtdlpArgs(params: YtdlpParams): string[] {
args.push('--ffmpeg-location', ffmpegBin)
}
if (quality !== 'best') {
const height = quality.replace('p', '')
args.push('-f', `${format}[height<=?${height}]+bestaudio/best[height<=?${height}]`)
if (format === 'mp3') {
args.push('-x', '--audio-format', 'mp3')
args.push('--audio-quality', audioQuality && audioQuality !== 'best' ? `${audioQuality}K` : '0')
} else {
const heightFilter = quality !== 'best' ? `[height<=?${quality.replace('p', '')}]` : ''
args.push('-f', `bestvideo${heightFilter}+bestaudio/best${heightFilter}`)
args.push('--merge-output-format', format)
}
if (subtitles) {
args.push('--write-sub', '--sub-lang', 'fr,en')
const langs = subtitleLangs?.length ? subtitleLangs.join(',') : 'fr,en'
args.push('--write-subs', '--write-auto-subs', '--sub-lang', langs)
}
if (clipStart != null || clipEnd != null) {
args.push('--download-sections', `*${clipStart ?? 0}-${clipEnd ?? ''}`)
}
if (extraArgs) {