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>
133 lines
3.5 KiB
TypeScript
133 lines
3.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
type DownloadMode,
|
|
buildDownloadArgs,
|
|
expectedExtension,
|
|
parseProgress,
|
|
} from '../src/services/downloadPlan.js';
|
|
|
|
describe('expectedExtension', () => {
|
|
it('retourne mp4 pour le mode vidéo', () => {
|
|
const mode: DownloadMode = {
|
|
kind: 'video',
|
|
options: { subtitles: false, embedThumbnail: false },
|
|
};
|
|
expect(expectedExtension(mode)).toBe('mp4');
|
|
});
|
|
|
|
it('retourne le format demandé pour le mode audio', () => {
|
|
expect(expectedExtension({ kind: 'audio', format: 'opus' })).toBe('opus');
|
|
});
|
|
|
|
it('retourne jpg pour le mode miniature', () => {
|
|
expect(expectedExtension({ kind: 'thumbnail' })).toBe('jpg');
|
|
});
|
|
});
|
|
|
|
describe('buildDownloadArgs', () => {
|
|
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
|
const outputDir = '/tmp/abc123';
|
|
|
|
it('mode vidéo sans options : pas de sous-titres ni de miniature embarquée', () => {
|
|
const args = buildDownloadArgs(
|
|
url,
|
|
{ kind: 'video', options: { subtitles: false, embedThumbnail: false } },
|
|
outputDir,
|
|
);
|
|
|
|
expect(args).toEqual([
|
|
'--newline',
|
|
'--no-playlist',
|
|
'--add-metadata',
|
|
'-f',
|
|
'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b',
|
|
'--merge-output-format',
|
|
'mp4',
|
|
'-o',
|
|
`${outputDir}/%(title)s.%(ext)s`,
|
|
'--',
|
|
url,
|
|
]);
|
|
});
|
|
|
|
it('mode vidéo avec sous-titres et miniature embarquée', () => {
|
|
const args = buildDownloadArgs(
|
|
url,
|
|
{ kind: 'video', options: { subtitles: true, embedThumbnail: true } },
|
|
outputDir,
|
|
);
|
|
|
|
expect(args).toContain('--embed-subs');
|
|
expect(args).toContain('--embed-thumbnail');
|
|
expect(args).toEqual(
|
|
expect.arrayContaining(['--write-subs', '--write-auto-subs', '--sub-langs', 'fr,en']),
|
|
);
|
|
});
|
|
|
|
it('mode audio : extraction au format demandé', () => {
|
|
const args = buildDownloadArgs(url, { kind: 'audio', format: 'opus' }, outputDir);
|
|
|
|
expect(args).toEqual([
|
|
'--newline',
|
|
'--no-playlist',
|
|
'--add-metadata',
|
|
'-x',
|
|
'--audio-format',
|
|
'opus',
|
|
'-o',
|
|
`${outputDir}/%(title)s.%(ext)s`,
|
|
'--',
|
|
url,
|
|
]);
|
|
});
|
|
|
|
it('mode miniature : téléchargement de la vidéo ignoré', () => {
|
|
const args = buildDownloadArgs(url, { kind: 'thumbnail' }, outputDir);
|
|
|
|
expect(args).toEqual([
|
|
'--newline',
|
|
'--no-playlist',
|
|
'--add-metadata',
|
|
'--skip-download',
|
|
'--write-thumbnail',
|
|
'--convert-thumbnails',
|
|
'jpg',
|
|
'-o',
|
|
`thumbnail:${outputDir}/%(title)s`,
|
|
'--',
|
|
url,
|
|
]);
|
|
});
|
|
|
|
it("place toujours l'URL après un séparateur `--`, quel que soit le mode", () => {
|
|
for (const mode of [
|
|
{ kind: 'video', options: { subtitles: false, embedThumbnail: false } },
|
|
{ kind: 'audio', format: 'mp3' },
|
|
{ kind: 'thumbnail' },
|
|
] satisfies DownloadMode[]) {
|
|
const args = buildDownloadArgs(url, mode, outputDir);
|
|
const separatorIndex = args.indexOf('--');
|
|
expect(separatorIndex).toBeGreaterThanOrEqual(0);
|
|
expect(args[separatorIndex + 1]).toBe(url);
|
|
expect(args).toHaveLength(separatorIndex + 2);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('parseProgress', () => {
|
|
it('extrait un pourcentage entier', () => {
|
|
expect(parseProgress('[download] 42% of 10.00MiB at 1.00MiB/s ETA 00:05')).toBe(42);
|
|
});
|
|
|
|
it('extrait un pourcentage décimal', () => {
|
|
expect(parseProgress('[download] 99.5% of 10.00MiB')).toBe(99.5);
|
|
});
|
|
|
|
it('retourne null pour une ligne sans progression', () => {
|
|
expect(parseProgress('[info] Writing video metadata as JSON')).toBeNull();
|
|
});
|
|
|
|
it('borne le résultat entre 0 et 100', () => {
|
|
expect(parseProgress('[download] 100.0% of 10.00MiB')).toBe(100);
|
|
});
|
|
});
|