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>
72 lines
2.5 KiB
TypeScript
72 lines
2.5 KiB
TypeScript
/** Résumé d'une vidéo listée depuis une chaîne ou une playlist. */
|
|
export interface VideoSummary {
|
|
/** Identifiant YouTube de la vidéo. */
|
|
readonly id: string;
|
|
/** Titre de la vidéo. */
|
|
readonly title: string;
|
|
/** URL complète de la vidéo. */
|
|
readonly url: string;
|
|
/** URL de la miniature, si connue. */
|
|
readonly thumbnailUrl: string | null;
|
|
/** Durée en secondes, si connue (absente pour un live en cours). */
|
|
readonly durationSeconds: number | null;
|
|
/** Date de mise en ligne au format `YYYYMMDD`, si connue. */
|
|
readonly uploadDate: string | null;
|
|
}
|
|
|
|
/**
|
|
* Extrait l'URL de miniature d'une entrée yt-dlp. En mode `--flat-playlist`,
|
|
* l'extracteur YouTube ne renseigne quasiment jamais le champ singulier
|
|
* `thumbnail` : il fournit uniquement `thumbnails`, un tableau d'objets
|
|
* `{url, width?, height?}` généralement trié par résolution croissante.
|
|
*
|
|
* @param entry - Entrée JSON déjà parsée.
|
|
* @returns L'URL de la miniature de meilleure résolution connue, ou `null`.
|
|
*/
|
|
function extractThumbnailUrl(entry: Record<string, unknown>): string | null {
|
|
if (typeof entry.thumbnail === 'string') {
|
|
return entry.thumbnail;
|
|
}
|
|
|
|
if (Array.isArray(entry.thumbnails) && entry.thumbnails.length > 0) {
|
|
const best = entry.thumbnails.at(-1) as Record<string, unknown> | undefined;
|
|
if (best && typeof best.url === 'string') {
|
|
return best.url;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Convertit une entrée de la sortie `yt-dlp --flat-playlist` (ligne de `-j`
|
|
* ou élément de `entries` de `-J`) en résumé de vidéo. Retourne `null` pour
|
|
* toute entrée inexploitable (pas un objet, ou sans identifiant, titre ou
|
|
* URL) plutôt que d'interrompre le traitement des autres entrées.
|
|
*
|
|
* @param raw - Valeur JSON déjà parsée, de forme non garantie.
|
|
* @returns Le résumé extrait, ou `null` si l'entrée n'a pas pu être exploitée.
|
|
*/
|
|
export function summarizeEntry(raw: unknown): VideoSummary | null {
|
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
return null;
|
|
}
|
|
|
|
const entry = raw as Record<string, unknown>;
|
|
const id = typeof entry.id === 'string' ? entry.id : null;
|
|
const title = typeof entry.title === 'string' ? entry.title : null;
|
|
const url = typeof entry.url === 'string' ? entry.url : null;
|
|
|
|
if (!id || !title || !url) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
title,
|
|
url,
|
|
thumbnailUrl: extractThumbnailUrl(entry),
|
|
durationSeconds: typeof entry.duration === 'number' ? entry.duration : null,
|
|
uploadDate: typeof entry.upload_date === 'string' ? entry.upload_date : null,
|
|
};
|
|
}
|