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>
192 lines
7.1 KiB
TypeScript
192 lines
7.1 KiB
TypeScript
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();
|
||
});
|
||
});
|