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, 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, ): FastifyPluginAsync { return async (app: FastifyInstance): Promise => { 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(); 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; }); }; }