import type { FastifyBaseLogger } from 'fastify'; import { runYtDlp } from './ytDlpRunner.js'; /** Section d'une chaîne YouTube à parcourir. */ export type ChannelSection = 'videos' | 'streams'; /** Résumé d'une vidéo/stream listé depuis une chaîne. */ export interface ChannelVideoSummary { /** 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; } /** * Erreur levée quand l'identifiant de chaîne fourni ne correspond à aucun * format accepté (ni handle, ni URL de chaîne YouTube). */ export class InvalidChannelError extends Error { /** * @param channel - Valeur fournie par l'appelant, refusée telle quelle. */ public constructor(channel: string) { super(`Identifiant de chaîne invalide : "${channel}"`); this.name = 'InvalidChannelError'; } } // Handle YouTube : commence obligatoirement par une lettre/chiffre (jamais // par "-" ou "."), suivi de lettres/chiffres/points/tirets/underscores, // 3 à 30 caractères au total. Le premier caractère imposé exclut par // construction toute chaîne pouvant être prise pour une option yt-dlp // (voir aussi le séparateur `--` dans listChannel, en défense supplémentaire). const HANDLE_PATTERN = /^@?[A-Za-z0-9][\w.-]{2,29}$/; const CHANNEL_URL_PATTERN = /^https:\/\/(www\.)?youtube\.com\/(@[\w.-]{3,30}|channel\/[\w-]+|c\/[\w.-]+)\/?$/; /** * Construit l'URL de la section demandée (vidéos ou streams) d'une chaîne, * à partir d'un handle (`@nom`, avec ou sans le `@`) ou d'une URL de chaîne * déjà complète. * * @param channel - Handle ou URL de chaîne fournis par l'utilisateur. * @param section - Section de la chaîne à cibler. * @returns L'URL YouTube de la section demandée. * @throws {InvalidChannelError} Si `channel` ne correspond à aucun format accepté. */ function buildChannelUrl(channel: string, section: ChannelSection): string { const trimmed = channel.trim(); if (CHANNEL_URL_PATTERN.test(trimmed)) { return `${trimmed.replace(/\/$/, '')}/${section}`; } if (HANDLE_PATTERN.test(trimmed)) { const handle = trimmed.startsWith('@') ? trimmed : `@${trimmed}`; return `https://www.youtube.com/${handle}/${section}`; } throw new InvalidChannelError(channel); } /** * Vérifie si un titre contient tous les mots-clés fournis (recherche * insensible à la casse, correspondance ET entre les mots-clés). * * @param title - Titre à tester. * @param keywords - Mots-clés devant tous apparaître dans le titre. * @returns `true` si tous les mots-clés sont présents dans le titre. */ function matchesKeywords(title: string, keywords: readonly string[]): boolean { if (keywords.length === 0) { return true; } const lowerTitle = title.toLowerCase(); return keywords.every((keyword) => lowerTitle.includes(keyword.toLowerCase())); } /** * Convertit une ligne de sortie `yt-dlp --flat-playlist -j` en résumé de * vidéo. Retourne `null` pour toute ligne illisible ou incomplète plutôt * que d'interrompre le traitement des autres lignes. * * @param line - Une ligne de la sortie yt-dlp, censée contenir un objet JSON. * @returns Le résumé extrait, ou `null` si la ligne n'a pas pu être exploitée. */ function parseEntryLine(line: string): ChannelVideoSummary | null { if (line.trim().length === 0) { return null; } try { const entry = JSON.parse(line) as Record; 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: typeof entry.thumbnail === 'string' ? entry.thumbnail : null, durationSeconds: typeof entry.duration === 'number' ? entry.duration : null, uploadDate: typeof entry.upload_date === 'string' ? entry.upload_date : null, }; } catch { return null; } } /** * Service de découverte des vidéos/streams d'une chaîne YouTube, via * `yt-dlp --flat-playlist` (énumération rapide, sans extraction complète de * chaque vidéo). */ export class ChannelDiscoveryService { private readonly logger: FastifyBaseLogger; /** * @param logger - Logger utilisé pour tracer les requêtes et leurs échecs. */ public constructor(logger: FastifyBaseLogger) { this.logger = logger; } /** * Liste les vidéos (ou streams) d'une chaîne, filtrées par mots-clés * optionnels sur le titre. * * @param channel - Handle (`@nom`) ou URL de la chaîne à parcourir. * @param section - Section à parcourir (`videos` ou `streams`). * @param keywords - Mots-clés devant tous apparaître dans le titre (ET). * @returns La liste des vidéos correspondantes. * @throws {InvalidChannelError} Si `channel` n'est pas un identifiant valide. * @throws {YtDlpError} Si yt-dlp échoue (chaîne introuvable, réseau, etc.). */ public async listChannel( channel: string, section: ChannelSection, keywords: readonly string[] = [], ): Promise { const url = buildChannelUrl(channel, section); this.logger.info({ channel, section, keywords }, 'Découverte de chaîne : requête yt-dlp'); let stdout: string; try { // `--` marque la fin des options : même si `url` contenait un jour // un "-" en tête (déjà exclu par buildChannelUrl), yt-dlp ne // pourrait pas l'interpréter comme une option. stdout = await runYtDlp(['--flat-playlist', '-j', '--', url], 60_000); } catch (error) { this.logger.error( { err: error, channel, section }, 'Découverte de chaîne : échec de la requête yt-dlp', ); throw error; } const results = stdout .split('\n') .map(parseEntryLine) .filter((entry): entry is ChannelVideoSummary => entry !== null) .filter((entry) => matchesKeywords(entry.title, keywords)); this.logger.info( { channel, section, count: results.length }, 'Découverte de chaîne : requête terminée', ); return results; } }