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>
117 lines
3.5 KiB
TypeScript
117 lines
3.5 KiB
TypeScript
import Fastify from 'fastify';
|
||
import { 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() };
|
||
});
|
||
|
||
import { playlistRoutes } from '../src/routes/playlist.js';
|
||
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
|
||
|
||
const validUrl = encodeURIComponent('https://www.youtube.com/playlist?list=PLKDZ1ig0uz-U');
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(runYtDlp).mockReset();
|
||
});
|
||
|
||
describe('GET /api/playlist', () => {
|
||
it('retourne le titre et les vidéos d’une playlist valide', async () => {
|
||
vi.mocked(runYtDlp).mockResolvedValue(
|
||
JSON.stringify({
|
||
_type: 'playlist',
|
||
title: 'Ma playlist',
|
||
entries: [
|
||
{
|
||
id: 'v1',
|
||
title: 'Vidéo 1',
|
||
url: 'https://www.youtube.com/watch?v=v1',
|
||
duration: 90,
|
||
thumbnails: [{ url: 'https://x/v1.jpg' }],
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
|
||
const app = Fastify();
|
||
await app.register(playlistRoutes);
|
||
|
||
const response = await app.inject({ method: 'GET', url: `/api/playlist?url=${validUrl}` });
|
||
|
||
expect(response.statusCode).toBe(200);
|
||
expect(response.json()).toEqual({
|
||
title: 'Ma playlist',
|
||
videos: [
|
||
{
|
||
id: 'v1',
|
||
title: 'Vidéo 1',
|
||
url: 'https://www.youtube.com/watch?v=v1',
|
||
thumbnailUrl: 'https://x/v1.jpg',
|
||
durationSeconds: 90,
|
||
uploadDate: null,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
['un domaine étranger', 'https://evil.com/playlist?list=PLabc'],
|
||
['un lien de vidéo', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
|
||
['un lien de vidéo avec list', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc'],
|
||
['du texte quelconque', 'pas une url'],
|
||
])('renvoie 400 pour %s, sans appeler yt-dlp', async (_label, url) => {
|
||
const app = Fastify();
|
||
await app.register(playlistRoutes);
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: `/api/playlist?url=${encodeURIComponent(url)}`,
|
||
});
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
expect(runYtDlp).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('renvoie 400 si le paramètre url est manquant (validation de schéma)', async () => {
|
||
const app = Fastify();
|
||
await app.register(playlistRoutes);
|
||
|
||
const response = await app.inject({ method: 'GET', url: '/api/playlist' });
|
||
|
||
expect(response.statusCode).toBe(400);
|
||
});
|
||
|
||
it('renvoie 502 quand yt-dlp échoue (ex. playlist introuvable)', async () => {
|
||
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'The playlist does not exist.'));
|
||
|
||
const app = Fastify();
|
||
await app.register(playlistRoutes);
|
||
|
||
const response = await app.inject({ method: 'GET', url: `/api/playlist?url=${validUrl}` });
|
||
|
||
expect(response.statusCode).toBe(502);
|
||
});
|
||
|
||
it('renvoie 502 (pas 500) quand le binaire yt-dlp est introuvable (ENOENT)', async () => {
|
||
const spawnError = Object.assign(new Error('spawn yt-dlp ENOENT'), { code: 'ENOENT' });
|
||
vi.mocked(runYtDlp).mockRejectedValue(spawnError);
|
||
|
||
const app = Fastify();
|
||
await app.register(playlistRoutes);
|
||
|
||
const response = await app.inject({ method: 'GET', url: `/api/playlist?url=${validUrl}` });
|
||
|
||
expect(response.statusCode).toBe(502);
|
||
});
|
||
|
||
it('renvoie 502 (pas 500) quand la sortie de yt-dlp est illisible', async () => {
|
||
vi.mocked(runYtDlp).mockResolvedValue('pas du JSON');
|
||
|
||
const app = Fastify();
|
||
await app.register(playlistRoutes);
|
||
|
||
const response = await app.inject({ method: 'GET', url: `/api/playlist?url=${validUrl}` });
|
||
|
||
expect(response.statusCode).toBe(502);
|
||
});
|
||
});
|