diff --git a/client/src/api/queueApi.ts b/client/src/api/queueApi.ts index 95cdb60..389229f 100644 --- a/client/src/api/queueApi.ts +++ b/client/src/api/queueApi.ts @@ -12,6 +12,7 @@ export interface QueueItemDto { id: string; url: string; title: string | null; + thumbnailUrl: string | null; mode: QueueItemMode; status: QueueItemStatus; progress: number; @@ -48,6 +49,7 @@ async function readErrorMessage(response: Response): Promise { * @param url - URL de la vidéo YouTube à télécharger. * @param mode - Mode de téléchargement et ses options. * @param title - Titre connu de la vidéo, s'il y en a un (affichage uniquement). + * @param thumbnailUrl - URL de miniature connue, s'il y en a une (affichage uniquement). * @returns L'élément créé. * @throws {Error} Si la requête échoue ou si le serveur refuse l'ajout. */ @@ -55,13 +57,19 @@ export async function enqueueItem( url: string, mode: EnqueueMode, title: string | null, + thumbnailUrl: string | null = null, ): Promise { let response: Response; try { response = await fetch('/api/queue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, title: title ?? undefined, ...mode }), + body: JSON.stringify({ + url, + title: title ?? undefined, + thumbnailUrl: thumbnailUrl ?? undefined, + ...mode, + }), }); } catch (error) { throw new Error('Impossible de contacter le serveur.', { cause: error }); diff --git a/client/src/views/downloadForm/DownloadForm.ts b/client/src/views/downloadForm/DownloadForm.ts index 2ec3bd2..f252a5d 100644 --- a/client/src/views/downloadForm/DownloadForm.ts +++ b/client/src/views/downloadForm/DownloadForm.ts @@ -93,7 +93,7 @@ async function handleAddResult( setMessage(errorEl, null); try { - await enqueueItem(video.url, readCurrentMode(formRoot), video.title); + await enqueueItem(video.url, readCurrentMode(formRoot), video.title, video.thumbnailUrl); button.textContent = 'Ajouté ✓'; callbacks.onItemQueued(); } catch (error) { diff --git a/client/src/views/queuePanel/QueuePanel.scss b/client/src/views/queuePanel/QueuePanel.scss index 5df04b8..8fc65a5 100644 --- a/client/src/views/queuePanel/QueuePanel.scss +++ b/client/src/views/queuePanel/QueuePanel.scss @@ -48,6 +48,9 @@ } &__item { + display: flex; + align-items: flex-start; + gap: 0.65rem; border: 1px solid var(--color-border); border-left-width: 4px; border-radius: var(--radius); @@ -70,6 +73,20 @@ } } + &__item-thumb { + width: 96px; + height: 54px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; + background: var(--color-bg); + } + + &__item-content { + flex: 1; + min-width: 0; + } + &__item-title { margin: 0; font-size: 0.9rem; diff --git a/client/src/views/queuePanel/QueuePanel.ts b/client/src/views/queuePanel/QueuePanel.ts index 68ba594..d136a3d 100644 --- a/client/src/views/queuePanel/QueuePanel.ts +++ b/client/src/views/queuePanel/QueuePanel.ts @@ -99,36 +99,47 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { const li = document.createElement('li'); li.className = `queue-panel__item queue-panel__item--${item.status}`; + if (item.thumbnailUrl) { + const img = document.createElement('img'); + img.src = item.thumbnailUrl; + img.alt = ''; + img.className = 'queue-panel__item-thumb'; + li.append(img); + } + + const content = document.createElement('div'); + content.className = 'queue-panel__item-content'; + const title = document.createElement('p'); title.className = 'queue-panel__item-title'; title.textContent = item.title ?? item.url; - li.append(title); + content.append(title); const meta = document.createElement('p'); meta.className = 'queue-panel__item-meta'; meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`; - li.append(meta); + content.append(meta); if (isActive(item)) { const progress = document.createElement('progress'); progress.className = 'queue-panel__item-progress'; progress.max = 100; progress.value = item.progress; - li.append(progress); + content.append(progress); } if (item.status === 'failed' && item.error) { const error = document.createElement('p'); error.className = 'queue-panel__item-error'; error.textContent = item.error; - li.append(error); + content.append(error); } if (item.status === 'completed' && !item.fileName) { const note = document.createElement('p'); note.className = 'queue-panel__item-meta'; note.textContent = 'Déjà téléchargé'; - li.append(note); + content.append(note); } const actions = document.createElement('div'); @@ -155,7 +166,8 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { } actions.append(removeButton); - li.append(actions); + content.append(actions); + li.append(content); return li; } diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts index f70db8d..9fdd475 100644 --- a/server/src/routes/queue.ts +++ b/server/src/routes/queue.ts @@ -12,6 +12,8 @@ interface CreateQueueItemBody { url: string; /** Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. */ title?: string; + /** URL de la miniature, si déjà connue (ex. depuis une recherche de chaîne), pour l'affichage uniquement. */ + thumbnailUrl?: string; /** Mode de téléchargement demandé. */ mode: 'video' | 'audio' | 'thumbnail'; /** Mode vidéo uniquement : récupérer et embarquer les sous-titres fr/en. */ @@ -28,6 +30,7 @@ const createQueueItemSchema = { properties: { url: { type: 'string', minLength: 1, maxLength: 500 }, title: { type: 'string', maxLength: 300 }, + thumbnailUrl: { type: 'string', maxLength: 500 }, mode: { type: 'string', enum: ['video', 'audio', 'thumbnail'] }, subtitles: { type: 'boolean' }, embedThumbnail: { type: 'boolean' }, @@ -135,6 +138,7 @@ export function createQueueRoutes( url, toDownloadMode(request.body), request.body.title ?? null, + request.body.thumbnailUrl ?? null, ); return reply.status(201).send({ item }); }, diff --git a/server/src/services/ChannelDiscoveryService.ts b/server/src/services/ChannelDiscoveryService.ts index 7a0de1d..c48fd8e 100644 --- a/server/src/services/ChannelDiscoveryService.ts +++ b/server/src/services/ChannelDiscoveryService.ts @@ -84,6 +84,30 @@ function matchesKeywords(title: string, keywords: readonly string[]): boolean { return keywords.every((keyword) => lowerTitle.includes(keyword.toLowerCase())); } +/** + * Extrait l'URL de miniature d'une entrée yt-dlp. En mode `--flat-playlist`, + * l'extracteur YouTube ne renseigne quasiment jamais le champ singulier + * `thumbnail` : il fournit uniquement `thumbnails`, un tableau d'objets + * `{url, width?, height?}` généralement trié par résolution croissante. + * + * @param entry - Entrée JSON déjà parsée. + * @returns L'URL de la miniature de meilleure résolution connue, ou `null`. + */ +function extractThumbnailUrl(entry: Record): string | null { + if (typeof entry.thumbnail === 'string') { + return entry.thumbnail; + } + + if (Array.isArray(entry.thumbnails) && entry.thumbnails.length > 0) { + const best = entry.thumbnails.at(-1) as Record | undefined; + if (best && typeof best.url === 'string') { + return best.url; + } + } + + return null; +} + /** * Convertit une ligne de sortie `yt-dlp --flat-playlist -j` en résumé de * vidéo. Retourne `null` pour toute ligne illisible ou incomplète plutôt @@ -111,7 +135,7 @@ function parseEntryLine(line: string): ChannelVideoSummary | null { id, title, url, - thumbnailUrl: typeof entry.thumbnail === 'string' ? entry.thumbnail : null, + thumbnailUrl: extractThumbnailUrl(entry), durationSeconds: typeof entry.duration === 'number' ? entry.duration : null, uploadDate: typeof entry.upload_date === 'string' ? entry.upload_date : null, }; diff --git a/server/src/services/QueueService.ts b/server/src/services/QueueService.ts index 976c59e..489815f 100644 --- a/server/src/services/QueueService.ts +++ b/server/src/services/QueueService.ts @@ -21,6 +21,8 @@ export interface QueueItem { readonly url: string; /** Titre de la vidéo, si connu au moment de l'ajout (purement informatif, pour l'affichage). */ readonly title: string | null; + /** URL de la miniature, si connue au moment de l'ajout (purement informatif, pour l'affichage). */ + readonly thumbnailUrl: string | null; /** Mode de téléchargement demandé. */ readonly mode: DownloadMode; /** État d'avancement courant. */ @@ -42,6 +44,7 @@ interface InternalQueueItem { id: string; url: string; title: string | null; + thumbnailUrl: string | null; mode: DownloadMode; status: QueueItemStatus; progress: number; @@ -100,14 +103,21 @@ export class QueueService { * @param url - URL de la vidéo à télécharger (déjà validée par l'appelant). * @param mode - Mode de téléchargement demandé. * @param title - Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. + * @param thumbnailUrl - URL de la miniature, si déjà connue (ex. depuis une recherche de chaîne), pour l'affichage uniquement. * @returns L'élément créé, à l'état `"pending"`. */ - public enqueue(url: string, mode: DownloadMode, title: string | null = null): QueueItem { + public enqueue( + url: string, + mode: DownloadMode, + title: string | null = null, + thumbnailUrl: string | null = null, + ): QueueItem { const id = randomUUID(); const item: InternalQueueItem = { id, url, title, + thumbnailUrl, mode, status: 'pending', progress: 0, @@ -362,6 +372,7 @@ export class QueueService { id: item.id, url: item.url, title: item.title, + thumbnailUrl: item.thumbnailUrl, mode: item.mode, status: item.status, progress: item.progress, diff --git a/server/test/QueueService.test.ts b/server/test/QueueService.test.ts index 6f5d3af..f145aa2 100644 --- a/server/test/QueueService.test.ts +++ b/server/test/QueueService.test.ts @@ -52,6 +52,16 @@ describe('QueueService', () => { expect(final?.error).toBeNull(); }); + it('conserve la miniature fournie à enqueue() dans la vue publique', async () => { + vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); + + const service = new QueueService(noopLogger, baseDir, 1); + const item = service.enqueue(url, videoMode, 'Titre', 'https://x/thumb.jpg'); + + expect(item.thumbnailUrl).toBe('https://x/thumb.jpg'); + expect(service.get(item.id)?.thumbnailUrl).toBe('https://x/thumb.jpg'); + }); + it('respecte la concurrence maximale configurée', async () => { let concurrentCount = 0; let maxObservedConcurrent = 0; diff --git a/server/test/channelDiscoveryService.test.ts b/server/test/channelDiscoveryService.test.ts index 8f8da76..1f633dc 100644 --- a/server/test/channelDiscoveryService.test.ts +++ b/server/test/channelDiscoveryService.test.ts @@ -49,6 +49,26 @@ describe('ChannelDiscoveryService', () => { ); }); + it('retombe sur le tableau "thumbnails" (forme réelle de yt-dlp en --flat-playlist)', async () => { + vi.mocked(runYtDlp).mockResolvedValue( + JSON.stringify({ + id: 'd1', + title: 'Sans champ thumbnail singulier', + url: 'https://youtu.be/d1', + thumbnails: [ + { url: 'https://x/d1-small.jpg', height: 90 }, + { url: 'https://x/d1-large.jpg', height: 360 }, + ], + }), + ); + + const service = new ChannelDiscoveryService(noopLogger); + const results = await service.listChannel('@worldclimbing', 'videos'); + + expect(results).toHaveLength(1); + expect(results[0]?.thumbnailUrl).toBe('https://x/d1-large.jpg'); + }); + it('accepte une URL de chaîne complète et ignore la casse des mots-clés', async () => { vi.mocked(runYtDlp).mockResolvedValue( JSON.stringify({ id: 'c1', title: 'BIG Speed Final', url: 'https://youtu.be/c1' }), diff --git a/server/test/queueRoute.test.ts b/server/test/queueRoute.test.ts index 1edef27..474a3d2 100644 --- a/server/test/queueRoute.test.ts +++ b/server/test/queueRoute.test.ts @@ -33,7 +33,12 @@ describe('routes de file d’attente', () => { const response = await app.inject({ method: 'POST', url: '/api/queue', - payload: { url: validUrl, mode: 'audio', audioFormat: 'opus' }, + payload: { + url: validUrl, + mode: 'audio', + audioFormat: 'opus', + thumbnailUrl: 'https://x/thumb.jpg', + }, }); expect(response.statusCode).toBe(201); @@ -42,6 +47,7 @@ describe('routes de file d’attente', () => { url: validUrl, mode: { kind: 'audio', format: 'opus' }, status: expect.stringMatching(/pending|running/), + thumbnailUrl: 'https://x/thumb.jpg', }); }); diff --git a/spec/architecture.md b/spec/architecture.md index 1a9d31d..ae4ef6e 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -22,10 +22,10 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe - `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. + - `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. 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. - `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. + - `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`). 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). @@ -36,7 +36,7 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe - 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 (.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. +- `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. @@ -46,7 +46,7 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe - `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. +- `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.