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>
Coller un lien de playlist (youtube.com/playlist?list=...) dans le champ
URL affiche toutes ses videos dans la meme zone de resultats que la
recherche de chaine ; chaque ligne a son bouton "Ajouter", et un bouton
"Tout ajouter a la file" ajoute le reste d'un coup.
Serveur :
- GET /api/playlist (PlaylistDiscoveryService) : yt-dlp --flat-playlist
-J. -J plutot que -j : le titre de la playlist n'existe qu'a la racine,
et une playlist vide reste un resultat valide.
- playlistUrl.ts : n'accepte que les liens youtube.com/playlist?list=<id>
et reconstruit l'URL canonique depuis le seul identifiant valide (rien
d'autre de l'entree n'atteint yt-dlp). Un lien watch?v=...&list=...
reste une video (--no-playlist etait deja passe au telechargement).
- videoSummary.ts : conversion d'une entree --flat-playlist en resume de
video, extraite de ChannelDiscoveryService pour etre partagee (chaine
et playlist parsent desormais identiquement). ChannelVideoSummary
devient VideoSummary (et ChannelVideoDto -> VideoDto cote client).
Client :
- Le champ URL accepte video ou playlist (isPlaylistUrl aiguille) ; zone
de resultats partagee (createResultsView).
- "Tout ajouter" : POST /api/queue sequentiels (ordre conserve, pas de
doublon avec les lignes deja ajoutees). Un echec n'interrompt pas le
lot : nombre d'echecs + premier message affiches, seules les lignes en
echec redeviennent ajoutables. Le mode est lu une seule fois pour tout
le lot.
- Miniatures de resultats en loading="lazy" (une playlist peut compter
des centaines de videos).
Verifie contre le vrai yt-dlp (2026.07.04) : forme reelle de -J relevee
(fixtures de test calquees dessus), playlist de 1607 videos listee en
~11 s (delai fixe a 120 s). Teste dans le navigateur reel : playlist de
13 videos, ajout unitaire, "tout ajouter" (13 elements, aucun doublon),
echec partiel simule + reprise, playlist inexistante, lien video avec
list=, recherche de chaine (non-regression).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deux causes distinctes au meme symptome (aucune ligne de liste n'affichait
de miniature) :
- ChannelDiscoveryService : en --flat-playlist, l'extracteur YouTube de
yt-dlp ne renseigne quasiment jamais le champ singulier `thumbnail`,
seulement `thumbnails` (tableau, trie par resolution croissante). Le
parsing ne lisait que `thumbnail` -> thumbnailUrl toujours null. Passe
inapercu en test car le mock ne reproduisait que la forme theorique,
jamais la forme reelle observee en prod. extractThumbnailUrl() lit
desormais aussi `thumbnails`, en repli.
- QueuePanel (file d'attente) : aucun champ thumbnailUrl n'existait sur
QueueItem/QueueItemDto - fonctionnalite jamais implementee, pas de
regression. Ajoutee de bout en bout : POST /api/queue accepte
thumbnailUrl (purement informatif, jamais transmis a yt-dlp),
QueueService la conserve et l'expose, DownloadForm la transmet depuis
les resultats de recherche, QueuePanel l'affiche (miniature 96x54 a
gauche de chaque ligne).
Verifie en navigateur reel (pas seulement les tests) : element ajoute via
POST direct avec thumbnailUrl, image reellement chargee (naturalWidth 480)
et positionnee dans le DOM rendu.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remplace le "tout télécharger" par téléchargements individuels décalés
(PR précédente) par une unique archive zip, streamée sans être bufferisée
en mémoire (paquet `archiver`).
- server/src/routes/queue.ts : GET /api/queue/download-all, construit le
zip à la volée depuis les fichiers des éléments `completed`, noms
dédupliqués en cas de titres identiques. Point d'attention : le flux
est envoyé à la réponse AVANT archive.finalize(), pas l'inverse -
finalize() n'aboutit qu'une fois tout le contenu écrit dans le flux, ce
qui bloquerait indéfiniment sur une grosse archive si rien ne la lit
encore.
- QueueService : buildDelivery() factorise la logique de livraison
(chemin + nettoyage post-envoi) entre consumeCompletedFile (un fichier)
et le nouveau consumeAllCompletedFiles (tous les fichiers prêts).
- QueuePanel : le bouton "Tout télécharger" déclenche maintenant un seul
lien vers /api/queue/download-all. Tous les liens de téléchargement
(individuel et groupé) portent target="_blank" : si jamais rien n'est
disponible côté serveur au moment du clic, l'erreur s'ouvre dans un
nouvel onglet plutôt que de remplacer l'application.
- test/helpers.ts : extractOutputDir et waitFor factorisés (dupliqués
entre deux fichiers de test) ; waitFor accepte maintenant un prédicat
async (la version précédente, sync uniquement, aurait toujours vu un
prédicat async comme "vrai" immédiatement - une Promise est truthy).
4 nouveaux tests, dont un qui vérifie la signature zip réelle (PK\x03\x04)
sur une archive construite à partir de vrais fichiers temporaires (pas
juste des mocks) - 65 tests serveur au total. Vérifié aussi sur le
serveur réel en cours d'exécution (pas seulement les tests automatisés) :
GET /api/queue/download-all répond bien 404 sur une file vide, bouton
correctement cablé et masqué côté client.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- QueueService.remove() remplace cancel() : supprime l'élément de la
liste quel que soit son état (annule d'abord s'il était en attente/en
cours), au lieu de se limiter aux deux cas actifs et renvoyer false
pour un élément déjà terminé.
- consumeCompletedFile() met fileName à null une fois le fichier livré
(et son dossier nettoyé) : évite qu'un second appel tente de relire un
fichier qui n'existe déjà plus.
- QueuePanel : un même bouton retire l'élément quel que soit son état -
"Annuler" en attente/en cours, "Supprimer" une fois terminé. Bouton
"Tout télécharger" dans l'en-tête, qui déclenche avec un léger décalage
chaque téléchargement encore disponible (évite le blocage navigateur
des téléchargements multiples simultanés).
- Liste de résultats de recherche de chaîne agrandie (320px -> 65vh).
11 nouveaux tests côté serveur (63 -> alignés). Vérifié en navigateur :
ajout, échec, suppression effective (l'élément disparaît de la liste).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLIENT_DIST_PATH et TMP_DIR se résolvaient via process.cwd(), donc
uniquement correct si le process est lancé depuis server/. Repéré en
mettant en place la prévisualisation locale (.claude/launch.json, non
versionné) : npm --prefix server run dev depuis la racine du dépôt ne
change pas le cwd du script lancé, `npm --prefix server exec -- node -e
"console.log(process.cwd())"` le confirme (reste la racine du dépôt).
Résout désormais les deux chemins depuis l'emplacement du module
(import.meta.url) plutôt que process.cwd() - invariant quel que soit le
répertoire de lancement, y compris en Docker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Interface web complète : formulaire d'ajout (URL directe ou recherche de
chaîne) et panneau de file d'attente avec sondage de la progression.
- views/downloadForm/ : sélecteur de mode (vidéo/audio/miniature) partagé,
ajout direct par URL, recherche de chaîne (GET /api/channel) avec ajout
par résultat. Construction DOM via createElement/textContent (jamais
d'innerHTML avec du texte venant de YouTube), pas juste par prudence :
ça évite aussi tout risque d'injection depuis un titre de vidéo.
- views/queuePanel/ : sonde GET /api/queue toutes les 1,5s, affiche statut/
progression/erreur par élément, annulation et lien de téléchargement
natif (<a download>, pas de fetch+blob).
- api/ : clients fetch typés (channelApi, queueApi), chaque await dans son
propre try/catch avec message d'erreur adapté (réseau vs HTTP vs JSON).
- QueueItem gagne un champ title optionnel (purement informatif, jamais
utilisé pour construire la commande yt-dlp) pour un affichage plus
parlant qu'une URL brute.
- client/tsconfig.json + build.mjs : cible ES2022 (nécessaire pour
Error.cause, jusque-là ES2020).
- Vitest ajouté côté client (fonctions pures de src/format.ts ; le câblage
DOM est vérifié manuellement, pas de jsdom pour l'instant).
Deux bugs réels trouvés et corrigés en testant dans un vrai navigateur
(pas seulement en lisant le code) :
- CSS : un attribut `hidden` sur un conteneur `display: flex` se faisait
écraser par cette règle (même spécificité, la feuille de l'auteur gagne)
- les deux panneaux d'options restaient visibles en même temps.
- server/src/routes/channel.ts : ne mappait vers 502 que les YtDlpError
(code de sortie non nul), pas les erreurs brutes de spawn (ex. binaire
introuvable - ENOENT), qui remontaient donc en 500 générique. Corrigé +
test de non-régression.
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>
Pose l'infrastructure de base avant l'implémentation des fonctionnalités :
- server/ : Fastify + TypeScript (ESM), route /health, sert client/dist
- client/ : TypeScript vanilla + esbuild/sass, vue placeholder (shell)
- server/Dockerfile : image multi-étapes avec yt-dlp + ffmpeg embarqués
- .forgejo/workflows/publish-docker.yml : test puis publication sur le
registre de conteneurs Forgejo (nécessite le secret REGISTRY_TOKEN)
- biome.json : lint du repo entier (downloader/ exclu)
- spec/architecture.md : documentation de ces choix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>