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>
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
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),
|
||
});
|
||
});
|
||
});
|