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>
94 lines
2.9 KiB
TypeScript
94 lines
2.9 KiB
TypeScript
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
import { type Mock, type MockedFunction, vi } from 'vitest';
|
|
import type { RunYtDlpOptions, runYtDlp } from '../src/services/ytDlpRunner.js';
|
|
|
|
/** Un appel simulé à yt-dlp, pilotable depuis le test. */
|
|
export interface FakeYtDlpRun {
|
|
/** Arguments reçus par `runYtDlp`. */
|
|
readonly args: readonly string[];
|
|
/** Options reçues par `runYtDlp`. */
|
|
readonly options: RunYtDlpOptions;
|
|
/** Espions sur les méthodes de stdout du faux processus. */
|
|
readonly stdout: { pause: Mock; resume: Mock };
|
|
/** `true` dès que le signal d'annulation fourni à `runYtDlp` a été déclenché. */
|
|
readonly aborted: boolean;
|
|
/** Émet une ligne de stdout, comme le ferait le processus. */
|
|
emit: (line: string) => void;
|
|
/** Émet `count` entrées valides supplémentaires (numérotées à la suite des précédentes). */
|
|
emitEntries: (count: number) => void;
|
|
/** Termine le processus avec succès. */
|
|
finish: () => void;
|
|
/** Termine le processus en échec. */
|
|
fail: (error: Error) => void;
|
|
}
|
|
|
|
/**
|
|
* @param index - Numéro de l'entrée (à partir de 1).
|
|
* @returns Une ligne `yt-dlp --flat-playlist -j` valide pour cette entrée.
|
|
*/
|
|
export function entryLine(index: number): string {
|
|
return JSON.stringify({
|
|
id: `id${index}`,
|
|
title: `Vidéo ${index}`,
|
|
url: `https://www.youtube.com/watch?v=id${index}`,
|
|
duration: 60,
|
|
thumbnails: [{ url: `https://i.ytimg.com/vi/id${index}/hq.jpg` }],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remplace `runYtDlp` par un faux qui reste « en cours » tant que le test ne
|
|
* le termine pas, et dont le test pilote la sortie ligne par ligne.
|
|
*
|
|
* @param mockedRunYtDlp - Le `runYtDlp` mocké du module testé.
|
|
* @returns La liste (alimentée au fil des appels) des exécutions simulées.
|
|
*/
|
|
export function installFakeYtDlp(mockedRunYtDlp: MockedFunction<typeof runYtDlp>): FakeYtDlpRun[] {
|
|
const runs: FakeYtDlpRun[] = [];
|
|
|
|
mockedRunYtDlp.mockImplementation((args, options = {}) => {
|
|
const stdout = { pause: vi.fn(), resume: vi.fn() };
|
|
let settle: { resolve: (value: string) => void; reject: (reason: unknown) => void } | null =
|
|
null;
|
|
const promise = new Promise<string>((resolve, reject) => {
|
|
settle = { resolve, reject };
|
|
});
|
|
let emitted = 0;
|
|
|
|
const run: FakeYtDlpRun = {
|
|
args,
|
|
options,
|
|
stdout,
|
|
get aborted() {
|
|
return options.signal?.aborted ?? false;
|
|
},
|
|
emit: (line) => {
|
|
options.onStdoutLine?.(line);
|
|
},
|
|
emitEntries: (count) => {
|
|
for (let i = 0; i < count; i += 1) {
|
|
emitted += 1;
|
|
options.onStdoutLine?.(entryLine(emitted));
|
|
}
|
|
},
|
|
finish: () => {
|
|
settle?.resolve('');
|
|
},
|
|
fail: (error) => {
|
|
settle?.reject(error);
|
|
},
|
|
};
|
|
|
|
options.signal?.addEventListener('abort', () => {
|
|
const error = new Error('yt-dlp annulé');
|
|
error.name = 'AbortError';
|
|
settle?.reject(error);
|
|
});
|
|
options.onSpawn?.({ stdout } as unknown as ChildProcessWithoutNullStreams);
|
|
|
|
runs.push(run);
|
|
return promise;
|
|
});
|
|
|
|
return runs;
|
|
}
|