youtubeDownloader/server/src/routes/queue.ts
Nicolas 7fa28ea4f7 feat: téléchargement groupé en zip
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>
2026-09-16 23:39:18 +02:00

219 lines
7.3 KiB
TypeScript

import { createReadStream } from 'node:fs';
import archiver from 'archiver';
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import type { ServerConfig } from '../config.js';
import { QueueService } from '../services/QueueService.js';
import type { AudioFormat, DownloadMode } from '../services/downloadPlan.js';
import { InvalidVideoUrlError, assertValidVideoUrl } from '../services/videoUrl.js';
/** Corps de requête accepté par `POST /api/queue`. */
interface CreateQueueItemBody {
/** URL de la vidéo YouTube à télécharger. */
url: string;
/** Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. */
title?: 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. */
subtitles?: boolean;
/** Mode vidéo uniquement : embarquer la miniature. */
embedThumbnail?: boolean;
/** Mode audio uniquement : format de sortie (mp3 par défaut). */
audioFormat?: AudioFormat;
}
const createQueueItemSchema = {
type: 'object',
required: ['url', 'mode'],
properties: {
url: { type: 'string', minLength: 1, maxLength: 500 },
title: { type: 'string', maxLength: 300 },
mode: { type: 'string', enum: ['video', 'audio', 'thumbnail'] },
subtitles: { type: 'boolean' },
embedThumbnail: { type: 'boolean' },
audioFormat: { type: 'string', enum: ['mp3', 'm4a', 'opus'] },
},
} as const;
interface QueueItemParams {
id: string;
}
const queueItemParamsSchema = {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string', minLength: 1 },
},
} as const;
/**
* Convertit le corps de requête `POST /api/queue` en mode de téléchargement
* typé, en appliquant les valeurs par défaut des options spécifiques à
* chaque mode.
*
* @param body - Corps de requête déjà validé par le schéma Fastify.
* @returns Le mode de téléchargement correspondant.
*/
function toDownloadMode(body: CreateQueueItemBody): DownloadMode {
switch (body.mode) {
case 'video':
return {
kind: 'video',
options: {
subtitles: body.subtitles ?? false,
embedThumbnail: body.embedThumbnail ?? false,
},
};
case 'audio':
return { kind: 'audio', format: body.audioFormat ?? 'mp3' };
case 'thumbnail':
return { kind: 'thumbnail' };
}
}
/**
* Rend un nom de fichier unique au sein d'un zip : ajoute un suffixe
* `" (2)"`, `" (3)"`, etc. avant l'extension en cas de collision (deux
* vidéos au même titre produiraient sinon le même nom de fichier).
*
* @param used - Ensemble des noms déjà attribués dans l'archive en cours ; mis à jour par effet de bord.
* @param fileName - Nom de fichier souhaité.
* @returns Un nom de fichier garanti absent de `used`.
*/
function uniqueArchiveName(used: Set<string>, fileName: string): string {
if (!used.has(fileName)) {
used.add(fileName);
return fileName;
}
const dotIndex = fileName.lastIndexOf('.');
const base = dotIndex === -1 ? fileName : fileName.slice(0, dotIndex);
const ext = dotIndex === -1 ? '' : fileName.slice(dotIndex);
let counter = 2;
let candidate = `${base} (${counter})${ext}`;
while (used.has(candidate)) {
counter += 1;
candidate = `${base} (${counter})${ext}`;
}
used.add(candidate);
return candidate;
}
/**
* Construit le plugin Fastify exposant les routes de file d'attente :
* `POST /api/queue` (ajout), `GET /api/queue` (liste), `DELETE
* /api/queue/:id` (suppression, quel que soit l'état), `GET
* /api/queue/:id/file` (livraison du fichier terminé) et `GET
* /api/queue/download-all` (livraison groupée en zip).
*
* @param config - Configuration runtime (dossier temporaire, concurrence).
* @returns Le plugin Fastify à enregistrer.
*/
export function createQueueRoutes(
config: Pick<ServerConfig, 'tmpDir' | 'maxConcurrentDownloads'>,
): FastifyPluginAsync {
return async (app: FastifyInstance): Promise<void> => {
const queueService = new QueueService(app.log, config.tmpDir, config.maxConcurrentDownloads);
app.post<{ Body: CreateQueueItemBody }>(
'/api/queue',
{ schema: { body: createQueueItemSchema } },
async (request: FastifyRequest<{ Body: CreateQueueItemBody }>, reply: FastifyReply) => {
let url: string;
try {
url = assertValidVideoUrl(request.body.url);
} catch (error) {
if (error instanceof InvalidVideoUrlError) {
return reply.status(400).send({ error: error.message });
}
throw error;
}
const item = queueService.enqueue(
url,
toDownloadMode(request.body),
request.body.title ?? null,
);
return reply.status(201).send({ item });
},
);
app.get('/api/queue', async () => {
return { items: queueService.list() };
});
app.delete<{ Params: QueueItemParams }>(
'/api/queue/:id',
{ schema: { params: queueItemParamsSchema } },
async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => {
const removed = queueService.remove(request.params.id);
if (!removed) {
return reply.status(404).send({ error: 'Élément introuvable.' });
}
return reply.status(204).send();
},
);
app.get<{ Params: QueueItemParams }>(
'/api/queue/:id/file',
{ schema: { params: queueItemParamsSchema } },
async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => {
const result = queueService.consumeCompletedFile(request.params.id);
if (!result) {
return reply
.status(404)
.send({ error: 'Fichier introuvable ou téléchargement non terminé.' });
}
reply.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(result.fileName)}"`,
);
reply.raw.once('finish', result.onSent);
return reply.send(createReadStream(result.path));
},
);
app.get('/api/queue/download-all', async (_request: FastifyRequest, reply: FastifyReply) => {
const deliveries = queueService.consumeAllCompletedFiles();
if (deliveries.length === 0) {
return reply.status(404).send({ error: 'Aucun fichier disponible au téléchargement.' });
}
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('error', (error: Error) => {
app.log.error({ err: error }, "File d'attente : échec de la création du zip");
});
const usedNames = new Set<string>();
for (const delivery of deliveries) {
archive.file(delivery.path, { name: uniqueArchiveName(usedNames, delivery.fileName) });
}
reply.header('Content-Disposition', 'attachment; filename="youtubedownloader.zip"');
reply.header('Content-Type', 'application/zip');
reply.raw.once('finish', () => {
Promise.all(deliveries.map((delivery) => delivery.onSent())).catch((error: unknown) => {
app.log.error(
{ err: error },
"File d'attente : échec du nettoyage après téléchargement groupé",
);
});
});
// L'envoi de la réponse doit démarrer la consommation du flux
// AVANT finalize() : sur une grosse archive, finalize() n'aboutit
// qu'une fois tout le contenu écrit dans le flux, ce qui bloquerait
// indéfiniment si rien ne le lit encore (mémoire tampon pleine,
// plus d'écriture possible côté archiver).
const response = reply.send(archive);
archive.finalize().catch((error: unknown) => {
app.log.error({ err: error }, "File d'attente : échec de la finalisation du zip");
});
return response;
});
};
}