# 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`, `TMP_DIR`, `MAX_CONCURRENT_DOWNLOADS`). Les valeurs par défaut de `CLIENT_DIST_PATH`/`TMP_DIR` sont résolues depuis l'emplacement du module (`import.meta.url`), pas `process.cwd()` : reste correct quel que soit le répertoire depuis lequel le process est lancé (ex. `npm --prefix server run dev` depuis la racine du dépôt, utilisé par `.claude/launch.json` pour la prévisualisation - non versionné, voir `.gitignore`). - `src/routes/` : routes Fastify enregistrées comme plugins (`health.ts`, `channel.ts`, `playlist.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. Une ligne de sortie = une entrée, convertie par `videoSummary.ts`. - `PlaylistDiscoveryService.ts` : liste les vidéos d'une playlist (`yt-dlp --flat-playlist -J -- `, délai de 120 s : ~1600 vidéos mesurées en ~11 s, YouTube pagine par centaines). `-J` (un seul objet JSON `{title, entries: [...]}`) plutôt que `-j` (une ligne par entrée, comme pour les chaînes) : le titre de la playlist n'existe qu'à la racine, et une playlist vide reste un résultat valide avec son titre. Les entrées sont converties par le même `videoSummary.ts` que pour les chaînes. - `playlistUrl.ts` : valide qu'un lien est bien une playlist YouTube (`youtube.com/playlist?list=`, domaines `youtube.com`/`www.`/`m.`/`music.`, https, sans port ni identifiants) et retourne l'URL **canonique** reconstruite depuis le seul identifiant validé (`[\w-]{2,64}` - longueur variable selon le type de playlist) : rien d'autre de ce que l'appelant a fourni n'est jamais transmis à yt-dlp. Même garde anti-SSRF que `videoUrl.ts`. Un lien de vidéo portant un paramètre `list` (`watch?v=...&list=...`) n'est volontairement **pas** une playlist : il désigne une vidéo (et `downloadPlan.ts` passe déjà `--no-playlist`, donc seule cette vidéo est téléchargée). - `videoSummary.ts` : type `VideoSummary` et `summarizeEntry()`, conversion partagée d'une entrée `--flat-playlist` (chaîne ou playlist) en résumé de vidéo ; retourne `null` pour une entrée inexploitable plutôt que d'interrompre le reste. Miniature (`thumbnailUrl`) lue en priorité sur `thumbnail` (champ singulier), sinon sur le dernier élément de `thumbnails` (tableau) : en `--flat-playlist`, l'extracteur YouTube ne renseigne quasiment jamais le champ singulier, seulement le tableau (trié par résolution croissante) - un bug réel (miniatures jamais affichées) passé inaperçu en test tant que le mock ne reproduisait que la forme théorique (`thumbnail`) et jamais la forme réelle. `upload_date` est absent en pratique des entrées `--flat-playlist` (il n'y a qu'un `timestamp`) : `uploadDate` vaut donc `null`. - `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`/`consumeAllCompletedFiles` -> `onSent`, logique de livraison partagée via `buildDelivery`) : le serveur ne conserve jamais durablement les fichiers. `title`/`thumbnailUrl` sont de simples informations transmises par l'appelant à `enqueue()` (purement informatif, jamais utilisés pour construire la commande yt-dlp) et renvoyées telles quelles dans la vue publique. - 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`). Les mocks de sortie yt-dlp doivent reproduire la **forme réelle** observée, pas la forme théorique (voir le bug des miniatures ci-dessus) : les fixtures de `playlistDiscoveryService.test.ts` reprennent la sortie réelle de `yt-dlp --flat-playlist -J` (yt-dlp 2026.07.04) - relevée en lançant le vrai binaire (`downloader/yt-dlp.exe`, non versionné) via une entrée locale de `.claude/launch.json` (`YTDLP_PATH`), aussi utilisée pour vérifier la fonctionnalité de bout en bout dans le navigateur. Helpers communs (`waitFor`, `extractOutputDir`) factorisés dans `test/helpers.ts` - `waitFor` accepte un prédicat sync ou async (un prédicat async passé à une version qui n'attendait que du sync serait toujours vu comme "vrai" immédiatement, puisqu'un objet Promise est truthy). ## 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`, `playlistApi.ts`, `queueApi.ts`), avec leurs propres types DTO (`videoDto.ts` pour la vidéo listée, commune aux chaînes et aux playlists) - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm). - `src/playlistUrl.ts` : `isPlaylistUrl()`, fonction pure qui aiguille la saisie (lien de playlist -> parcourir ses vidéos, sinon -> ajouter la vidéo). Les mêmes domaines que le serveur, qui reste l'autorité de validation ; un lien `watch?v=...&list=...` n'est volontairement pas une playlist (comme côté serveur). - `src/views/downloadForm/` : formulaire d'ajout, deux entrées possibles partageant un même sélecteur de mode (vidéo/audio/miniature + options) : (1) un champ « URL (vidéo ou playlist) » - un lien de vidéo est ajouté directement à la file, un lien de playlist charge ses vidéos via `GET /api/playlist` ; (2) la recherche de chaîne via `GET /api/channel`. Les deux alimentent **une même zone de résultats** (`createResultsView`) : statut, une ligne par vidéo avec son bouton « Ajouter », et pour une playlist seulement un bouton « Tout ajouter à la file ». Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé (lu **une seule fois** au début d'un ajout groupé, pour ne pas mélanger les modes au sein d'un lot). - « Tout ajouter à la file » enchaîne des `POST /api/queue` **séquentiels** côté client (pas d'endpoint groupé : un corps de plusieurs milliers d'éléments dépasserait la limite par défaut de Fastify, et le rythme séquentiel conserve l'ordre de la playlist dans la file). Les lignes déjà ajoutées (bouton désactivé) sont ignorées, donc aucun doublon ; un échec sur une vidéo n'interrompt pas les suivantes - en fin de lot le nombre d'échecs et le premier message d'erreur sont affichés, seules les lignes en échec redeviennent ajoutables, et relancer le bouton ne réajoute que celles-là. Un compteur de « génération » (incrémenté à chaque remplacement de la liste) empêche un lot encore en cours sur une ancienne liste de modifier l'affichage de la nouvelle. Pas de boîte de confirmation : le nombre de vidéos est affiché dans le statut juste à côté du bouton. - Miniatures de résultats en `loading="lazy"` : une playlist ou une chaîne peut compter des centaines de vidéos (1607 mesurées), sans quoi chaque miniature serait demandée d'un coup. Pas appliqué dans la file d'attente, dont la liste est reconstruite à chaque sondage. - `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 (miniature si connue, titre ou URL à défaut, statut, barre de progression, erreur). Un même bouton retire l'élément quel que soit son état - libellé "Annuler" s'il est en attente/en cours, "Supprimer" une fois terminé (`DELETE /api/queue/:id` gère les deux cas côté serveur). Lien "Télécharger" natif uniquement si un fichier est encore disponible ; bouton "Tout télécharger (.zip)" dans l'en-tête (visible dès qu'au moins un fichier est prêt) qui déclenche un unique téléchargement zip via `GET /api/queue/download-all` - remplace une première version qui déclenchait un téléchargement par fichier avec un décalage entre chacun (fonctionnait, mais le zip évite complètement le blocage navigateur sur téléchargements multiples plutôt que de le contourner). Tous les liens de téléchargement (individuel et groupé) portent `target="_blank" rel="noopener"` : filet de sécurité si jamais rien n'est disponible côté serveur au moment du clic (état changé entre le rendu et le clic), l'éventuelle réponse d'erreur s'ouvre dans un nouvel onglet plutôt que de remplacer l'application. 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. - Layout en hauteur pleine page (`html`/`body`/`.shell` à `height:100%`/`100vh`, `overflow:hidden` sur `body`) : la page elle-même ne défile jamais, seules `download-form__results` et `queue-panel__list` le font (chacune `flex:1 1 0; min-height:0; overflow-y:auto` dans un parent qui propage `height:100%`/`min-height:0` depuis `.shell__panel`). `min-height: 0` est nécessaire à *chaque* niveau flex descendant - un enfant flex ne rétrécit jamais sous la taille de son contenu par défaut, ce qui ferait déborder la page entière au lieu de faire défiler juste la liste. Chaque vue neutralise le grow/shrink de ses enfants directs non-scrollables via `> * { flex: 0 0 auto; }`, puis la règle plus spécifique de l'élément scrollable (même spécificité, déclarée après donc prioritaire) le fait remplir l'espace restant. - 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. - `src/styles/base.scss` : palette et styles de base partagés (custom properties CSS sur `:root`, pas de `$variables` Sass - une variable Sass ne traverserait pas les fichiers, chaque vue étant compilée/importée séparément ; une custom property CSS si, puisqu'elle se résout à l'exécution dans le navigateur une fois tout le CSS regroupé par esbuild). Importé une fois depuis `app.ts`. Thème sombre/clair automatique selon `prefers-color-scheme` (pas de bascule manuelle) : un unique bloc `@media (prefers-color-scheme: dark)` redéfinit les mêmes custom properties dans `:root` - fonctionne partout car aucune autre feuille ne code de couleur en dur (vérifié : toutes passent par `var(--color-*)`). ## API - `GET /health` : statut du service (utilisé par le `HEALTHCHECK` Docker). - `GET /api/channel?channel=<@handle|URL>§ion=&keywords=` : 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. - `GET /api/playlist?url=` : liste les vidéos d'une playlist YouTube, dans l'ordre de la playlist. Réponses : `200 { title, videos: [...] }` (`title` vaut `null` si inconnu, `videos` est vide pour une playlist vide - même forme de vidéo que `/api/channel`), `400` si `url` est absent ou n'est pas un lien de playlist YouTube (y compris un lien de vidéo, même avec un paramètre `list`), `502` si yt-dlp échoue (playlist introuvable ou privée, binaire absent, sortie illisible). - `POST /api/queue` : ajoute un élément (`{ url, title?, thumbnailUrl?, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }`). `title`/`thumbnailUrl` sont purement informatifs (affichage), jamais utilisés 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` : supprime un élément (`204`), quel que soit son état - s'il était en attente/en cours, il est d'abord annulé, puis retiré de la liste dans tous les cas (dossier de sortie éventuel nettoyé). `404` seulement si l'identifiant est inconnu. - `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, et `fileName` repassé à `null` (un second appel renvoie `404`, pas une tentative de relire un fichier déjà supprimé). `404` tant que l'élément n'est pas `completed` avec un fichier disponible. - `GET /api/queue/download-all` : télécharge en une seule archive zip (streamée via `archiver`, jamais bufferisée en mémoire) tous les fichiers `completed` disponibles ; noms dédupliqués en cas de titres identiques (`Vidéo (2).mp4`). Même sémantique de nettoyage post-livraison que `.../file`, appliquée indépendamment par fichier. `404` si aucun fichier n'est disponible. Le flux est envoyé à la réponse (`reply.send`) *avant* `archive.finalize()` (pas l'inverse) : sur une grosse archive, `finalize()` n'aboutit qu'une fois tout le contenu écrit dans le flux, ce qui bloquerait indéfiniment si personne ne le lit encore. ## 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 **bookworm-slim** + `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). - Debian slim plutôt qu'Alpine (changé après coup) : le binaire autonome `yt-dlp_linux` est lié à la glibc. Sur Alpine (musl), `spawn('yt-dlp', ...)` échouait en prod avec `ENOENT` bien que le fichier soit présent et exécutable - le noyau ne trouve pas l'interpréteur ELF (`ld-linux`) que le binaire attend. Repéré via un rapport de bug en conditions réelles (pas en local, où `YTDLP_PATH` pointe vers un exécutable Windows). Même choix que mediaDrop pour un souci similaire avec une autre dépendance native. - `.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`) et de playlist (`GET /api/playlist`, ajout par vidéo ou d'un coup), 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).