youtubeDownloader/server/src/routes/queue.ts
Nicolas a96322f64e
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
feat: suppression d'éléments de la file, téléchargement groupé
- QueueService.remove() remplace cancel() : supprime l'élément de la
  liste quel que soit son état (annule d'abord s'il était en attente/en
  cours), au lieu de se limiter aux deux cas actifs et renvoyer false
  pour un élément déjà terminé.
- consumeCompletedFile() met fileName à null une fois le fichier livré
  (et son dossier nettoyé) : évite qu'un second appel tente de relire un
  fichier qui n'existe déjà plus.
- QueuePanel : un même bouton retire l'élément quel que soit son état -
  "Annuler" en attente/en cours, "Supprimer" une fois terminé. Bouton
  "Tout télécharger" dans l'en-tête, qui déclenche avec un léger décalage
  chaque téléchargement encore disponible (évite le blocage navigateur
  des téléchargements multiples simultanés).
- Liste de résultats de recherche de chaîne agrandie (320px -> 65vh).

11 nouveaux tests côté serveur (63 -> alignés). Vérifié en navigateur :
ajout, échec, suppression effective (l'élément disparaît de la liste).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:50:54 +02:00

148 lines
4.7 KiB
TypeScript

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<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));
},
);
};
}