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