youtubeDownloader/spec/architecture.md
Nicolas 374cfbb192 feat: recherche YouTube globale, avec defilement infini
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>
2026-09-19 15:43:46 +02:00

26 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, 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, search.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), l'annulation via AbortSignal, et onSpawn (reçoit le processus lancé, pour en piloter les flux - utilisé par la recherche pour suspendre stdout).
    • 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 -- <url canonique>, 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=<id>, 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).
    • SearchService.ts / SearchSession.ts / searchQuery.ts : recherche YouTube globale (ytsearchN:), paginée. Une session (SearchSession) = un seul processus yt-dlp (--flat-playlist --lazy-playlist -j -- ytsearch1000:<requête>) dont les pages sont servies depuis un tampon ; SearchService gère le parc de sessions (4 simultanées max - la moins récemment utilisée est fermée au-delà ; expiration après 10 min d'inactivité ou 30 min de vie, balayage toutes les 30 s ; fermeture dès que la dernière page est servie, sur DELETE, et à l'arrêt du serveur via le hook onClose). Pourquoi une session plutôt qu'un appel yt-dlp par page - mesuré avec le vrai yt-dlp : (1) l'ordre des résultats de YouTube n'est pas déterministe, même pour deux appels identiques (5 résultats sur 20 à la même position, 69 % de recouvrement sur 100) : paginer en relançant la recherche et en tranchant (offset/limit) donnait 15 à 30 % de doublons dès la 3e-4e page et des résultats jamais montrés ; (2) YouTube s'arrête de lui-même vers ~590 résultats même si on en demande 1000 ; (3) environ 7 % des résultats sont des doublons d'une même vidéo (dédoublonnés à l'ingestion, donc le décalage offset reste cohérent) ; (4) sans --lazy-playlist, yt-dlp matérialise toute la liste avant d'imprimer la première ligne (~23 s pour 1000) : avec, les résultats arrivent au fil des pages (20 en ~2,3 s). Le parcours n'avance qu'à la demande : dès que le tampon a 20 résultats d'avance sur ce qui a été demandé, la lecture de stdout est suspendue (child.stdout.pause() via onSpawn) ; le tube se remplit et yt-dlp bloque sur son écriture (mesuré : plus aucune ligne pendant 10 s de pause, reprise au rythme normal ensuite) - on n'aspire donc pas ~590 résultats (et ~30 requêtes vers YouTube) quand l'utilisateur ne défile pas. Vérifié en boîte noire : une page déjà tamponnée répond en ~4 ms, une page 200 demandée après 12 s d'inactivité en ~4 s. max plafonne le nombre de résultats (au plafond, yt-dlp est annulé) : c'est ainsi que fonctionne le mode « limite ». Un échec de yt-dlp avant tout résultat lève une erreur ; après des résultats, on sert le tampon et on signale la fin. Sous Windows, tuer le parent d'un exécutable PyInstaller n'arrête pas son enfant, qui finit son parcours puis s'arrête seul (~30 s, transitoire, dev seulement) ; sous Linux (production) le lanceur transmet SIGTERM à l'enfant, comme pour l'annulation de téléchargements. searchQuery.ts valide la requête (1 à 200 caractères, sans caractère de contrôle) : elle est transmise sous la forme d'un unique argument ytsearchN:<requête>, qui ne peut jamais être pris pour une option ni faire récupérer une autre URL.
    • videoSummary.ts : type VideoSummary et summarizeEntry(), conversion partagée d'une entrée --flat-playlist (chaîne, playlist ou recherche) 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. test/fakeYtDlp.ts simule un yt-dlp en flux (le test émet les lignes une à une et espionne pause/resume d'un faux stdout) pour tester SearchSession/SearchService/la route sans réseau ; le comportement du tube réel (une pause arrête bel et bien yt-dlp) n'est pas testé automatiquement - un enfant node écrit de façon synchrone dans un tube sous POSIX mais asynchrone sous Windows, donc un tel test ne serait pas portable - il a été mesuré à la main avec le vrai yt-dlp. 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, searchApi.ts, queueApi.ts), avec leurs propres types DTO (videoDto.ts pour la vidéo listée et la page de vidéos, communes aux chaînes, playlists et recherches) - pas de paquet de types partagé avec server/ (monorepo simple, pas de workspace npm).
  • src/searchLimit.ts : parseSearchLimit(), fonction pure qui lit le champ « Limite » du formulaire de recherche (entier >= 1, sinon « pas de limite »).
  • 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, trois 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 YouTube globale (mots-clés + « Limite » optionnelle) via /api/search ; (3) la recherche de chaîne via GET /api/channel. Toutes 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 » (volontairement absent des recherches : demandé ainsi, et une recherche peut compter des centaines de résultats).
    • Recherche globale : avec une limite renseignée, tous les résultats arrivent d'un coup (max = limit = N, une seule page) ; sans limite, la liste défile à l'infini (showPaged) : un élément « sentinelle » en fin de liste, observé par un IntersectionObserver dont la racine est la liste elle-même (marge de 300 px pour charger un peu avant la fin), déclenche le chargement de la page suivante (nextOffset fourni par le serveur, pages de 20). Après chaque page l'observateur est réarmé (unobserve puis observe) : sans cela, une page trop courte pour remplir la zone laisserait la sentinelle visible sans qu'aucun nouveau signal ne soit émis. La sentinelle affiche l'état : chargement, erreur avec « Réessayer » (une page en échec ne fait pas perdre celles déjà affichées, et rien ne boucle tout seul), ou « Fin des résultats ». Un indicateur de chargement en cours empêche deux requêtes de page simultanées, et le compteur de « génération » écarte une réponse arrivée après le remplacement de la liste. La recherche reste ouverte côté serveur tant qu'il reste des pages : elle est fermée (DELETE /api/search/:id) quand la liste est remplacée ou vidée (rappel onClose) et au pagehide (fermeture ou rechargement de l'onglet, fetch avec keepalive) ; à défaut, l'expiration serveur (10 min) s'en charge. 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.
    • Filet pour les écrans courts (ajouté avec le troisième formulaire, dont les ~65 px de plus laissaient à la liste ~104 px, soit une seule ligne, à 1366x650) : une liste non vide (&:not(:empty)) garde min-height: 13rem (~2 lignes), et .download-form passe en overflow-y: auto, de sorte que sur un écran trop court c'est la colonne du formulaire qui défile au lieu de rogner la liste ou des champs (la page, elle, ne défile toujours jamais). Sans effet tant que tout tient. Mesuré (DOM) : 1920x950 -> liste de 406 px, colonne fixe ; 1366x650 -> liste de 208 px, colonne défilante ; 800x450 -> idem, tout reste atteignable. Si trois entrées empilées deviennent trop gourmandes, des onglets (URL / recherche / chaîne) libéreraient ~130 px.
  • 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>&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/search ({ query, max?, limit? }), GET /api/search/:id?offset=&limit=, DELETE /api/search/:id : recherche YouTube globale paginée (voir SearchSession). POST ouvre une recherche et retourne 201 { id, videos, nextOffset } (première page, limit par défaut 20 ; max plafonne le nombre total de résultats, absent = jusqu'à la fin de ceux de YouTube, ~590) ; GET retourne 200 { videos, nextOffset } pour la page qui commence à offset ; nextOffset vaut null en fin de résultats (la dernière page peut être vide : la page pleine qui tombe pile sur le dernier résultat ne sait pas encore que yt-dlp a fini). La recherche est refermée par le serveur dès que la dernière page a été servie. Réponses d'erreur : 400 (requête vide, de plus de 200 caractères ou avec caractère de contrôle ; limit/max/offset hors bornes), 404 (recherche inconnue, expirée ou déjà refermée - message « relancez-la »), 502 (yt-dlp échoue avant le moindre résultat). DELETE répond 204 (ou 404 si déjà refermée).
  • GET /api/playlist?url=<lien de playlist> : 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), recherche YouTube globale paginée en défilement infini (/api/search*), 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).