From 0e78b44a55fc824965c429989f8fadcb556d7d0f Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 08:05:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20service=20de=20d=C3=A9couverte?= =?UTF-8?q?=20de=20cha=C3=AEne=20(GET=20/api/channel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/index.ts | 2 + server/src/routes/channel.ts | 84 ++++++++ .../src/services/ChannelDiscoveryService.ts | 185 ++++++++++++++++++ server/src/services/ytDlpRunner.ts | 68 +++++++ server/test/channelDiscoveryService.test.ts | 104 ++++++++++ server/test/channelRoute.test.ts | 69 +++++++ server/test/ytDlpRunner.test.ts | 25 +++ spec/architecture.md | 15 +- 8 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 server/src/routes/channel.ts create mode 100644 server/src/services/ChannelDiscoveryService.ts create mode 100644 server/src/services/ytDlpRunner.ts create mode 100644 server/test/channelDiscoveryService.test.ts create mode 100644 server/test/channelRoute.test.ts create mode 100644 server/test/ytDlpRunner.test.ts diff --git a/server/src/index.ts b/server/src/index.ts index 50ae42c..6f6093d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { 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; diff --git a/server/src/routes/channel.ts b/server/src/routes/channel.ts new file mode 100644 index 0000000..653df5e --- /dev/null +++ b/server/src/routes/channel.ts @@ -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 => { + 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; + } + }, + ); +}; diff --git a/server/src/services/ChannelDiscoveryService.ts b/server/src/services/ChannelDiscoveryService.ts new file mode 100644 index 0000000..2407508 --- /dev/null +++ b/server/src/services/ChannelDiscoveryService.ts @@ -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; + 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 { + 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; + } +} diff --git a/server/src/services/ytDlpRunner.ts b/server/src/services/ytDlpRunner.ts new file mode 100644 index 0000000..5738857 --- /dev/null +++ b/server/src/services/ytDlpRunner.ts @@ -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 { + 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); + }); + }); +} diff --git a/server/test/channelDiscoveryService.test.ts b/server/test/channelDiscoveryService.test.ts new file mode 100644 index 0000000..9cb9b7f --- /dev/null +++ b/server/test/channelDiscoveryService.test.ts @@ -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, + }, + ]); + }); +}); diff --git a/server/test/channelRoute.test.ts b/server/test/channelRoute.test.ts new file mode 100644 index 0000000..06cad34 --- /dev/null +++ b/server/test/channelRoute.test.ts @@ -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(); + 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§ion=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); + }); +}); diff --git a/server/test/ytDlpRunner.test.ts b/server/test/ytDlpRunner.test.ts new file mode 100644 index 0000000..a7a3d99 --- /dev/null +++ b/server/test/ytDlpRunner.test.ts @@ -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, + ); + }); +}); diff --git a/spec/architecture.md b/spec/architecture.md index 401ef36..83c466f 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -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>§ion=&keywords=` : 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).