feat(server): service de découverte de chaîne (GET /api/channel) #1

Merged
kyuno merged 1 commit from feature/channel-discovery into main 2026-09-16 09:29:31 +02:00
8 changed files with 549 additions and 3 deletions

View file

@ -1,6 +1,7 @@
import fastifyStatic from '@fastify/static';
import Fastify, { type FastifyInstance } from 'fastify';
import { type ServerConfig, loadConfig } from './config.js';
import { channelRoutes } from './routes/channel.js';
import { healthRoutes } from './routes/health.js';
/**
@ -19,6 +20,7 @@ async function buildServer(config: ServerConfig): Promise<FastifyInstance> {
index: ['index.html'],
});
await app.register(healthRoutes);
await app.register(channelRoutes);
} catch (error) {
app.log.error({ err: error }, "Échec de l'enregistrement des plugins Fastify");
throw error;

View file

@ -0,0 +1,84 @@
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import {
ChannelDiscoveryService,
type ChannelSection,
InvalidChannelError,
} from '../services/ChannelDiscoveryService.js';
import { YtDlpError } from '../services/ytDlpRunner.js';
/** Paramètres de requête acceptés par `GET /api/channel`. */
interface ListChannelQuerystring {
/** Handle (`@nom`) ou URL de la chaîne à parcourir. */
channel: string;
/** Section à parcourir ; par défaut `videos`. */
section?: ChannelSection;
/** Mots-clés séparés par des virgules, tous requis dans le titre (ET). */
keywords?: string;
}
const querystringSchema = {
type: 'object',
required: ['channel'],
properties: {
channel: { type: 'string', minLength: 1, maxLength: 100 },
section: { type: 'string', enum: ['videos', 'streams'] },
keywords: { type: 'string', maxLength: 200 },
},
} as const;
/**
* Découpe une liste de mots-clés séparés par des virgules en tableau
* nettoyé (espaces superflus et entrées vides supprimés).
*
* @param keywords - Chaîne brute de mots-clés séparés par des virgules.
* @returns Le tableau de mots-clés nettoyés.
*/
function parseKeywords(keywords: string | undefined): string[] {
if (!keywords) {
return [];
}
return keywords
.split(',')
.map((keyword) => keyword.trim())
.filter((keyword) => keyword.length > 0);
}
/**
* Plugin Fastify exposant les routes de découverte de chaîne :
* `GET /api/channel` liste les vidéos/streams d'une chaîne YouTube.
*
* @param app - Instance Fastify sur laquelle enregistrer les routes.
*/
export const channelRoutes: FastifyPluginAsync = async (app: FastifyInstance): Promise<void> => {
const discoveryService = new ChannelDiscoveryService(app.log);
app.get<{ Querystring: ListChannelQuerystring }>(
'/api/channel',
{ schema: { querystring: querystringSchema } },
async (
request: FastifyRequest<{ Querystring: ListChannelQuerystring }>,
reply: FastifyReply,
) => {
const { channel, section = 'videos', keywords } = request.query;
try {
const videos = await discoveryService.listChannel(
channel,
section,
parseKeywords(keywords),
);
return { videos };
} catch (error) {
if (error instanceof InvalidChannelError) {
return reply.status(400).send({ error: error.message });
}
if (error instanceof YtDlpError) {
return reply
.status(502)
.send({ error: 'Échec de la récupération depuis YouTube (yt-dlp).' });
}
throw error;
}
},
);
};

View file

@ -0,0 +1,185 @@
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;
}
}

View file

@ -0,0 +1,68 @@
import { spawn } from 'node:child_process';
/**
* Erreur levée quand le processus yt-dlp se termine en échec (code de
* sortie non nul).
*/
export class YtDlpError extends Error {
/** Code de sortie du processus (`null` s'il a été tué par un signal). */
public readonly exitCode: number | null;
/** Sortie d'erreur standard du processus, pour diagnostic. */
public readonly stderr: string;
/**
* @param exitCode - Code de sortie du processus yt-dlp.
* @param stderr - Contenu de la sortie d'erreur standard du processus.
*/
public constructor(exitCode: number | null, stderr: string) {
super(`yt-dlp a échoué (code ${exitCode}) : ${stderr.slice(0, 500)}`);
this.name = 'YtDlpError';
this.exitCode = exitCode;
this.stderr = stderr;
}
}
/**
* Exécute yt-dlp avec les arguments fournis et retourne sa sortie standard
* complète. N'utilise jamais de shell : les arguments sont passés tels
* quels au processus (tableau d'arguments, pas de concaténation dans une
* chaîne), ce qui exclut toute injection de commande.
*
* Le chemin de l'exécutable est lu dans `process.env.YTDLP_PATH` à chaque
* appel (par défaut `"yt-dlp"`, résolu via PATH), pour rester surchargeable
* en test ou sur un environnement où le binaire n'est pas sur le PATH.
*
* @param args - Arguments passés à yt-dlp (jamais interpolés dans un shell).
* @param timeoutMs - Délai maximal avant l'arrêt forcé du processus.
* @returns La sortie standard (stdout) du processus.
* @throws {YtDlpError} Si le processus se termine avec un code non nul.
*/
export function runYtDlp(args: readonly string[], timeoutMs = 30_000): Promise<string> {
const ytDlpPath = process.env.YTDLP_PATH ?? 'yt-dlp';
return new Promise((resolve, reject) => {
const child = spawn(ytDlpPath, [...args], { shell: false, timeout: timeoutMs });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error: Error) => {
reject(error);
});
child.on('close', (exitCode: number | null) => {
if (exitCode !== 0) {
reject(new YtDlpError(exitCode, stderr));
return;
}
resolve(stdout);
});
});
}

View file

@ -0,0 +1,104 @@
import type { FastifyBaseLogger } from 'fastify';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../src/services/ytDlpRunner.js', () => ({
runYtDlp: vi.fn(),
}));
import {
ChannelDiscoveryService,
InvalidChannelError,
} from '../src/services/ChannelDiscoveryService.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
describe('ChannelDiscoveryService', () => {
it('parse les lignes yt-dlp et filtre par mots-clés', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
[
JSON.stringify({
id: 'a1',
title: 'Finale speed 2026',
url: 'https://youtu.be/a1',
duration: 120,
thumbnail: 'https://x/a1.jpg',
upload_date: '20260101',
}),
JSON.stringify({ id: 'a2', title: 'Interview backstage', url: 'https://youtu.be/a2' }),
'',
].join('\n'),
);
const service = new ChannelDiscoveryService(noopLogger);
const results = await service.listChannel('@worldclimbing', 'streams', ['speed']);
expect(results).toEqual([
{
id: 'a1',
title: 'Finale speed 2026',
url: 'https://youtu.be/a1',
thumbnailUrl: 'https://x/a1.jpg',
durationSeconds: 120,
uploadDate: '20260101',
},
]);
expect(runYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/streams'],
60_000,
);
});
it('accepte une URL de chaîne complète et ignore la casse des mots-clés', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
JSON.stringify({ id: 'c1', title: 'BIG Speed Final', url: 'https://youtu.be/c1' }),
);
const service = new ChannelDiscoveryService(noopLogger);
const results = await service.listChannel('https://www.youtube.com/@worldclimbing/', 'videos', [
'SPEED',
]);
expect(results).toHaveLength(1);
expect(runYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/videos'],
60_000,
);
});
it.each(['--exec=rm', '--exec', '-x', '.hidden'])(
'rejette "%s" comme identifiant de chaîne invalide sans jamais appeler yt-dlp',
async (channel) => {
const service = new ChannelDiscoveryService(noopLogger);
await expect(service.listChannel(channel, 'videos')).rejects.toBeInstanceOf(
InvalidChannelError,
);
expect(runYtDlp).not.toHaveBeenCalled();
},
);
it('ignore silencieusement les lignes JSON invalides ou incomplètes', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
[
'not json',
JSON.stringify({ id: 'b1' }),
JSON.stringify({ id: 'b2', title: 'ok', url: 'https://youtu.be/b2' }),
].join('\n'),
);
const service = new ChannelDiscoveryService(noopLogger);
const results = await service.listChannel('somehandle', 'videos');
expect(results).toEqual([
{
id: 'b2',
title: 'ok',
url: 'https://youtu.be/b2',
thumbnailUrl: null,
durationSeconds: null,
uploadDate: null,
},
]);
});
});

View file

@ -0,0 +1,69 @@
import Fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
return { ...actual, runYtDlp: vi.fn() };
});
import { channelRoutes } from '../src/routes/channel.js';
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
describe('GET /api/channel', () => {
it('retourne les vidéos correspondantes pour une chaîne valide', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
JSON.stringify({ id: 'v1', title: 'Test video', url: 'https://youtu.be/v1' }),
);
const app = Fastify();
await app.register(channelRoutes);
const response = await app.inject({
method: 'GET',
url: '/api/channel?channel=@test&section=videos',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
videos: [
{
id: 'v1',
title: 'Test video',
url: 'https://youtu.be/v1',
thumbnailUrl: null,
durationSeconds: null,
uploadDate: null,
},
],
});
});
it('renvoie 400 pour un identifiant de chaîne invalide', async () => {
const app = Fastify();
await app.register(channelRoutes);
const response = await app.inject({ method: 'GET', url: '/api/channel?channel=--exec' });
expect(response.statusCode).toBe(400);
});
it('renvoie 400 si le paramètre channel est manquant (validation de schéma)', async () => {
const app = Fastify();
await app.register(channelRoutes);
const response = await app.inject({ method: 'GET', url: '/api/channel' });
expect(response.statusCode).toBe(400);
});
it('renvoie 502 quand yt-dlp échoue', async () => {
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network error'));
const app = Fastify();
await app.register(channelRoutes);
const response = await app.inject({ method: 'GET', url: '/api/channel?channel=@test' });
expect(response.statusCode).toBe(502);
});
});

View file

@ -0,0 +1,25 @@
import { afterEach, describe, expect, it } from 'vitest';
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
describe('runYtDlp', () => {
afterEach(() => {
// biome-ignore lint/performance/noDelete: une assignation à `undefined` stockerait la chaîne "undefined" dans process.env.
delete process.env.YTDLP_PATH;
});
it('retourne stdout quand le processus réussit', async () => {
process.env.YTDLP_PATH = process.execPath;
const stdout = await runYtDlp(['-e', "console.log('bonjour')"]);
expect(stdout.trim()).toBe('bonjour');
});
it('lève YtDlpError quand le processus se termine en échec', async () => {
process.env.YTDLP_PATH = process.execPath;
await expect(runYtDlp(['-e', "console.error('boom'); process.exit(3)"])).rejects.toBeInstanceOf(
YtDlpError,
);
});
});

View file

@ -19,9 +19,12 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
- Fastify 5, TypeScript strict, ESM (`"type": "module"`, imports relatifs en `.js`, résolution `NodeNext`).
- `src/index.ts` : bootstrap (plugins, écoute, arrêt propre SIGINT/SIGTERM).
- `src/config.ts` : configuration runtime depuis l'environnement (`PORT`, `HOST`, `CLIENT_DIST_PATH`).
- `src/routes/` : routes Fastify enregistrées comme plugins (`health.ts` pour l'instant). Les futurs domaines métier (découverte de chaîne, file d'attente, téléchargement, notifications temps réel) suivront le même principe de service modulaire autonome, un fichier/dossier par domaine sous `src/`.
- `src/routes/` : routes Fastify enregistrées comme plugins (`health.ts`, `channel.ts`). Chaque domaine métier (découverte de chaîne, file d'attente, téléchargement, notifications temps réel à venir) suit le même principe : un plugin de route fin sous `src/routes/`, qui délègue à un service sous `src/services/`.
- `src/services/` :
- `ytDlpRunner.ts` : exécution bas niveau de yt-dlp (`node:child_process.spawn`, jamais de shell - arguments toujours passés en tableau, jamais concaténés dans une chaîne, pour exclure toute injection de commande).
- `ChannelDiscoveryService.ts` : liste les vidéos/streams d'une chaîne (`yt-dlp --flat-playlist -j`), filtre par mots-clés sur le titre. Valide strictement le handle/URL de chaîne en entrée (premier caractère forcément alphanumérique) et ajoute un séparateur `--` avant l'URL passée à yt-dlp, en défense en profondeur contre l'injection d'options.
- Le serveur sert aussi les fichiers statiques de `client/dist` via `@fastify/static`.
- Tests : Vitest (`server/test/`), via `app.inject()` (pas de serveur HTTP réel démarré en test).
- Tests : Vitest (`server/test/`). Deux niveaux : services testés directement (avec `ytDlpRunner` mocké pour `ChannelDiscoveryService` - seul `ytDlpRunner` lui-même spawn un vrai process, `node` en test), et routes testées via `app.inject()` (schéma de validation Fastify, mapping des erreurs en codes HTTP). Les deux niveaux comptent : un test de service avec un exemple d'entrée mal choisi avait laissé passer une regex de validation trop permissive (`--exec` accepté), repérée seulement par le test de route avec un autre exemple.
## Frontend (`client/`)
@ -29,6 +32,11 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
- Build via `build.mjs` (esbuild), pas d'outil de build plus lourd (Vite, webpack) : le script compile `src/app.ts` en bundle ESM, copie `src/index.html`, et compile/regroupe les `.scss` importés en un `app.css` voisin (plugin esbuild maison qui délègue au paquet `sass`).
- Pas encore de router : une seule vue (shell) pour l'instant. Un routeur sera introduit avec la première vraie vue (parcours de chaîne).
## API
- `GET /health` : statut du service (utilisé par le `HEALTHCHECK` Docker).
- `GET /api/channel?channel=<@handle|URL>&section=<videos|streams>&keywords=<a,b>` : liste les vidéos/streams d'une chaîne, filtrés par mots-clés (ET, insensible à la casse) sur le titre. `section` par défaut `videos`. Réponses : `200 { videos: [...] }`, `400` si `channel` est absent/invalide, `502` si yt-dlp échoue.
## Lint
Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, `client/src`) - pas d'ESLint/Prettier. `downloader/` (scripts legacy, non versionné) est explicitement exclu.
@ -41,4 +49,5 @@ Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, `
## Statut
Squelette d'infrastructure seulement : serveur qui répond, client placeholder, Docker, CI. Aucune des fonctionnalités métier (découverte de chaîne, file d'attente, téléchargement yt-dlp, notifications temps réel) n'est encore implémentée - elles arriveront par PR successives.
- Fait : infrastructure (serveur qui répond, client placeholder, Docker, CI), découverte de chaîne (`GET /api/channel`).
- À venir (PR successives) : file d'attente, téléchargement yt-dlp (vidéo/audio/miniature), notifications temps réel, vues frontend (parcours de chaîne, file d'attente).