import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; /** * Erreur levée quand le processus yt-dlp se termine en échec (code de * sortie non nul). */ export class YtDlpError extends Error { /** Code de sortie du processus (`null` s'il a été tué par un signal). */ public readonly exitCode: number | null; /** Sortie d'erreur standard du processus, pour diagnostic. */ public readonly stderr: string; /** * @param exitCode - Code de sortie du processus yt-dlp. * @param stderr - Contenu de la sortie d'erreur standard du processus. */ public constructor(exitCode: number | null, stderr: string) { super(`yt-dlp a échoué (code ${exitCode}) : ${stderr.slice(0, 500)}`); this.name = 'YtDlpError'; this.exitCode = exitCode; this.stderr = stderr; } } /** Options d'exécution de {@link runYtDlp}. */ export interface RunYtDlpOptions { /** Délai maximal avant l'arrêt forcé du processus (30s par défaut). */ readonly timeoutMs?: number; /** Rappelé pour chaque ligne complète reçue sur stdout, au fil de l'exécution. */ readonly onStdoutLine?: (line: string) => void; /** Signal d'annulation : le processus est tué si ce signal se déclenche. */ readonly signal?: AbortSignal; /** * Rappelé une fois, juste après le lancement du processus, avec le * processus lui-même : permet à l'appelant d'en piloter les flux (ex. * suspendre la lecture de stdout pour freiner un yt-dlp qui produit plus * vite qu'on ne consomme). */ readonly onSpawn?: (child: ChildProcessWithoutNullStreams) => void; } /** * Découpe un flux de sortie en lignes complètes au fil des chunks reçus, * en conservant le fragment incomplet final entre deux appels. */ class LineSplitter { private buffer = ''; /** * @param onLine - Rappelé pour chaque ligne complète extraite du chunk. */ public constructor(private readonly onLine: (line: string) => void) {} /** * Ajoute un nouveau chunk de texte et émet toute ligne désormais complète. * * @param chunk - Fragment de texte reçu (peut contenir 0, 1 ou plusieurs sauts de ligne). */ public push(chunk: string): void { this.buffer += chunk; const lines = this.buffer.split('\n'); this.buffer = lines.pop() ?? ''; for (const line of lines) { this.onLine(line.replace(/\r$/, '')); } } /** Émet le fragment restant, s'il y en a un, en fin de flux. */ public flush(): void { if (this.buffer.length > 0) { this.onLine(this.buffer.replace(/\r$/, '')); this.buffer = ''; } } } /** * Exécute yt-dlp avec les arguments fournis et retourne sa sortie standard * complète. N'utilise jamais de shell : les arguments sont passés tels * quels au processus (tableau d'arguments, pas de concaténation dans une * chaîne), ce qui exclut toute injection de commande. * * Le chemin de l'exécutable est lu dans `process.env.YTDLP_PATH` à chaque * appel (par défaut `"yt-dlp"`, résolu via PATH), pour rester surchargeable * en test ou sur un environnement où le binaire n'est pas sur le PATH. * * @param args - Arguments passés à yt-dlp (jamais interpolés dans un shell). * @param options - Options d'exécution (délai, suivi ligne par ligne, annulation). * @returns La sortie standard (stdout) complète du processus. * @throws {YtDlpError} Si le processus se termine avec un code non nul. */ export function runYtDlp(args: readonly string[], options: RunYtDlpOptions = {}): Promise { const ytDlpPath = process.env.YTDLP_PATH ?? 'yt-dlp'; const { timeoutMs = 30_000, onStdoutLine, signal, onSpawn } = options; return new Promise((resolve, reject) => { const child = spawn(ytDlpPath, [...args], { shell: false, timeout: timeoutMs, signal }); onSpawn?.(child); let stdout = ''; let stderr = ''; const splitter = onStdoutLine ? new LineSplitter(onStdoutLine) : null; child.stdout.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8'); stdout += text; splitter?.push(text); }); child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); child.on('error', (error: Error) => { reject(error); }); child.on('close', (exitCode: number | null) => { splitter?.flush(); if (signal?.aborted) { reject(new DOMException('yt-dlp annulé', 'AbortError')); return; } if (exitCode !== 0) { reject(new YtDlpError(exitCode, stderr)); return; } resolve(stdout); }); }); }