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
+4
View File
@@ -29,6 +29,10 @@ const baseDownload = {
format: 'mp4',
quality: 'best',
subtitles: false,
subtitleLangs: null,
clipStart: null,
clipEnd: null,
audioQuality: null,
extraArgs: null,
}
+8 -8
View File
@@ -1,16 +1,11 @@
import { spawn } from 'child_process'
import { existsSync, readdirSync, statSync } from 'fs'
import { readdirSync, statSync } from 'fs'
import path from 'path'
import { prisma } from '@/lib/prisma'
import { buildYtdlpArgs } from '@/lib/ytdlp'
import { buildYtdlpArgs, buildYtdlpCommand } from '@/lib/ytdlp'
import { createToken } from '@/lib/token'
import { config } from '../config/app.config'
function resolveYtdlpBin(): string {
const local = path.join(config.BIN_DIR, 'yt-dlp')
return existsSync(local) ? local : 'yt-dlp'
}
export async function processDownload(downloadId: string): Promise<void> {
const download = await prisma.download.findUnique({ where: { id: downloadId } })
if (!download) return
@@ -21,13 +16,18 @@ export async function processDownload(downloadId: string): Promise<void> {
format: download.format,
quality: download.quality,
subtitles: download.subtitles,
subtitleLangs: download.subtitleLangs ? download.subtitleLangs.split(',') : null,
clipStart: download.clipStart,
clipEnd: download.clipEnd,
audioQuality: download.audioQuality,
extraArgs: download.extraArgs,
})
let stderr = ''
const { command, args: spawnArgs } = buildYtdlpCommand(args)
await new Promise<void>((resolve) => {
const proc = spawn(resolveYtdlpBin(), args)
const proc = spawn(command, spawnArgs)
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
proc.on('close', async (code) => {