youtubeDownloader/server/test/playlistDiscoveryService.test.ts
Nicolas f0dec86b19
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
feat: playlists YouTube (parcourir, ajouter une par une ou d'un coup)
Coller un lien de playlist (youtube.com/playlist?list=...) dans le champ
URL affiche toutes ses videos dans la meme zone de resultats que la
recherche de chaine ; chaque ligne a son bouton "Ajouter", et un bouton
"Tout ajouter a la file" ajoute le reste d'un coup.

Serveur :
- GET /api/playlist (PlaylistDiscoveryService) : yt-dlp --flat-playlist
  -J. -J plutot que -j : le titre de la playlist n'existe qu'a la racine,
  et une playlist vide reste un resultat valide.
- playlistUrl.ts : n'accepte que les liens youtube.com/playlist?list=<id>
  et reconstruit l'URL canonique depuis le seul identifiant valide (rien
  d'autre de l'entree n'atteint yt-dlp). Un lien watch?v=...&list=...
  reste une video (--no-playlist etait deja passe au telechargement).
- videoSummary.ts : conversion d'une entree --flat-playlist en resume de
  video, extraite de ChannelDiscoveryService pour etre partagee (chaine
  et playlist parsent desormais identiquement). ChannelVideoSummary
  devient VideoSummary (et ChannelVideoDto -> VideoDto cote client).

Client :
- Le champ URL accepte video ou playlist (isPlaylistUrl aiguille) ; zone
  de resultats partagee (createResultsView).
- "Tout ajouter" : POST /api/queue sequentiels (ordre conserve, pas de
  doublon avec les lignes deja ajoutees). Un echec n'interrompt pas le
  lot : nombre d'echecs + premier message affiches, seules les lignes en
  echec redeviennent ajoutables. Le mode est lu une seule fois pour tout
  le lot.
- Miniatures de resultats en loading="lazy" (une playlist peut compter
  des centaines de videos).

Verifie contre le vrai yt-dlp (2026.07.04) : forme reelle de -J relevee
(fixtures de test calquees dessus), playlist de 1607 videos listee en
~11 s (delai fixe a 120 s). Teste dans le navigateur reel : playlist de
13 videos, ajout unitaire, "tout ajouter" (13 elements, aucun doublon),
echec partiel simule + reprise, playlist inexistante, lien video avec
list=, recherche de chaine (non-regression).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 14:55:38 +02:00

142 lines
4.9 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 type { FastifyBaseLogger } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/services/ytDlpRunner.js', () => ({
runYtDlp: vi.fn(),
}));
import { PlaylistDiscoveryService } from '../src/services/PlaylistDiscoveryService.js';
import { InvalidPlaylistUrlError } from '../src/services/playlistUrl.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
const playlistUrl = 'https://www.youtube.com/playlist?list=PLKDZ1ig0uz-U';
// Forme réelle de `yt-dlp --flat-playlist -J` sur une playlist (relevée avec
// yt-dlp 2026.07.04) : un objet racine avec `title`, et des entrées `url`
// portant `thumbnails` (tableau, jamais `thumbnail`) et pas de `upload_date`.
const realShapeOutput = JSON.stringify({
_type: 'playlist',
id: 'PLKDZ1ig0uz-U',
title: 'Deep clean dreams',
playlist_count: 3,
entries: [
{
_type: 'url',
ie_key: 'Youtube',
id: '9rqQgOERpDo',
url: 'https://www.youtube.com/watch?v=9rqQgOERpDo',
title: 'Restoring an ABANDONED 1963 Split Window Corvette!',
duration: 1917,
thumbnails: [
{ url: 'https://i.ytimg.com/vi/9rqQgOERpDo/small.jpg', width: 168, height: 94 },
{ url: 'https://i.ytimg.com/vi/9rqQgOERpDo/large.jpg', width: 336, height: 188 },
],
},
{
_type: 'url',
id: 'aaaaaaaaaaa',
url: 'https://www.youtube.com/watch?v=aaaaaaaaaaa',
title: 'Deuxième vidéo',
duration: 60,
thumbnails: [{ url: 'https://i.ytimg.com/vi/aaaaaaaaaaa/large.jpg' }],
},
{ _type: 'url', id: 'bbbbbbbbbbb', title: 'Entrée sans URL (indisponible)' },
],
});
beforeEach(() => {
vi.mocked(runYtDlp).mockReset();
});
describe('PlaylistDiscoveryService', () => {
it('parse la sortie réelle de yt-dlp : titre, vidéos dans l’ordre, entrées inexploitables ignorées', async () => {
vi.mocked(runYtDlp).mockResolvedValue(realShapeOutput);
const service = new PlaylistDiscoveryService(noopLogger);
const listing = await service.listPlaylist(playlistUrl);
expect(listing).toEqual({
title: 'Deep clean dreams',
videos: [
{
id: '9rqQgOERpDo',
title: 'Restoring an ABANDONED 1963 Split Window Corvette!',
url: 'https://www.youtube.com/watch?v=9rqQgOERpDo',
thumbnailUrl: 'https://i.ytimg.com/vi/9rqQgOERpDo/large.jpg',
durationSeconds: 1917,
uploadDate: null,
},
{
id: 'aaaaaaaaaaa',
title: 'Deuxième vidéo',
url: 'https://www.youtube.com/watch?v=aaaaaaaaaaa',
thumbnailUrl: 'https://i.ytimg.com/vi/aaaaaaaaaaa/large.jpg',
durationSeconds: 60,
uploadDate: null,
},
],
});
});
it('appelle yt-dlp avec -J, l’URL canonique après "--", et un délai adapté aux longues playlists', async () => {
vi.mocked(runYtDlp).mockResolvedValue(realShapeOutput);
const service = new PlaylistDiscoveryService(noopLogger);
await service.listPlaylist(
' https://music.youtube.com/playlist?si=zzz&list=PLKDZ1ig0uz-U&index=2 ',
);
expect(runYtDlp).toHaveBeenCalledTimes(1);
expect(runYtDlp).toHaveBeenCalledWith(['--flat-playlist', '-J', '--', playlistUrl], {
timeoutMs: 120_000,
});
});
it('renvoie une playlist vide avec son titre quand entries est vide', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
JSON.stringify({ _type: 'playlist', title: 'Vide', entries: [] }),
);
const service = new PlaylistDiscoveryService(noopLogger);
await expect(service.listPlaylist(playlistUrl)).resolves.toEqual({ title: 'Vide', videos: [] });
});
it('tolère l’absence de titre et d’entries (titre null, aucune vidéo)', async () => {
vi.mocked(runYtDlp).mockResolvedValue(JSON.stringify({ _type: 'playlist' }));
const service = new PlaylistDiscoveryService(noopLogger);
await expect(service.listPlaylist(playlistUrl)).resolves.toEqual({ title: null, videos: [] });
});
it('rejette une URL invalide sans jamais appeler yt-dlp', async () => {
const service = new PlaylistDiscoveryService(noopLogger);
await expect(
service.listPlaylist('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc'),
).rejects.toBeInstanceOf(InvalidPlaylistUrlError);
expect(runYtDlp).not.toHaveBeenCalled();
});
it('propage l’échec de yt-dlp', async () => {
const failure = new Error('yt-dlp a échoué (code 1) : The playlist does not exist.');
vi.mocked(runYtDlp).mockRejectedValue(failure);
const service = new PlaylistDiscoveryService(noopLogger);
await expect(service.listPlaylist(playlistUrl)).rejects.toBe(failure);
});
it('lève une erreur explicite (avec la cause) quand la sortie n’est pas du JSON', async () => {
vi.mocked(runYtDlp).mockResolvedValue('ceci n’est pas du JSON');
const service = new PlaylistDiscoveryService(noopLogger);
await expect(service.listPlaylist(playlistUrl)).rejects.toMatchObject({
message: expect.stringContaining('illisible'),
cause: expect.any(SyntaxError),
});
});
});