diff --git a/client/src/api/queueApi.ts b/client/src/api/queueApi.ts index d841432..607f56d 100644 --- a/client/src/api/queueApi.ts +++ b/client/src/api/queueApi.ts @@ -106,12 +106,13 @@ export async function listQueueItems(): Promise { } /** - * Annule un élément de la file d'attente via `DELETE /api/queue/:id`. + * Supprime un élément de la file d'attente via `DELETE /api/queue/:id`, + * quel que soit son état (l'annule d'abord s'il était en attente/en cours). * - * @param id - Identifiant de l'élément à annuler. + * @param id - Identifiant de l'élément à supprimer. * @throws {Error} Si la requête échoue ou si l'élément est introuvable. */ -export async function cancelQueueItem(id: string): Promise { +export async function removeQueueItem(id: string): Promise { let response: Response; try { response = await fetch(`/api/queue/${encodeURIComponent(id)}`, { method: 'DELETE' }); diff --git a/client/src/views/downloadForm/DownloadForm.scss b/client/src/views/downloadForm/DownloadForm.scss index 19035b7..c0187a2 100644 --- a/client/src/views/downloadForm/DownloadForm.scss +++ b/client/src/views/downloadForm/DownloadForm.scss @@ -84,7 +84,7 @@ display: flex; flex-direction: column; gap: 0.5rem; - max-height: 320px; + max-height: 65vh; overflow-y: auto; } diff --git a/client/src/views/queuePanel/QueuePanel.html b/client/src/views/queuePanel/QueuePanel.html index 93546ad..c95c016 100644 --- a/client/src/views/queuePanel/QueuePanel.html +++ b/client/src/views/queuePanel/QueuePanel.html @@ -1,5 +1,8 @@
-

File d'attente

+
+

File d'attente

+ +

Aucun élément pour l'instant.

    diff --git a/client/src/views/queuePanel/QueuePanel.scss b/client/src/views/queuePanel/QueuePanel.scss index ccb1ce5..b8a0425 100644 --- a/client/src/views/queuePanel/QueuePanel.scss +++ b/client/src/views/queuePanel/QueuePanel.scss @@ -9,6 +9,18 @@ font-weight: 600; } + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + + button { + font-size: 0.8rem; + padding: 0.35rem 0.7rem; + } + } + &__empty { color: var(--color-text-muted); font-size: 0.85rem; diff --git a/client/src/views/queuePanel/QueuePanel.ts b/client/src/views/queuePanel/QueuePanel.ts index bbc9013..593e930 100644 --- a/client/src/views/queuePanel/QueuePanel.ts +++ b/client/src/views/queuePanel/QueuePanel.ts @@ -1,8 +1,8 @@ import { type QueueItemDto, - cancelQueueItem, listQueueItems, queueItemFileUrl, + removeQueueItem, } from '../../api/queueApi'; import queuePanelTemplate from './QueuePanel.html'; import './QueuePanel.scss'; @@ -10,6 +10,9 @@ import './QueuePanel.scss'; /** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */ const POLL_INTERVAL_MS = 1500; +/** Délai entre deux déclenchements de téléchargement lors d'un "tout télécharger" (limite les blocages navigateur sur les téléchargements multiples). */ +const BULK_DOWNLOAD_STAGGER_MS = 250; + const STATUS_LABELS: Record = { pending: 'En attente', running: 'En cours', @@ -31,32 +34,77 @@ export interface QueuePanelController { } /** - * Annule un élément de la file au clic sur son bouton "Annuler". + * @param item - Élément dont on veut savoir s'il est encore actionnable (en attente/en cours). + * @returns `true` si l'élément peut encore être annulé. + */ +function isActive(item: QueueItemDto): boolean { + return item.status === 'pending' || item.status === 'running'; +} + +/** + * @param item - Élément à tester. + * @returns `true` si un fichier est disponible au téléchargement pour cet élément. + */ +function isDownloadable(item: QueueItemDto): boolean { + return item.status === 'completed' && item.fileName !== null; +} + +/** + * Supprime un élément de la file au clic sur son bouton "Annuler"/"Supprimer". * - * @param id - Identifiant de l'élément à annuler. + * @param id - Identifiant de l'élément à supprimer. * @param button - Bouton cliqué, désactivé pendant la requête. * @param onDone - Rappelé après la requête (succès ou échec) pour rafraîchir la liste. */ -async function handleCancel( +async function handleRemove( id: string, button: HTMLButtonElement, onDone: () => void, ): Promise { button.disabled = true; try { - await cancelQueueItem(id); + await removeQueueItem(id); } catch (error) { - console.error("Échec de l'annulation", error); + console.error('Échec de la suppression', error); } finally { onDone(); } } +/** + * Déclenche, avec un léger décalage entre chacun, le téléchargement de tous + * les fichiers actuellement disponibles (évite le blocage navigateur des + * téléchargements multiples déclenchés au même instant). + * + * @param button - Bouton "Tout télécharger" cliqué, désactivé pendant l'opération. + */ +async function handleDownloadAll(button: HTMLButtonElement): Promise { + button.disabled = true; + try { + const items = await listQueueItems(); + const downloadable = items.filter(isDownloadable); + + downloadable.forEach((item, index) => { + setTimeout(() => { + const link = document.createElement('a'); + link.href = queueItemFileUrl(item.id); + document.body.append(link); + link.click(); + link.remove(); + }, index * BULK_DOWNLOAD_STAGGER_MS); + }); + } catch (error) { + console.error('Échec du téléchargement groupé', error); + } finally { + button.disabled = false; + } +} + /** * Construit la ligne d'affichage d'un élément de la file. * * @param item - Élément à afficher. - * @param onChanged - Rappelé après une action (annulation) pour rafraîchir la liste. + * @param onChanged - Rappelé après une action (annulation/suppression) pour rafraîchir la liste. * @returns L'élément `
  • ` prêt à être inséré dans la liste. */ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { @@ -73,7 +121,7 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`; li.append(meta); - if (item.status === 'running' || item.status === 'pending') { + if (isActive(item)) { const progress = document.createElement('progress'); progress.className = 'queue-panel__item-progress'; progress.max = 100; @@ -88,23 +136,27 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { li.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); + } + const actions = document.createElement('div'); actions.className = 'queue-panel__item-actions'; - if (item.status === 'pending' || item.status === 'running') { - const cancelButton = document.createElement('button'); - cancelButton.type = 'button'; - cancelButton.className = 'secondary'; - cancelButton.textContent = 'Annuler'; - cancelButton.addEventListener('click', () => { - handleCancel(item.id, cancelButton, onChanged).catch((error: unknown) => { - console.error("Erreur inattendue lors de l'annulation", error); - }); + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'secondary'; + removeButton.textContent = isActive(item) ? 'Annuler' : 'Supprimer'; + removeButton.addEventListener('click', () => { + handleRemove(item.id, removeButton, onChanged).catch((error: unknown) => { + console.error('Erreur inattendue lors de la suppression', error); }); - actions.append(cancelButton); - } + }); - if (item.status === 'completed') { + if (isDownloadable(item)) { const link = document.createElement('a'); link.href = queueItemFileUrl(item.id); link.textContent = 'Télécharger'; @@ -112,12 +164,14 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { actions.append(link); } + actions.append(removeButton); li.append(actions); return li; } /** - * Rend la liste des éléments dans le panneau. + * Rend la liste des éléments dans le panneau et ajuste la visibilité du + * bouton "Tout télécharger" selon qu'il existe au moins un fichier prêt. * * @param root - Racine du panneau monté. * @param items - Éléments à afficher. @@ -126,12 +180,17 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { function render(root: HTMLElement, items: readonly QueueItemDto[], onChanged: () => void): void { const list = root.querySelector('[data-role="list"]'); const empty = root.querySelector('[data-role="empty"]'); + const downloadAllButton = root.querySelector('[data-role="download-all"]'); if (!list || !empty) { return; } empty.hidden = items.length > 0; list.replaceChildren(...items.map((item) => renderItem(item, onChanged))); + + if (downloadAllButton) { + downloadAllButton.hidden = !items.some(isDownloadable); + } } /** @@ -162,6 +221,13 @@ export function mountQueuePanel(root: HTMLElement): QueuePanelController { }); }; + const downloadAllButton = root.querySelector('[data-role="download-all"]'); + downloadAllButton?.addEventListener('click', () => { + handleDownloadAll(downloadAllButton).catch((error: unknown) => { + console.error('Erreur inattendue lors du téléchargement groupé', error); + }); + }); + refresh(); setInterval(refresh, POLL_INTERVAL_MS); diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts index 6ae5435..c206c5d 100644 --- a/server/src/routes/queue.ts +++ b/server/src/routes/queue.ts @@ -74,8 +74,8 @@ function toDownloadMode(body: CreateQueueItemBody): DownloadMode { /** * Construit le plugin Fastify exposant les routes de file d'attente : * `POST /api/queue` (ajout), `GET /api/queue` (liste), `DELETE - * /api/queue/:id` (annulation) et `GET /api/queue/:id/file` (livraison du - * fichier terminé). + * /api/queue/:id` (suppression, quel que soit l'état) et `GET + * /api/queue/:id/file` (livraison du fichier terminé). * * @param config - Configuration runtime (dossier temporaire, concurrence). * @returns Le plugin Fastify à enregistrer. @@ -117,9 +117,9 @@ export function createQueueRoutes( '/api/queue/:id', { schema: { params: queueItemParamsSchema } }, async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => { - const cancelled = queueService.cancel(request.params.id); - if (!cancelled) { - return reply.status(404).send({ error: 'Élément introuvable ou déjà terminé.' }); + const removed = queueService.remove(request.params.id); + if (!removed) { + return reply.status(404).send({ error: 'Élément introuvable.' }); } return reply.status(204).send(); }, diff --git a/server/src/services/QueueService.ts b/server/src/services/QueueService.ts index 9ad9c69..c11796f 100644 --- a/server/src/services/QueueService.ts +++ b/server/src/services/QueueService.ts @@ -142,13 +142,15 @@ export class QueueService { } /** - * Annule un élément : retiré de la file s'il était en attente, ou son - * processus yt-dlp est arrêté s'il était en cours. + * Retire un élément de la file, quel que soit son état : s'il est en + * attente ou en cours, il est d'abord arrêté (le processus yt-dlp est + * annulé le cas échéant) ; dans tous les cas l'entrée est supprimée de + * la liste et son dossier de sortie éventuel nettoyé. * - * @param id - Identifiant de l'élément à annuler. - * @returns `true` si l'élément existait et a pu être annulé, `false` sinon. + * @param id - Identifiant de l'élément à retirer. + * @returns `true` si l'élément existait, `false` sinon. */ - public cancel(id: string): boolean { + public remove(id: string): boolean { const item = this.items.get(id); if (!item) { return false; @@ -159,17 +161,19 @@ export class QueueService { if (index !== -1) { this.pendingIds.splice(index, 1); } - item.status = 'cancelled'; - this.logger.info({ id }, "File d'attente : élément en attente annulé"); - return true; - } - - if (item.status === 'running') { + } else if (item.status === 'running') { item.abortController?.abort(); - return true; } - return false; + this.items.delete(id); + this.cleanupDir(item).catch((error: unknown) => { + this.logger.error( + { err: error, id }, + "File d'attente : échec du nettoyage lors de la suppression", + ); + }); + this.logger.info({ id, status: item.status }, "File d'attente : élément supprimé"); + return true; } /** @@ -197,12 +201,20 @@ export class QueueService { path, fileName, onSent: () => - this.cleanupDir(item).catch((error: unknown) => { - this.logger.error( - { err: error, id: item.id }, - "File d'attente : échec du nettoyage après livraison", - ); - }), + this.cleanupDir(item) + .catch((error: unknown) => { + this.logger.error( + { err: error, id: item.id }, + "File d'attente : échec du nettoyage après livraison", + ); + }) + .finally(() => { + // Le fichier vient d'être envoyé (et son dossier nettoyé, au + // mieux) : `fileName` ne doit plus désigner un fichier + // consommable, sous peine qu'un second appel tente de lire un + // fichier qui n'existe plus. + item.fileName = null; + }), }; } diff --git a/server/test/QueueService.test.ts b/server/test/QueueService.test.ts index f489f05..8798cb3 100644 --- a/server/test/QueueService.test.ts +++ b/server/test/QueueService.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -114,7 +115,7 @@ describe('QueueService', () => { await expect(stat(join(baseDir, item.id))).rejects.toThrow(); }); - it('annule un élément encore en attente sans jamais appeler yt-dlp pour lui', async () => { + it('remove() supprime un élément encore en attente sans jamais appeler yt-dlp pour lui', async () => { vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); // occupe le seul slot indéfiniment const service = new QueueService(noopLogger, baseDir, 1); @@ -122,16 +123,17 @@ describe('QueueService', () => { const second = service.enqueue(url, videoMode); expect(service.get(second.id)?.status).toBe('pending'); - expect(service.cancel(second.id)).toBe(true); - expect(service.get(second.id)?.status).toBe('cancelled'); + expect(service.remove(second.id)).toBe(true); + expect(service.get(second.id)).toBeNull(); // Le premier élément appelle yt-dlp de façon asynchrone (après son // `mkdir`) : on attend cet appel plutôt que de supposer qu'il a déjà // eu lieu au moment où enqueue() rend la main. await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1); + expect(runYtDlp).toHaveBeenCalledTimes(1); // jamais appelé pour le second, supprimé avant de démarrer }); - it("annule un élément en cours en déclenchant son signal d'annulation", async () => { + it("remove() supprime un élément en cours en déclenchant son signal d'annulation", async () => { vi.mocked(runYtDlp).mockImplementation( (_args, options) => new Promise((_resolve, reject) => { @@ -140,7 +142,7 @@ describe('QueueService', () => { error.name = 'AbortError'; reject(error); }; - // Le signal peut déjà être annulé si cancel() a été appelé avant + // Le signal peut déjà être annulé si remove() a été appelé avant // que ce mock ne soit invoqué (après le `mkdir` de runItem) : un // listener "abort" posé après coup ne se déclencherait jamais. if (options?.signal?.aborted) { @@ -155,8 +157,30 @@ describe('QueueService', () => { const item = service.enqueue(url, videoMode); await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1); - expect(service.cancel(item.id)).toBe(true); - await waitFor(() => service.get(item.id)?.status === 'cancelled'); + expect(service.remove(item.id)).toBe(true); + // Retiré immédiatement de la liste, pas seulement une fois l'abort + // effectif côté yt-dlp. + expect(service.get(item.id)).toBeNull(); + }); + + it('remove() retourne false pour un identifiant inconnu', () => { + const service = new QueueService(noopLogger, baseDir, 1); + expect(service.remove('inconnu')).toBe(false); + }); + + it('remove() supprime un élément terminé et nettoie le fichier jamais téléchargé', async () => { + vi.mocked(runYtDlp).mockImplementation(async (args) => { + await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake'); + return ''; + }); + + const service = new QueueService(noopLogger, baseDir, 1); + const item = service.enqueue(url, videoMode); + await waitFor(() => service.get(item.id)?.status === 'completed'); + + expect(service.remove(item.id)).toBe(true); + expect(service.get(item.id)).toBeNull(); + await waitFor(() => !existsSync(join(baseDir, item.id))); }); it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => { @@ -188,5 +212,10 @@ describe('QueueService', () => { await result.onSent(); await expect(stat(dirname(result.path))).rejects.toThrow(); + + // Le fichier a été livré : un second appel ne doit plus rien exposer, + // sous peine de tenter de relire un fichier qui n'existe plus. + expect(service.get(item.id)?.fileName).toBeNull(); + expect(service.consumeCompletedFile(item.id)).toBeNull(); }); }); diff --git a/server/test/queueRoute.test.ts b/server/test/queueRoute.test.ts index 084aeda..753a258 100644 --- a/server/test/queueRoute.test.ts +++ b/server/test/queueRoute.test.ts @@ -93,6 +93,24 @@ describe('routes de file d’attente', () => { expect(response.statusCode).toBe(404); }); + it('DELETE /api/queue/:id supprime un élément et le retire de la liste', async () => { + const app = Fastify(); + await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); + + const created = await app.inject({ + method: 'POST', + url: '/api/queue', + payload: { url: validUrl, mode: 'video' }, + }); + const { id } = created.json().item; + + const deleteResponse = await app.inject({ method: 'DELETE', url: `/api/queue/${id}` }); + expect(deleteResponse.statusCode).toBe(204); + + const listResponse = await app.inject({ method: 'GET', url: '/api/queue' }); + expect(listResponse.json().items).toHaveLength(0); + }); + it('GET /api/queue/:id/file renvoie 404 tant que le téléchargement n’est pas terminé', async () => { const app = Fastify(); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); diff --git a/spec/architecture.md b/spec/architecture.md index 2b61d88..af2f421 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -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, bouton annuler/lien de téléchargement selon le statut). 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 (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`. @@ -47,8 +47,8 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe - `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` : annule un élément (`204`) - retiré de la file s'il était en attente, processus yt-dlp arrêté s'il était en cours. `404` si inconnu ou déjà terminé. -- `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. `404` tant que l'élément n'est pas `completed`. +- `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