From 374cfbb1923aee89a1431ced896332306ac5d910 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 19 Sep 2026 15:43:46 +0200 Subject: [PATCH] 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:) 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:, 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 --- README.md | 1 + client/src/api/searchApi.ts | 126 +++++++ client/src/api/videoDto.ts | 8 + client/src/searchLimit.ts | 16 + client/src/styles/base.scss | 1 + .../src/views/downloadForm/DownloadForm.html | 13 + .../src/views/downloadForm/DownloadForm.scss | 37 ++ client/src/views/downloadForm/DownloadForm.ts | 344 ++++++++++++++++-- client/test/searchLimit.test.ts | 27 ++ server/src/index.ts | 2 + server/src/routes/search.ts | 128 +++++++ server/src/services/SearchService.ts | 210 +++++++++++ server/src/services/SearchSession.ts | 272 ++++++++++++++ server/src/services/searchQuery.ts | 60 +++ server/src/services/ytDlpRunner.ts | 12 +- server/test/fakeYtDlp.ts | 94 +++++ server/test/searchQuery.test.ts | Bin 0 -> 1311 bytes server/test/searchRoute.test.ts | 181 +++++++++ server/test/searchService.test.ts | 189 ++++++++++ server/test/searchSession.test.ts | 251 +++++++++++++ server/test/ytDlpRunner.test.ts | 30 ++ spec/architecture.md | 19 +- 22 files changed, 1991 insertions(+), 30 deletions(-) create mode 100644 client/src/api/searchApi.ts create mode 100644 client/src/searchLimit.ts create mode 100644 client/test/searchLimit.test.ts create mode 100644 server/src/routes/search.ts create mode 100644 server/src/services/SearchService.ts create mode 100644 server/src/services/SearchSession.ts create mode 100644 server/src/services/searchQuery.ts create mode 100644 server/test/fakeYtDlp.ts create mode 100644 server/test/searchQuery.test.ts create mode 100644 server/test/searchRoute.test.ts create mode 100644 server/test/searchService.test.ts create mode 100644 server/test/searchSession.test.ts diff --git a/README.md b/README.md index aa3c831..f9e9a51 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Ce projet reprend et remplace une série de scripts `.cmd` (téléchargement vid - **Parcourir une chaîne** : recherche par chaîne (URL ou `@handle`), liste de ses vidéos/streams avec filtre par mots-clés dans le titre. - **Ajout par URL** : ajout direct d'une ou plusieurs URLs YouTube. +- **Recherche YouTube** : recherche globale par mots-clés, avec une limite de résultats optionnelle ; sans limite, la liste se prolonge en défilant jusqu'à la fin des résultats de YouTube. - **Playlists** : coller un lien de playlist (`youtube.com/playlist?list=...`) affiche toutes ses vidéos, à ajouter une par une ou d'un coup (« Tout ajouter à la file »). - **File d'attente** : chaque élément ajouté est configurable individuellement : - **Vidéo** : qualité, sous-titres (fr/en), miniature intégrée, métadonnées. diff --git a/client/src/api/searchApi.ts b/client/src/api/searchApi.ts new file mode 100644 index 0000000..9bb5b44 --- /dev/null +++ b/client/src/api/searchApi.ts @@ -0,0 +1,126 @@ +import type { VideoPage } from './videoDto'; + +/** Recherche ouverte côté serveur : son identifiant et sa première page. */ +export interface SearchStart extends VideoPage { + /** Identifiant à fournir pour lire les pages suivantes et fermer la recherche. */ + id: string; +} + +/** + * Extrait le message d'erreur d'une réponse HTTP en échec, avec un message + * de repli si le corps n'est pas un JSON exploitable. + * + * @param response - Réponse HTTP dont le statut n'est pas OK. + * @param fallback - Message à utiliser si le corps ne fournit pas d'erreur. + * @returns Le message d'erreur à afficher. + */ +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const body = (await response.json()) as { error?: string }; + return body.error ?? fallback; + } catch { + return fallback; + } +} + +/** + * Ouvre une recherche YouTube via `POST /api/search` et retourne sa première + * page. Le serveur garde la recherche ouverte pour servir les pages suivantes + * (voir {@link fetchSearchPage}) ; la fermer avec {@link closeSearch}. + * + * @param query - Mots-clés de la recherche. + * @param max - Nombre maximal de résultats au total, ou `null` pour aucune limite. + * @param limit - Taille de la première page. + * @returns L'identifiant de la recherche et sa première page. + * @throws {Error} Si la requête échoue ou si le serveur répond une erreur. + */ +export async function startSearch( + query: string, + max: number | null, + limit: number, +): Promise { + let response: Response; + try { + response = await fetch('/api/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, limit, max: max ?? undefined }), + }); + } catch (error) { + throw new Error('Impossible de contacter le serveur.', { cause: error }); + } + + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Échec de la recherche (${response.status}).`), + ); + } + + try { + return (await response.json()) as SearchStart; + } catch (error) { + throw new Error('Réponse du serveur invalide.', { cause: error }); + } +} + +/** + * Lit une page d'une recherche ouverte, via `GET /api/search/:id`. + * + * @param id - Identifiant retourné par {@link startSearch}. + * @param offset - Position du premier résultat de la page (le `nextOffset` précédent). + * @param limit - Nombre de résultats demandés. + * @returns La page et la position de la suivante. + * @throws {Error} Si la requête échoue, ou si la recherche a expiré côté serveur. + */ +export async function fetchSearchPage( + id: string, + offset: number, + limit: number, +): Promise { + const params = new URLSearchParams({ offset: String(offset), limit: String(limit) }); + + let response: Response; + try { + response = await fetch(`/api/search/${encodeURIComponent(id)}?${params.toString()}`); + } catch (error) { + throw new Error('Impossible de contacter le serveur.', { cause: error }); + } + + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Échec du chargement des résultats (${response.status}).`), + ); + } + + try { + return (await response.json()) as VideoPage; + } catch (error) { + throw new Error('Réponse du serveur invalide.', { cause: error }); + } +} + +/** + * Ferme une recherche côté serveur (`DELETE /api/search/:id`), ce qui libère + * son processus yt-dlp. Une recherche déjà fermée ou expirée (`404`) n'est + * pas une erreur. + * + * @param id - Identifiant de la recherche à fermer. + * @throws {Error} Si le serveur est injoignable ou répond une erreur autre que « introuvable ». + */ +export async function closeSearch(id: string): Promise { + let response: Response; + try { + response = await fetch(`/api/search/${encodeURIComponent(id)}`, { + method: 'DELETE', + keepalive: true, + }); + } catch (error) { + throw new Error('Impossible de contacter le serveur.', { cause: error }); + } + + if (!response.ok && response.status !== 404) { + throw new Error( + await readErrorMessage(response, `Échec de la fermeture (${response.status}).`), + ); + } +} diff --git a/client/src/api/videoDto.ts b/client/src/api/videoDto.ts index fadc70f..d9cd5e4 100644 --- a/client/src/api/videoDto.ts +++ b/client/src/api/videoDto.ts @@ -13,3 +13,11 @@ export interface VideoDto { /** Date de mise en ligne au format `YYYYMMDD`, si connue. */ uploadDate: string | null; } + +/** Une page de vidéos, avec la position de la suivante (miroir du DTO serveur). */ +export interface VideoPage { + /** Vidéos de la page. */ + videos: VideoDto[]; + /** Position à demander pour la page suivante, ou `null` s'il n'y a plus de résultats. */ + nextOffset: number | null; +} diff --git a/client/src/searchLimit.ts b/client/src/searchLimit.ts new file mode 100644 index 0000000..560f0c4 --- /dev/null +++ b/client/src/searchLimit.ts @@ -0,0 +1,16 @@ +/** + * Lit le champ « Limite » du formulaire de recherche. + * + * @param raw - Contenu brut du champ. + * @returns Le nombre maximal de résultats voulu (entier >= 1), ou `null` + * quand il n'y a pas de limite (champ vide, ou valeur qui n'est pas un + * entier positif) - la recherche défile alors à l'infini. + */ +export function parseSearchLimit(raw: string): number | null { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) { + return null; + } + const value = Number.parseInt(trimmed, 10); + return value >= 1 ? value : null; +} diff --git a/client/src/styles/base.scss b/client/src/styles/base.scss index 9626016..e3082ff 100644 --- a/client/src/styles/base.scss +++ b/client/src/styles/base.scss @@ -73,6 +73,7 @@ button { input[type='text'], input[type='url'], +input[type='number'], select { border: 1px solid var(--color-border); border-radius: var(--radius); diff --git a/client/src/views/downloadForm/DownloadForm.html b/client/src/views/downloadForm/DownloadForm.html index 28e27cc..066fdfc 100644 --- a/client/src/views/downloadForm/DownloadForm.html +++ b/client/src/views/downloadForm/DownloadForm.html @@ -34,6 +34,19 @@
+
+ + + +
+ +