- 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>
148 lines
4.7 KiB
TypeScript
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));
|
|
},
|
|
);
|
|
};
|
|
}
|