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>
251 lines
8 KiB
TypeScript
251 lines
8 KiB
TypeScript
import type { FastifyBaseLogger } from 'fastify';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
|
||
return { ...actual, runYtDlp: vi.fn() };
|
||
});
|
||
|
||
import {
|
||
SEARCH_DEPTH,
|
||
SearchSession,
|
||
type SearchSessionOptions,
|
||
} from '../src/services/SearchSession.js';
|
||
import { runYtDlp } from '../src/services/ytDlpRunner.js';
|
||
import { type FakeYtDlpRun, entryLine, installFakeYtDlp } from './fakeYtDlp.js';
|
||
|
||
let runs: FakeYtDlpRun[];
|
||
let logger: { info: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||
|
||
/**
|
||
* @param overrides - Réglages à surcharger.
|
||
* @returns Des réglages de session de test (plafond large, longue avance).
|
||
*/
|
||
function options(overrides: Partial<SearchSessionOptions> = {}): SearchSessionOptions {
|
||
return { query: 'speed climbing', max: 1000, readAhead: 20, lifetimeMs: 60_000, ...overrides };
|
||
}
|
||
|
||
/**
|
||
* @param overrides - Réglages à surcharger.
|
||
* @returns Une session ouverte avec le logger de test (son yt-dlp simulé est `runs.at(-1)`).
|
||
*/
|
||
function open(overrides: Partial<SearchSessionOptions> = {}): SearchSession {
|
||
return new SearchSession(logger as unknown as FastifyBaseLogger, options(overrides));
|
||
}
|
||
|
||
/** Laisse s'exécuter les micro-tâches et les callbacks en attente. */
|
||
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(runYtDlp).mockReset();
|
||
runs = installFakeYtDlp(vi.mocked(runYtDlp));
|
||
logger = { info: vi.fn(), error: vi.fn() };
|
||
});
|
||
|
||
describe('SearchSession', () => {
|
||
it('lance yt-dlp en mode paresseux, avec la requête (préfixe ytsearch) et la durée de vie demandée', () => {
|
||
open({ query: 'escalade été', lifetimeMs: 12_345 });
|
||
|
||
expect(runs).toHaveLength(1);
|
||
expect(runs[0]?.args).toEqual([
|
||
'--flat-playlist',
|
||
'--lazy-playlist',
|
||
'-j',
|
||
'--',
|
||
`ytsearch${SEARCH_DEPTH}:escalade été`,
|
||
]);
|
||
expect(runs[0]?.options.timeoutMs).toBe(12_345);
|
||
});
|
||
|
||
it('attend d’avoir assez de résultats avant de répondre à une page', async () => {
|
||
const session = open();
|
||
let settled = false;
|
||
const pagePromise = session.page(0, 3).then((page) => {
|
||
settled = true;
|
||
return page;
|
||
});
|
||
|
||
runs[0]?.emitEntries(2);
|
||
await flush();
|
||
expect(settled).toBe(false);
|
||
|
||
runs[0]?.emitEntries(1);
|
||
const page = await pagePromise;
|
||
|
||
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2', 'id3']);
|
||
expect(page.nextOffset).toBe(3);
|
||
});
|
||
|
||
it('sert les pages suivantes depuis le tampon, sans relancer yt-dlp', async () => {
|
||
const session = open();
|
||
const first = session.page(0, 2);
|
||
runs[0]?.emitEntries(6);
|
||
await first;
|
||
|
||
const second = await session.page(2, 2);
|
||
const third = await session.page(4, 2);
|
||
|
||
expect(second.videos.map((video) => video.id)).toEqual(['id3', 'id4']);
|
||
expect(third.videos.map((video) => video.id)).toEqual(['id5', 'id6']);
|
||
expect(runs).toHaveLength(1);
|
||
});
|
||
|
||
it('ignore les doublons, les lignes illisibles et les entrées incomplètes', async () => {
|
||
const session = open();
|
||
const pagePromise = session.page(0, 10);
|
||
|
||
runs[0]?.emit(entryLine(1));
|
||
runs[0]?.emit(entryLine(1)); // doublon : YouTube répète parfois une vidéo
|
||
runs[0]?.emit('ceci n’est pas du JSON');
|
||
runs[0]?.emit(JSON.stringify({ id: 'sans-url', title: 'Incomplète' }));
|
||
runs[0]?.emit(entryLine(2));
|
||
runs[0]?.finish();
|
||
const page = await pagePromise;
|
||
|
||
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2']);
|
||
});
|
||
|
||
it('signale la fin des résultats : page partielle et nextOffset null', async () => {
|
||
const session = open();
|
||
const pagePromise = session.page(0, 5);
|
||
|
||
runs[0]?.emitEntries(2);
|
||
runs[0]?.finish();
|
||
const page = await pagePromise;
|
||
|
||
expect(page.videos).toHaveLength(2);
|
||
expect(page.nextOffset).toBeNull();
|
||
});
|
||
|
||
it('signale la fin par une page vide quand la dernière page tombe pile sur le dernier résultat', async () => {
|
||
const session = open();
|
||
const first = session.page(0, 4);
|
||
|
||
runs[0]?.emitEntries(4);
|
||
const firstPage = await first;
|
||
// yt-dlp n'a pas encore signalé sa fin au moment où la page est pleine :
|
||
// une suite reste possible, la page suivante tranche.
|
||
expect(firstPage.videos).toHaveLength(4);
|
||
expect(firstPage.nextOffset).toBe(4);
|
||
|
||
runs[0]?.finish();
|
||
const next = await session.page(4, 4);
|
||
|
||
expect(next).toEqual({ videos: [], nextOffset: null });
|
||
});
|
||
|
||
it('renvoie une page vide et la fin quand on demande au-delà des résultats', async () => {
|
||
const session = open();
|
||
const first = session.page(0, 2);
|
||
runs[0]?.emitEntries(2);
|
||
runs[0]?.finish();
|
||
await first;
|
||
|
||
const beyond = await session.page(50, 10);
|
||
|
||
expect(beyond).toEqual({ videos: [], nextOffset: null });
|
||
});
|
||
|
||
it('s’arrête au plafond : annule yt-dlp, ignore la suite et signale la fin', async () => {
|
||
const session = open({ max: 3 });
|
||
const pagePromise = session.page(0, 10);
|
||
|
||
runs[0]?.emitEntries(5);
|
||
const page = await pagePromise;
|
||
|
||
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2', 'id3']);
|
||
expect(page.nextOffset).toBeNull();
|
||
expect(runs[0]?.aborted).toBe(true);
|
||
expect(logger.error).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('suspend la lecture quand le tampon a assez d’avance, et la reprend à la demande', async () => {
|
||
const session = open({ readAhead: 2 });
|
||
const first = session.page(0, 2); // demande : 2 résultats
|
||
runs[0]?.emitEntries(3);
|
||
await first;
|
||
expect(runs[0]?.stdout.pause).not.toHaveBeenCalled(); // 3 < 2 + 2
|
||
|
||
runs[0]?.emitEntries(1); // 4 >= 2 + 2 : assez d'avance
|
||
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
|
||
expect(runs[0]?.stdout.resume).not.toHaveBeenCalled();
|
||
|
||
const next = await session.page(2, 2); // servi depuis le tampon...
|
||
expect(next.videos.map((video) => video.id)).toEqual(['id3', 'id4']);
|
||
expect(runs[0]?.stdout.resume).toHaveBeenCalledTimes(1); // ...et le parcours repart
|
||
});
|
||
|
||
it('ne suspend qu’une fois tant qu’il n’a pas été relancé', async () => {
|
||
const session = open({ readAhead: 1 });
|
||
const first = session.page(0, 1);
|
||
runs[0]?.emitEntries(2); // suspend
|
||
await first;
|
||
runs[0]?.emitEntries(3); // encore des lignes déjà dans le tube au moment de la pause
|
||
|
||
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('lève l’erreur quand yt-dlp échoue avant le moindre résultat', async () => {
|
||
const session = open();
|
||
const pagePromise = session.page(0, 5);
|
||
|
||
runs[0]?.fail(new Error('boom'));
|
||
|
||
await expect(pagePromise).rejects.toThrow('boom');
|
||
expect(logger.error).toHaveBeenCalled();
|
||
});
|
||
|
||
it('sert ce qu’il a et signale la fin quand yt-dlp échoue après des résultats', async () => {
|
||
const session = open();
|
||
const pagePromise = session.page(0, 10);
|
||
|
||
runs[0]?.emitEntries(2);
|
||
runs[0]?.fail(new Error('réseau coupé'));
|
||
const page = await pagePromise;
|
||
|
||
expect(page.videos).toHaveLength(2);
|
||
expect(page.nextOffset).toBeNull();
|
||
expect(logger.error).toHaveBeenCalled();
|
||
});
|
||
|
||
it('dispose : annule yt-dlp sans le compter comme un échec, et libère les appels en attente', async () => {
|
||
const session = open();
|
||
const pending = session.page(0, 5);
|
||
|
||
session.dispose('test');
|
||
const page = await pending;
|
||
await flush();
|
||
|
||
expect(runs[0]?.aborted).toBe(true);
|
||
expect(page).toEqual({ videos: [], nextOffset: null });
|
||
expect(logger.error).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('dispose relance un flux suspendu pour qu’il puisse se vider et se fermer', async () => {
|
||
const session = open({ readAhead: 1 });
|
||
const first = session.page(0, 1);
|
||
runs[0]?.emitEntries(2); // suspend
|
||
await first;
|
||
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
|
||
|
||
session.dispose('test');
|
||
|
||
expect(runs[0]?.stdout.resume).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('dispose est idempotent', () => {
|
||
const session = open();
|
||
|
||
session.dispose('premier');
|
||
session.dispose('second');
|
||
|
||
expect(logger.info).toHaveBeenCalledWith(
|
||
expect.objectContaining({ reason: 'premier' }),
|
||
expect.any(String),
|
||
);
|
||
expect(logger.info).not.toHaveBeenCalledWith(
|
||
expect.objectContaining({ reason: 'second' }),
|
||
expect.any(String),
|
||
);
|
||
});
|
||
});
|