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>
203 lines
7.7 KiB
TypeScript
203 lines
7.7 KiB
TypeScript
import { existsSync } from 'node:fs';
|
||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import type { FastifyBaseLogger } from 'fastify';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
|
||
return { ...actual, runYtDlp: vi.fn() };
|
||
});
|
||
|
||
import { QueueService } from '../src/services/QueueService.js';
|
||
import type { DownloadMode } from '../src/services/downloadPlan.js';
|
||
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
|
||
import { extractOutputDir, waitFor } from './helpers.js';
|
||
|
||
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
|
||
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||
const videoMode: DownloadMode = {
|
||
kind: 'video',
|
||
options: { subtitles: false, embedThumbnail: false },
|
||
};
|
||
|
||
let baseDir: string;
|
||
|
||
beforeEach(async () => {
|
||
baseDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-test-'));
|
||
vi.mocked(runYtDlp).mockReset();
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await rm(baseDir, { recursive: true, force: true });
|
||
});
|
||
|
||
describe('QueueService', () => {
|
||
it('exécute un élément jusqu’à complétion et retrouve le fichier produit', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(async (args, options) => {
|
||
options?.onStdoutLine?.('[download] 50.0% of 10.00MiB');
|
||
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
|
||
return '';
|
||
});
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 2);
|
||
const item = service.enqueue(url, videoMode);
|
||
|
||
await waitFor(() => service.get(item.id)?.status === 'completed');
|
||
|
||
const final = service.get(item.id);
|
||
expect(final?.fileName).toBe('My Video.mp4');
|
||
expect(final?.progress).toBe(100);
|
||
expect(final?.error).toBeNull();
|
||
});
|
||
|
||
it('respecte la concurrence maximale configurée', async () => {
|
||
let concurrentCount = 0;
|
||
let maxObservedConcurrent = 0;
|
||
const releaseFns: Array<() => void> = [];
|
||
|
||
vi.mocked(runYtDlp).mockImplementation(async (args) => {
|
||
concurrentCount += 1;
|
||
maxObservedConcurrent = Math.max(maxObservedConcurrent, concurrentCount);
|
||
await new Promise<void>((resolve) => releaseFns.push(resolve));
|
||
concurrentCount -= 1;
|
||
await writeFile(join(extractOutputDir(args), 'v.mp4'), 'x');
|
||
return '';
|
||
});
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 2);
|
||
const first = service.enqueue(url, videoMode);
|
||
const second = service.enqueue(url, videoMode);
|
||
const third = service.enqueue(url, videoMode);
|
||
const items = [first, second, third];
|
||
|
||
await waitFor(() => releaseFns.length === 2);
|
||
expect(maxObservedConcurrent).toBe(2);
|
||
expect(service.get(third.id)?.status).toBe('pending');
|
||
|
||
releaseFns[0]?.();
|
||
await waitFor(() => releaseFns.length === 3);
|
||
releaseFns[1]?.();
|
||
releaseFns[2]?.();
|
||
|
||
await waitFor(() => items.every((item) => service.get(item.id)?.status === 'completed'));
|
||
expect(maxObservedConcurrent).toBeLessThanOrEqual(2);
|
||
});
|
||
|
||
it('marque un élément en échec et nettoie son dossier de sortie quand yt-dlp échoue', async () => {
|
||
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network unreachable'));
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 2);
|
||
const item = service.enqueue(url, videoMode);
|
||
|
||
await waitFor(() => service.get(item.id)?.status === 'failed');
|
||
|
||
expect(service.get(item.id)?.error).toContain('network unreachable');
|
||
await expect(stat(join(baseDir, item.id))).rejects.toThrow();
|
||
});
|
||
|
||
it('remove() supprime un élément encore en attente sans jamais appeler yt-dlp pour lui', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); // occupe le seul slot indéfiniment
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
service.enqueue(url, videoMode);
|
||
const second = service.enqueue(url, videoMode);
|
||
|
||
expect(service.get(second.id)?.status).toBe('pending');
|
||
expect(service.remove(second.id)).toBe(true);
|
||
expect(service.get(second.id)).toBeNull();
|
||
|
||
// Le premier élément appelle yt-dlp de façon asynchrone (après son
|
||
// `mkdir`) : on attend cet appel plutôt que de supposer qu'il a déjà
|
||
// eu lieu au moment où enqueue() rend la main.
|
||
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
|
||
expect(runYtDlp).toHaveBeenCalledTimes(1); // jamais appelé pour le second, supprimé avant de démarrer
|
||
});
|
||
|
||
it("remove() supprime un élément en cours en déclenchant son signal d'annulation", async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(
|
||
(_args, options) =>
|
||
new Promise((_resolve, reject) => {
|
||
const rejectAborted = () => {
|
||
const error = new Error('aborted');
|
||
error.name = 'AbortError';
|
||
reject(error);
|
||
};
|
||
// Le signal peut déjà être annulé si remove() a été appelé avant
|
||
// que ce mock ne soit invoqué (après le `mkdir` de runItem) : un
|
||
// listener "abort" posé après coup ne se déclencherait jamais.
|
||
if (options?.signal?.aborted) {
|
||
rejectAborted();
|
||
return;
|
||
}
|
||
options?.signal?.addEventListener('abort', rejectAborted);
|
||
}),
|
||
);
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode);
|
||
|
||
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
|
||
expect(service.remove(item.id)).toBe(true);
|
||
// Retiré immédiatement de la liste, pas seulement une fois l'abort
|
||
// effectif côté yt-dlp.
|
||
expect(service.get(item.id)).toBeNull();
|
||
});
|
||
|
||
it('remove() retourne false pour un identifiant inconnu', () => {
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
expect(service.remove('inconnu')).toBe(false);
|
||
});
|
||
|
||
it('remove() supprime un élément terminé et nettoie le fichier jamais téléchargé', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(async (args) => {
|
||
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
|
||
return '';
|
||
});
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode);
|
||
await waitFor(() => service.get(item.id)?.status === 'completed');
|
||
|
||
expect(service.remove(item.id)).toBe(true);
|
||
expect(service.get(item.id)).toBeNull();
|
||
await waitFor(() => !existsSync(join(baseDir, item.id)));
|
||
});
|
||
|
||
it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => {
|
||
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {}));
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode);
|
||
|
||
expect(service.consumeCompletedFile('inconnu')).toBeNull();
|
||
expect(service.consumeCompletedFile(item.id)).toBeNull(); // encore en cours
|
||
});
|
||
|
||
it('consumeCompletedFile expose le fichier puis onSent nettoie le dossier', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(async (args) => {
|
||
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
|
||
return '';
|
||
});
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode);
|
||
await waitFor(() => service.get(item.id)?.status === 'completed');
|
||
|
||
const result = service.consumeCompletedFile(item.id);
|
||
if (!result) {
|
||
throw new Error('expected consumeCompletedFile to return a result');
|
||
}
|
||
expect(result.fileName).toBe('My Video.mp4');
|
||
expect(result.path).toBe(join(baseDir, item.id, 'My Video.mp4'));
|
||
|
||
await result.onSent();
|
||
await expect(stat(dirname(result.path))).rejects.toThrow();
|
||
|
||
// Le fichier a été livré : un second appel ne doit plus rien exposer,
|
||
// sous peine de tenter de relire un fichier qui n'existe plus.
|
||
expect(service.get(item.id)?.fileName).toBeNull();
|
||
expect(service.consumeCompletedFile(item.id)).toBeNull();
|
||
});
|
||
});
|