youtubeDownloader/server/test/QueueService.test.ts
Nicolas 63ef42cc20 feat(server): file d'attente + téléchargement (vidéo/audio/miniature)
Cœur du pipeline : ajoute une file d'attente exécutant les téléchargements
yt-dlp avec une concurrence bornée, et livre le fichier produit au
navigateur (pas de dépôt durable côté serveur).

- downloadPlan.ts : construction des arguments yt-dlp par mode (vidéo avec
  sous-titres/miniature en option, audio dans un format courant, miniature
  seule), toujours avec séparateur `--` avant l'URL. Parsing de la
  progression depuis stdout (`[download] NN.N%`).
- videoUrl.ts : valide qu'une URL cible bien youtube.com/youtu.be avant de
  la passer à yt-dlp - ferme le risque de relais SSRF via l'extracteur
  générique de yt-dlp (des centaines de sites supportés) si une URL
  arbitraire était acceptée.
- QueueService.ts : file en mémoire, N téléchargements simultanés
  (MAX_CONCURRENT_DOWNLOADS), annulation via AbortSignal, nettoyage du
  dossier de sortie après échec/annulation ou après livraison du fichier.
- ytDlpRunner.ts : étendu pour le suivi ligne par ligne de stdout
  (progression) et l'annulation (AbortSignal passé à child_process.spawn).
- routes/queue.ts : POST/GET /api/queue, DELETE /api/queue/:id,
  GET /api/queue/:id/file (streaming + nettoyage après envoi).

45 nouveaux tests (59 au total) : fonctions pures, services (vrai système
de fichiers temporaire, yt-dlp mocké), routes. Vérifié aussi en conditions
réelles (serveur démarré localement) : validation d'URL, ajout en file,
et passage correct à l'état "failed" + nettoyage quand le binaire yt-dlp
est absent (ENOENT).

Empilée sur feature/channel-discovery (réutilise ytDlpRunner.ts).

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

192 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('annule 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.cancel(second.id)).toBe(true);
expect(service.get(second.id)?.status).toBe('cancelled');
// 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);
});
it("annule 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 cancel() 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.cancel(item.id)).toBe(true);
await waitFor(() => service.get(item.id)?.status === 'cancelled');
});
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();
});
});