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>
13 KiB
13 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ésolutionNodeNext). 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 deCLIENT_DIST_PATH/TMP_DIRsont résolues depuis l'emplacement du module (import.meta.url), pasprocess.cwd(): reste correct quel que soit le répertoire depuis lequel le process est lancé (ex.npm --prefix server run devdepuis la racine du dépôt, utilisé par.claude/launch.jsonpour 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 soussrc/routes/, qui délègue à un service soussrc/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 viaAbortSignal.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 suryoutube.com/youtu.be(formatswatch/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 sousTMP_DIR, supprimé après échec/annulation ou après livraison du fichier au client (consumeCompletedFile/consumeAllCompletedFiles->onSent, logique de livraison partagée viabuildDelivery) : le serveur ne conserve jamais durablement les fichiers.
- Le serveur sert aussi les fichiers statiques de
client/distvia@fastify/static. - Tests : Vitest (
server/test/). Trois niveaux : fonctions pures testées directement (downloadPlan.ts,videoUrl.ts), services testés avecytDlpRunnermocké mais un vrai système de fichiers temporaire (QueueService.test.ts, plus réaliste qu'un mock fs), et routes testées viaapp.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 (--execaccepté), repérée seulement par le test de route avec un autre exemple (voirchannelDiscoveryService.test.ts/channelRoute.test.ts). Helpers communs (waitFor,extractOutputDir) factorisés danstest/helpers.ts-waitForaccepte 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 compilesrc/app.tsen bundle ESM, copiesrc/index.html, et compile/regroupe les.scssimportés en unapp.cssvoisin (plugin esbuild maison qui délègue au paquetsass). Cible ES2022 (nécessaire pourError.cause, utilisé dansapi/). - Pas de router :
src/shell/Shell.tsmonte 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é avecserver/(monorepo simple, pas de workspace npm).src/views/downloadForm/: formulaire d'ajout, deux entrées possibles (URL directe, ou recherche de chaîne viaGET /api/channelpuis 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/: sondeGET /api/queuetoutes 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/:idgè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 viaGET /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é) portenttarget="_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. Exposerefresh()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
hiddencombiné à une règledisplay: flexsur 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 undisplayexplicite. - Layout en hauteur pleine page (
html/body/.shellàheight:100%/100vh,overflow:hiddensurbody) : la page elle-même ne défile jamais, seulesdownload-form__resultsetqueue-panel__listle font (chacuneflex:1 1 0; min-height:0; overflow-y:autodans un parent qui propageheight:100%/min-height:0depuis.shell__panel).min-height: 0est 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$variablesSass - 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 depuisapp.ts.
API
GET /health: statut du service (utilisé par leHEALTHCHECKDocker).GET /api/channel?channel=<@handle|URL>§ion=<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.sectionpar défautvideos. Réponses :200 { videos: [...] },400sichannelest absent/invalide,502si yt-dlp échoue.POST /api/queue: ajoute un élément ({ url, title?, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }).titleest purement informatif (affichage), jamais utilisé pour construire la commande yt-dlp.201 { item },400si l'URL n'est pas une URL de vidéo YouTube reconnue.GET /api/queue: liste tous les éléments ({ items: [...] }), avec leurstatus(pending/running/completed/failed/cancelled),progress(0-100, best-effort) eterror.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é).404seulement si l'identifiant est inconnu.GET /api/queue/:id/file: télécharge le fichier produit par un élémentcompleted(Content-Disposition: attachment). Le dossier de sortie de l'élément est supprimé juste après l'envoi, etfileNamerepassé ànull(un second appel renvoie404, pas une tentative de relire un fichier déjà supprimé).404tant que l'élément n'est pascompletedavec un fichier disponible.GET /api/queue/download-all: télécharge en une seule archive zip (streamée viaarchiver, jamais bufferisée en mémoire) tous les fichierscompleteddisponibles ; 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.404si aucun fichier n'est disponible. Le flux est envoyé à la réponse (reply.send) avantarchive.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 deserver/etclient/. Étapes : build client (esbuild) → build serveur (tsc) → image runtime (Node 22 bookworm-slim +ffmpeg+ binaire autonomeyt-dlp, utilisateur non-rootnode).yt-dlpetffmpegsont installés dans l'image (pas vendorisés dans le dépôt, contrairement aux scripts.cmdd'origine qui embarquaientyt-dlp.exeet un build ffmpeg complet).- Debian slim plutôt qu'Alpine (changé après coup) : le binaire autonome
yt-dlp_linuxest lié à la glibc. Sur Alpine (musl),spawn('yt-dlp', ...)échouait en prod avecENOENTbien 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_PATHpointe 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: jobtest(lint racine, typecheck/tests/build serveur, typecheck/tests/build client) puis jobpublish(build + push de l'image versgit.dev.kyuno.fr/kyuno/youtubedownloader, seulement sitestpasse et hors PR). Tags :latest+ sha court sur pushmain,X.Y.Z/X.Y/latestsur tagvX.Y.Z. Nécessite le secret de dépôtREGISTRY_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).