feat(server): service de découverte de chaîne (GET /api/channel) #1
8 changed files with 549 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
84
server/src/routes/channel.ts
Normal file
84
server/src/routes/channel.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
185
server/src/services/ChannelDiscoveryService.ts
Normal file
185
server/src/services/ChannelDiscoveryService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
68
server/src/services/ytDlpRunner.ts
Normal file
68
server/src/services/ytDlpRunner.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
104
server/test/channelDiscoveryService.test.ts
Normal file
104
server/test/channelDiscoveryService.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
69
server/test/channelRoute.test.ts
Normal file
69
server/test/channelRoute.test.ts
Normal 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§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);
|
||||
});
|
||||
});
|
||||
25
server/test/ytDlpRunner.test.ts
Normal file
25
server/test/ytDlpRunner.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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=<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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue