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
+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) {