youtubeDownloader/server/test/channelDiscoveryService.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

104 lines
3 KiB
TypeScript

import type { FastifyBaseLogger } from 'fastify';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../src/services/ytDlpRunner.js', () => ({
runYtDlp: vi.fn(),
}));
import {
ChannelDiscoveryService,
InvalidChannelError,
} from '../src/services/ChannelDiscoveryService.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
describe('ChannelDiscoveryService', () => {
it('parse les lignes yt-dlp et filtre par mots-clés', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
[
JSON.stringify({
id: 'a1',
title: 'Finale speed 2026',
url: 'https://youtu.be/a1',
duration: 120,
thumbnail: 'https://x/a1.jpg',
upload_date: '20260101',
}),
JSON.stringify({ id: 'a2', title: 'Interview backstage', url: 'https://youtu.be/a2' }),
'',
].join('\n'),
);
const service = new ChannelDiscoveryService(noopLogger);
const results = await service.listChannel('@worldclimbing', 'streams', ['speed']);
expect(results).toEqual([
{
id: 'a1',
title: 'Finale speed 2026',
url: 'https://youtu.be/a1',
thumbnailUrl: 'https://x/a1.jpg',
durationSeconds: 120,
uploadDate: '20260101',
},
]);
expect(runYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/streams'],
{ timeoutMs: 60_000 },
);
});
it('accepte une URL de chaîne complète et ignore la casse des mots-clés', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
JSON.stringify({ id: 'c1', title: 'BIG Speed Final', url: 'https://youtu.be/c1' }),
);
const service = new ChannelDiscoveryService(noopLogger);
const results = await service.listChannel('https://www.youtube.com/@worldclimbing/', 'videos', [
'SPEED',
]);
expect(results).toHaveLength(1);
expect(runYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/videos'],
{ timeoutMs: 60_000 },
);
});
it.each(['--exec=rm', '--exec', '-x', '.hidden'])(
'rejette "%s" comme identifiant de chaîne invalide sans jamais appeler yt-dlp',
async (channel) => {
const service = new ChannelDiscoveryService(noopLogger);
await expect(service.listChannel(channel, 'videos')).rejects.toBeInstanceOf(
InvalidChannelError,
);
expect(runYtDlp).not.toHaveBeenCalled();
},
);
it('ignore silencieusement les lignes JSON invalides ou incomplètes', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
[
'not json',
JSON.stringify({ id: 'b1' }),
JSON.stringify({ id: 'b2', title: 'ok', url: 'https://youtu.be/b2' }),
].join('\n'),
);
const service = new ChannelDiscoveryService(noopLogger);
const results = await service.listChannel('somehandle', 'videos');
expect(results).toEqual([
{
id: 'b2',
title: 'ok',
url: 'https://youtu.be/b2',
thumbnailUrl: null,
durationSeconds: null,
uploadDate: null,
},
]);
});
});