import { createReadStream } from 'node:fs'; 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' }; } } /** * 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) et `GET * /api/queue/:id/file` (livraison du fichier terminé). * * @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)); }, ); }; }