# 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`, `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). 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" dans l'en-tête qui déclenche, avec un léger décalage entre chacun (évite le blocage navigateur des téléchargements multiples simultanés), tous les téléchargements prêts. 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. - `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`. ## 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. - `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` : 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. ## 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`), 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).