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>
Cœur du pipeline : ajoute une file d'attente exécutant les téléchargements
yt-dlp avec une concurrence bornée, et livre le fichier produit au
navigateur (pas de dépôt durable côté serveur).
- downloadPlan.ts : construction des arguments yt-dlp par mode (vidéo avec
sous-titres/miniature en option, audio dans un format courant, miniature
seule), toujours avec séparateur `--` avant l'URL. Parsing de la
progression depuis stdout (`[download] NN.N%`).
- videoUrl.ts : valide qu'une URL cible bien youtube.com/youtu.be avant de
la passer à yt-dlp - ferme le risque de relais SSRF via l'extracteur
générique de yt-dlp (des centaines de sites supportés) si une URL
arbitraire était acceptée.
- QueueService.ts : file en mémoire, N téléchargements simultanés
(MAX_CONCURRENT_DOWNLOADS), annulation via AbortSignal, nettoyage du
dossier de sortie après échec/annulation ou après livraison du fichier.
- ytDlpRunner.ts : étendu pour le suivi ligne par ligne de stdout
(progression) et l'annulation (AbortSignal passé à child_process.spawn).
- routes/queue.ts : POST/GET /api/queue, DELETE /api/queue/:id,
GET /api/queue/:id/file (streaming + nettoyage après envoi).
45 nouveaux tests (59 au total) : fonctions pures, services (vrai système
de fichiers temporaire, yt-dlp mocké), routes. Vérifié aussi en conditions
réelles (serveur démarré localement) : validation d'URL, ajout en file,
et passage correct à l'état "failed" + nettoyage quand le binaire yt-dlp
est absent (ENOENT).
Empilée sur feature/channel-discovery (réutilise ytDlpRunner.ts).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Première brique du pipeline : lister les vidéos/streams d'une chaîne
YouTube (yt-dlp --flat-playlist), filtrables par mots-clés sur le titre.
- ytDlpRunner.ts : exécution de yt-dlp via child_process.spawn (jamais de
shell, arguments en tableau - exclut l'injection de commande)
- ChannelDiscoveryService.ts : validation stricte du handle/URL de chaîne
+ séparateur `--` avant l'URL (défense en profondeur contre l'injection
d'options yt-dlp), parsing tolérant des lignes JSON, filtrage mots-clés
- routes/channel.ts : GET /api/channel (validation de schéma Fastify,
400 si entrée invalide, 502 si yt-dlp échoue)
- 14 tests (unitaires services + intégration route via app.inject) - un
test de route a révélé une regex de validation trop permissive qu'un
test de service avait manquée, corrigée avant ce commit
La file d'attente et le téléchargement suivront dans une PR séparée.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>