youtubeDownloader/server/src/services/ytDlpRunner.ts
Nicolas 374cfbb192 feat: recherche YouTube globale, avec defilement infini
Nouveau formulaire "Recherche YouTube" (mots-cles + limite optionnelle),
branche sur la zone de resultats existante (miniatures, bouton "Ajouter"
par ligne ; pas de "Tout ajouter", comme demande).

- Avec une limite : tous les resultats d'un coup (une seule page).
- Sans limite : defilement infini. Un element sentinelle en fin de liste,
  observe par un IntersectionObserver (racine = la liste, marge 300 px),
  charge la page suivante ; il est rearme apres chaque page, affiche
  chargement / erreur avec "Reessayer" / "Fin des resultats", et une page
  en echec ne fait pas perdre les precedentes.

Serveur (GET/POST/DELETE /api/search) : une "session" = un seul yt-dlp
(--flat-playlist --lazy-playlist -j -- ytsearch1000:<requete>) servi page
par page depuis un tampon. Pourquoi pas un appel yt-dlp par page - mesure
avec le vrai yt-dlp : l'ordre des resultats de YouTube n'est pas
deterministe meme pour deux appels identiques (5/20 aux memes positions,
69 % de recouvrement sur 100), donc paginer en relancant la recherche
donnait 15 a 30 % de doublons des la 3e-4e page et des trous ; YouTube
s'arrete vers ~590 resultats ; ~7 % de doublons internes (dedoublonnes a
l'ingestion) ; sans --lazy-playlist yt-dlp n'imprime rien avant la fin
(~23 s), avec il emet au fil des pages (20 en ~2,3 s).

Le parcours n'avance qu'a la demande : quand le tampon a 20 resultats
d'avance, la lecture de stdout est suspendue (nouvelle option onSpawn de
runYtDlp) ; le tube se remplit et yt-dlp bloque (mesure : plus aucune
ligne pendant 10 s de pause). On n'aspire donc pas ~590 resultats, et ~30
requetes vers YouTube, quand l'utilisateur ne defile pas.

Cycle de vie : 4 sessions simultanees max (la moins recemment utilisee est
fermee), expiration a 10 min d'inactivite / 30 min de vie, fermeture des
que la derniere page est servie, sur DELETE (liste remplacee cote client),
au pagehide du client, et a l'arret du serveur. Requete validee (1 a 200
caracteres, sans caractere de controle) et transmise en un seul argument
ytsearchN:<requete>, jamais interpretable comme une option.

Mise en page : ce troisieme formulaire laissait a la liste ~104 px (une
ligne) a 1366x650. Une liste non vide garde min-height 13rem et la colonne
du formulaire defile au lieu de rogner (la page ne defile toujours pas).

Verifie : contre le vrai yt-dlp (page tamponnee en 4 ms, page 200 apres
12 s d'inactivite en ~4 s : la suspension tient ; 29 pages -> 576 videos,
576 uniques ; processus fermes a la fin / sur DELETE / au plafond) et dans
le navigateur (580 resultats sans doublon jusqu'a "Fin des resultats",
requetes de page uniques malgre deux signaux simultanes, echec + reprise,
session expiree, remplacement de la recherche, mode limite, ajout d'un
resultat a la file, pagehide, playlist et chaine en non-regression). Le
volet IntersectionObserver a ete pilote par un observateur factice : le
panneau de navigation n'affichait plus de frames (rAF/IO natifs muets).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:43:46 +02:00

131 lines
4.3 KiB
TypeScript

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<string> {
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);
});
});
}