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>
219 lines
7.3 KiB
TypeScript
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;
|
|
});
|
|
};
|
|
}
|