youtubeDownloader/server/src/services/ChannelDiscoveryService.ts
Nicolas 0e78b44a55
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
feat(server): service de découverte de chaîne (GET /api/channel)
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>
2026-09-16 08:05:29 +02:00

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