youtubeDownloader/spec/architecture.md
Nicolas 725c5a5a7f feat(client): frontend (formulaire d'ajout + file d'attente)
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>
2026-09-16 09:28:49 +02:00

9 KiB

Architecture

Contexte

Remplace une série de scripts .cmd (./downloader, non versionné) par une application web : parcourir une chaîne YouTube, mettre des vidéos/audios en file d'attente, les télécharger en parallèle avec suivi en temps réel, et les livrer au navigateur de l'utilisateur. Pas d'intégration Emby, pas d'authentification (usage LAN).

Structure du dépôt

server/   Backend Fastify (TypeScript, ESM)
client/   Frontend TypeScript vanilla (esbuild + sass)
.forgejo/workflows/   CI : lint + typecheck + tests + build, puis publication Docker

Pas de docker-compose.yml pour l'instant (une seule image, pas encore de dépendances externes type base de données).

Backend (server/)

  • Fastify 5, TypeScript strict, ESM ("type": "module", imports relatifs en .js, résolution NodeNext).
  • src/index.ts : bootstrap (plugins, écoute, arrêt propre SIGINT/SIGTERM).
  • src/config.ts : configuration runtime depuis l'environnement (PORT, HOST, CLIENT_DIST_PATH).
  • src/routes/ : routes Fastify enregistrées comme plugins (health.ts, channel.ts, queue.ts). Chaque domaine métier suit le même principe : un plugin de route fin sous src/routes/, qui délègue à un service sous src/services/.
  • src/services/ :
    • ytDlpRunner.ts : exécution bas niveau de yt-dlp (node:child_process.spawn, jamais de shell - arguments toujours passés en tableau, jamais concaténés dans une chaîne, pour exclure toute injection de commande). Supporte le suivi ligne par ligne de stdout (onStdoutLine, pour la progression) et l'annulation via AbortSignal.
    • ChannelDiscoveryService.ts : liste les vidéos/streams d'une chaîne (yt-dlp --flat-playlist -j), filtre par mots-clés sur le titre. Valide strictement le handle/URL de chaîne en entrée (premier caractère forcément alphanumérique) et ajoute un séparateur -- avant l'URL passée à yt-dlp, en défense en profondeur contre l'injection d'options.
    • videoUrl.ts : valide qu'une URL de vidéo est bien sur youtube.com/youtu.be (formats watch/shorts/live/court) avant de la transmettre à yt-dlp. N'accepte aucun autre domaine : yt-dlp sait suivre des centaines de sites via un extracteur générique capable de récupérer une URL quelconque, ce qui en ferait un relais SSRF si cette validation n'existait pas.
    • downloadPlan.ts : fonctions pures construisant les arguments yt-dlp selon le mode (vidéo/audio/miniature) et parsant la progression ([download] 42.0% ...) depuis stdout. Toujours un séparateur -- avant l'URL (même défense qu'au-dessus).
    • QueueService.ts : file d'attente en mémoire, exécution avec concurrence bornée (MAX_CONCURRENT_DOWNLOADS, défaut 2). Chaque élément a son propre sous-dossier sous TMP_DIR, supprimé après échec/annulation ou après livraison du fichier au client (consumeCompletedFile -> onSent) : le serveur ne conserve jamais durablement les fichiers.
  • Le serveur sert aussi les fichiers statiques de client/dist via @fastify/static.
  • Tests : Vitest (server/test/). Trois niveaux : fonctions pures testées directement (downloadPlan.ts, videoUrl.ts), services testés avec ytDlpRunner mocké mais un vrai système de fichiers temporaire (QueueService.test.ts, plus réaliste qu'un mock fs), et routes testées via app.inject(). Les trois niveaux comptent : un test de service avec un exemple d'entrée mal choisi avait laissé passer une regex de validation trop permissive (--exec accepté), repérée seulement par le test de route avec un autre exemple (voir channelDiscoveryService.test.ts/channelRoute.test.ts).

Frontend (client/)

  • TypeScript vanilla, pas de framework. Chaque vue = un dossier avec Vue.ts / Vue.html / Vue.scss (gabarit importé comme chaîne brute, style Sass importé et compilé).
  • Build via build.mjs (esbuild), pas d'outil de build plus lourd (Vite, webpack) : le script compile src/app.ts en bundle ESM, copie src/index.html, et compile/regroupe les .scss importés en un app.css voisin (plugin esbuild maison qui délègue au paquet sass). Cible ES2022 (nécessaire pour Error.cause, utilisé dans api/).
  • Pas de router : src/shell/Shell.ts monte deux vues fixes côte à côte (views/downloadForm/, views/queuePanel/), reliées par un callback simple (onItemQueued) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire.
  • src/api/ : clients fetch vers le backend (channelApi.ts, queueApi.ts), avec leurs propres types DTO - pas de paquet de types partagé avec server/ (monorepo simple, pas de workspace npm).
  • src/views/downloadForm/ : formulaire d'ajout, deux entrées possibles (URL directe, ou recherche de chaîne via GET /api/channel puis ajout par résultat) partageant un même sélecteur de mode (vidéo/audio/miniature + options). Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé.
  • src/views/queuePanel/ : sonde GET /api/queue toutes les 1,5s (pas de push temps réel pour l'instant, voir Statut) et affiche chaque élément (titre ou URL à défaut, statut, barre de progression, erreur, bouton annuler/lien de téléchargement selon le statut). Expose refresh() pour un rafraîchissement immédiat après un ajout.
  • Piège CSS relevé en testant dans un vrai navigateur (pas seulement les tests) : un attribut hidden combiné à une règle display: flex sur le même sélecteur se fait écraser par cette règle (même spécificité, la feuille de l'auteur gagne sur celle du user-agent) - toujours ajouter &[hidden] { display: none; } quand un conteneur togglé porte aussi un display explicite.
  • Tests : Vitest (client/test/), uniquement sur les fonctions pures (format.ts) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement.

API

  • GET /health : statut du service (utilisé par le HEALTHCHECK Docker).
  • GET /api/channel?channel=<@handle|URL>&section=<videos|streams>&keywords=<a,b> : liste les vidéos/streams d'une chaîne, filtrés par mots-clés (ET, insensible à la casse) sur le titre. section par défaut videos. Réponses : 200 { videos: [...] }, 400 si channel est absent/invalide, 502 si yt-dlp échoue.
  • POST /api/queue : ajoute un élément ({ url, title?, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }). title est purement informatif (affichage), jamais utilisé pour construire la commande yt-dlp. 201 { item }, 400 si l'URL n'est pas une URL de vidéo YouTube reconnue.
  • GET /api/queue : liste tous les éléments ({ items: [...] }), avec leur status (pending/running/completed/failed/cancelled), progress (0-100, best-effort) et error.
  • DELETE /api/queue/:id : annule un élément (204) - retiré de la file s'il était en attente, processus yt-dlp arrêté s'il était en cours. 404 si inconnu ou déjà terminé.
  • GET /api/queue/:id/file : télécharge le fichier produit par un élément completed (Content-Disposition: attachment). Le dossier de sortie de l'élément est supprimé juste après l'envoi. 404 tant que l'élément n'est pas completed.

Lint

Biome.js à la racine (biome.json), applique aux deux paquets (server/src, client/src) - pas d'ESLint/Prettier. downloader/ (scripts legacy, non versionné) est explicitement exclu.

Docker / CI-CD

  • server/Dockerfile : image multi-étapes construite avec pour contexte la racine du dépôt (docker build -f server/Dockerfile .), car elle a besoin à la fois de server/ et client/. Étapes : build client (esbuild) → build serveur (tsc) → image runtime (Node 22 Alpine + ffmpeg + binaire autonome yt-dlp, utilisateur non-root node).
  • yt-dlp et ffmpeg sont installés dans l'image (pas vendorisés dans le dépôt, contrairement aux scripts .cmd d'origine qui embarquaient yt-dlp.exe et un build ffmpeg complet).
  • .forgejo/workflows/publish-docker.yml : job test (lint racine, typecheck/tests/build serveur, typecheck/tests/build client) puis job publish (build + push de l'image vers git.dev.kyuno.fr/kyuno/youtubedownloader, seulement si test passe et hors PR). Tags : latest + sha court sur push main, X.Y.Z/X.Y/latest sur tag vX.Y.Z. Nécessite le secret de dépôt REGISTRY_TOKEN (à créer manuellement, voir l'en-tête du workflow - un jeton d'API n'a pas le droit d'écrire dans le registre de paquets).

Statut

  • Fait : infrastructure (serveur, Docker, CI), découverte de chaîne (GET /api/channel), file d'attente + téléchargement vidéo/audio/miniature avec concurrence bornée (/api/queue*), frontend complet (formulaire d'ajout + file d'attente, sondage 1,5s).
  • À venir (PR successives) : notifications temps réel (WebSocket, progression poussée plutôt que sondage), tests client sur le câblage DOM (jsdom), sélection de qualité vidéo (actuellement fixe : meilleur mp4 disponible).