import { existsSync } from 'fs' import path from 'path' import { config } from '../../config/app.config' export type YtdlpParams = { url: string uuid: string 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[] } // bin/yt-dlp is a pure-Python zipapp, not a standalone binary: it has no bundled // interpreter or native libs to extract/mmap, so it works on hosts with a // noexec /tmp (unlike yt-dlp's PyInstaller-built binaries). It relies on a // `#!/usr/bin/env python3` shebang, which spawn() can't honor on Windows and // which resolves to whatever `python3` happens to be on PATH elsewhere (on // o2switch that's an unsupported 3.6) — so it's always invoked explicitly // through PYTHON_BIN. A system-wide `yt-dlp` (PATH fallback) already has its // own proper launcher and is run directly. export function buildYtdlpCommand(args: string[]): YtdlpCommand { const bin = resolveYtdlpBin() const isBundledScript = bin !== 'yt-dlp' if (isBundledScript) { return { command: config.PYTHON_BIN, args: [bin, ...args] } } return { command: bin, args } } export function buildYtdlpArgs(params: YtdlpParams): string[] { 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] // bin/ffmpeg is a Linux ELF binary bundled for o2switch prod deploy; it can't // run on Windows, so local dev instead looks for a bin/ffmpeg.exe. const ffmpegBin = path.join(config.BIN_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg') if (existsSync(ffmpegBin)) { args.push('--ffmpeg-location', ffmpegBin) } 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) { 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) { try { const extra = JSON.parse(extraArgs) as string[] args.push(...extra) } catch { // malformed extraArgs — skip silently } } args.push(url) return args }