youtubeDownloader/server/src/services/ChannelDiscoveryService.ts
Nicolas 63ef42cc20 feat(server): file d'attente + téléchargement (vidéo/audio/miniature)
Cœur du pipeline : ajoute une file d'attente exécutant les téléchargements
yt-dlp avec une concurrence bornée, et livre le fichier produit au
navigateur (pas de dépôt durable côté serveur).

- downloadPlan.ts : construction des arguments yt-dlp par mode (vidéo avec
  sous-titres/miniature en option, audio dans un format courant, miniature
  seule), toujours avec séparateur `--` avant l'URL. Parsing de la
  progression depuis stdout (`[download] NN.N%`).
- videoUrl.ts : valide qu'une URL cible bien youtube.com/youtu.be avant de
  la passer à yt-dlp - ferme le risque de relais SSRF via l'extracteur
  générique de yt-dlp (des centaines de sites supportés) si une URL
  arbitraire était acceptée.
- QueueService.ts : file en mémoire, N téléchargements simultanés
  (MAX_CONCURRENT_DOWNLOADS), annulation via AbortSignal, nettoyage du
  dossier de sortie après échec/annulation ou après livraison du fichier.
- ytDlpRunner.ts : étendu pour le suivi ligne par ligne de stdout
  (progression) et l'annulation (AbortSignal passé à child_process.spawn).
- routes/queue.ts : POST/GET /api/queue, DELETE /api/queue/:id,
  GET /api/queue/:id/file (streaming + nettoyage après envoi).

45 nouveaux tests (59 au total) : fonctions pures, services (vrai système
de fichiers temporaire, yt-dlp mocké), routes. Vérifié aussi en conditions
réelles (serveur démarré localement) : validation d'URL, ajout en file,
et passage correct à l'état "failed" + nettoyage quand le binaire yt-dlp
est absent (ENOENT).

Empilée sur feature/channel-discovery (réutilise ytDlpRunner.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 09:02:37 +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], { timeoutMs: 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;
}
}