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>
131 lines
4.3 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
}
|