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>
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
import { mkdtemp, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import Fastify 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(() => new Promise(() => {})) };
|
||
});
|
||
|
||
import { createQueueRoutes } from '../src/routes/queue.js';
|
||
|
||
const validUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||
|
||
let tmpDir: string;
|
||
|
||
beforeEach(async () => {
|
||
tmpDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-route-test-'));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await rm(tmpDir, { recursive: true, force: true });
|
||
});
|
||
|
||
describe('routes de file d’attente', () => {
|
||
it('POST /api/queue ajoute un élément valide et le retourne', async () => {
|
||
const app = Fastify();
|
||
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/queue',
|
||
payload: { url: validUrl, mode: 'audio', audioFormat: 'opus' },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(201);
|
||
const body = response.json();
|
||
expect(body.item).toMatchObject({
|
||
url: validUrl,
|
||
mode: { kind: 'audio', format: 'opus' },
|
||
status: expect.stringMatching(/pending|running/),
|
||
});
|
||
});
|
||
|
||
it('POST /api/queue renvoie 400 pour une URL non-YouTube', async () => {
|
||
const app = Fastify();
|
||
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/queue',
|
||
payload: { url: 'https://evil.com/x', mode: 'video' },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
});
|
||
|
||
it('POST /api/queue renvoie 400 pour un mode invalide (validation de schéma)', async () => {
|
||
const app = Fastify();
|
||
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/queue',
|
||
payload: { url: validUrl, mode: 'video-4k-ultra' },
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
});
|
||
|
||
it('GET /api/queue liste les éléments ajoutés', async () => {
|
||
const app = Fastify();
|
||
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||
|
||
await app.inject({
|
||
method: 'POST',
|
||
url: '/api/queue',
|
||
payload: { url: validUrl, mode: 'thumbnail' },
|
||
});
|
||
const response = await app.inject({ method: 'GET', url: '/api/queue' });
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
expect(response.json().items).toHaveLength(1);
|
||
});
|
||
|
||
it('DELETE /api/queue/:id renvoie 404 pour un identifiant inconnu', async () => {
|
||
const app = Fastify();
|
||
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||
|
||
const response = await app.inject({ method: 'DELETE', url: '/api/queue/inconnu' });
|
||
|
||
expect(response.statusCode).toBe(404);
|
||
});
|
||
|
||
it('GET /api/queue/:id/file renvoie 404 tant que le téléchargement n’est pas terminé', async () => {
|
||
const app = Fastify();
|
||
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||
|
||
const created = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/queue',
|
||
payload: { url: validUrl, mode: 'video' },
|
||
});
|
||
const { id } = created.json().item;
|
||
|
||
const response = await app.inject({ method: 'GET', url: `/api/queue/${id}/file` });
|
||
|
||
expect(response.statusCode).toBe(404);
|
||
});
|
||
});
|