Première brique du pipeline : lister les vidéos/streams d'une chaîne YouTube (yt-dlp --flat-playlist), filtrables par mots-clés sur le titre. - ytDlpRunner.ts : exécution de yt-dlp via child_process.spawn (jamais de shell, arguments en tableau - exclut l'injection de commande) - ChannelDiscoveryService.ts : validation stricte du handle/URL de chaîne + séparateur `--` avant l'URL (défense en profondeur contre l'injection d'options yt-dlp), parsing tolérant des lignes JSON, filtrage mots-clés - routes/channel.ts : GET /api/channel (validation de schéma Fastify, 400 si entrée invalide, 502 si yt-dlp échoue) - 14 tests (unitaires services + intégration route via app.inject) - un test de route a révélé une regex de validation trop permissive qu'un test de service avait manquée, corrigée avant ce commit La file d'attente et le téléchargement suivront dans une PR séparée. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
6.3 KiB
TypeScript
185 lines
6.3 KiB
TypeScript
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<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: 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<ChannelVideoSummary[]> {
|
|
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;
|
|
}
|
|
}
|