- 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>
221 lines
8.4 KiB
TypeScript
221 lines
8.4 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';
|
||
|
||
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 },
|
||
};
|
||
|
||
/** Extrait le dossier de sortie passé à yt-dlp via `-o` (voir downloadPlan.ts). */
|
||
function extractOutputDir(args: readonly string[]): string {
|
||
const oIndex = args.indexOf('-o');
|
||
const template = args[oIndex + 1] ?? '';
|
||
const withoutPrefix = template.replace(/^thumbnail:/, '');
|
||
return withoutPrefix.slice(0, withoutPrefix.lastIndexOf('/'));
|
||
}
|
||
|
||
/** Attend qu'une condition devienne vraie, en pollant (pas de timers factices ici : vraie I/O fichier en jeu). */
|
||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||
const start = Date.now();
|
||
while (!predicate()) {
|
||
if (Date.now() - start > timeoutMs) {
|
||
throw new Error('waitFor: timeout');
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
}
|
||
}
|
||
|
||
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();
|
||
});
|
||
});
|