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>
70 lines
2.3 KiB
TypeScript
70 lines
2.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { summarizeEntry } from '../src/services/videoSummary.js';
|
||
|
||
const base = { id: 'abc', title: 'Titre', url: 'https://www.youtube.com/watch?v=abc' };
|
||
|
||
describe('summarizeEntry', () => {
|
||
it('extrait les champs connus et met null pour les champs absents', () => {
|
||
expect(summarizeEntry(base)).toEqual({
|
||
...base,
|
||
thumbnailUrl: null,
|
||
durationSeconds: null,
|
||
uploadDate: null,
|
||
});
|
||
});
|
||
|
||
it('lit durée et date de mise en ligne quand elles sont bien typées', () => {
|
||
const summary = summarizeEntry({ ...base, duration: 120, upload_date: '20260101' });
|
||
|
||
expect(summary?.durationSeconds).toBe(120);
|
||
expect(summary?.uploadDate).toBe('20260101');
|
||
});
|
||
|
||
it('ignore une durée ou une date de mauvais type', () => {
|
||
const summary = summarizeEntry({ ...base, duration: '120', upload_date: 20260101 });
|
||
|
||
expect(summary?.durationSeconds).toBeNull();
|
||
expect(summary?.uploadDate).toBeNull();
|
||
});
|
||
|
||
it('préfère le champ singulier thumbnail au tableau thumbnails', () => {
|
||
const summary = summarizeEntry({
|
||
...base,
|
||
thumbnail: 'https://x/single.jpg',
|
||
thumbnails: [{ url: 'https://x/array.jpg' }],
|
||
});
|
||
|
||
expect(summary?.thumbnailUrl).toBe('https://x/single.jpg');
|
||
});
|
||
|
||
it('retombe sur la dernière miniature du tableau thumbnails', () => {
|
||
const summary = summarizeEntry({
|
||
...base,
|
||
thumbnails: [{ url: 'https://x/small.jpg' }, { url: 'https://x/large.jpg' }],
|
||
});
|
||
|
||
expect(summary?.thumbnailUrl).toBe('https://x/large.jpg');
|
||
});
|
||
|
||
it('ignore un tableau thumbnails vide ou mal formé', () => {
|
||
expect(summarizeEntry({ ...base, thumbnails: [] })?.thumbnailUrl).toBeNull();
|
||
expect(summarizeEntry({ ...base, thumbnails: [{ width: 10 }] })?.thumbnailUrl).toBeNull();
|
||
expect(summarizeEntry({ ...base, thumbnails: [null] })?.thumbnailUrl).toBeNull();
|
||
});
|
||
|
||
it.each([
|
||
['sans id', { title: 'T', url: 'https://x' }],
|
||
['sans titre', { id: 'a', url: 'https://x' }],
|
||
['sans url', { id: 'a', title: 'T' }],
|
||
['avec un id non textuel', { id: 42, title: 'T', url: 'https://x' }],
|
||
])('retourne null pour une entrée %s', (_label, entry) => {
|
||
expect(summarizeEntry(entry)).toBeNull();
|
||
});
|
||
|
||
it.each([null, undefined, 'texte', 42, [], [base]])(
|
||
'retourne null pour une valeur qui n’est pas un objet (%j)',
|
||
(value) => {
|
||
expect(summarizeEntry(value)).toBeNull();
|
||
},
|
||
);
|
||
});
|