youtubeDownloader/server/src/services/videoSummary.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

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,
};
}