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>
Un seul bloc @media (prefers-color-scheme: dark) dans base.scss
redefinit les custom properties CSS existantes (--color-*) - tout le
reste du CSS de l'app y puise deja, rien d'autre a toucher. Pas de
bascule manuelle demandee.
Verifie en navigateur reel dans les deux modes (emulation
prefers-color-scheme), y compris les etats de couleur de la file
d'attente (echec/termine/en cours) qui restent lisibles en sombre.
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>
- Layout en hauteur pleine (html/body/.shell à 100%/100vh, overflow
hidden sur body) : la page ne défile plus jamais. Seules
download-form__results et queue-panel__list défilent en interne
(flex:1 1 0 + min-height:0 + overflow-y:auto), propagé depuis .shell
via min-height:0 à chaque niveau flex descendant.
- .shell__main : ratio de colonnes 3fr/2fr -> 2fr/1fr et max-width
960px -> 1280px, pour une liste de vidéos plus large.
- Miniatures de résultats agrandies (96x54 -> 144x81, ratio 16:9) et
titre sur 2 lignes (line-clamp) plutôt qu'une troncature sur une seule
ligne, pour mieux différencier les vidéos au premier coup d'œil.
Vérifié en navigateur avec des données de test (16 éléments de file,
12 résultats factices injectés) : bodyScrollHeight reste égal à
windowInnerHeight (page sans overflow) alors que les deux listes ont un
scrollHeight très supérieur à leur clientHeight (défilement interne
réel, pas juste visuel).
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>
- server/Dockerfile : base node:22-bookworm-slim au lieu d'alpine. Le
binaire autonome yt-dlp_linux est lié à la glibc ; sur musl (Alpine),
spawn échouait en prod avec ENOENT bien que le fichier soit présent et
exécutable (interpréteur ELF introuvable). Repéré via un rapport de bug
en conditions réelles. Même choix que mediaDrop pour un souci similaire.
- client/src/styles/base.scss : palette partagée (custom properties CSS),
fond/texte/bordures adoucis, boutons/inputs cohérents, variante
"secondary" pour les actions non-principales (Annuler). Remplace le
rendu par défaut trop contrasté (fond très sombre côté navigateur non
compensé, rouge vif sur les erreurs).
- Placeholders des champs chaîne/mots-clés rendus génériques
(@nomdelachaine, mot-clé1/mot-clé2) au lieu d'un exemple spécifique.
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>
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>