Compare commits

...

2 commits

Author SHA1 Message Date
6881c89568 Merge pull request 'feat(server): file d'attente + téléchargement (vidéo/audio/miniature)' (#2) from feature/download-queue into feature/channel-discovery
Reviewed-on: #2
2026-09-16 09:29:59 +02:00
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
15 changed files with 1188 additions and 14 deletions

View file

@ -10,22 +10,30 @@ export interface ServerConfig {
readonly host: string;
/** Chemin absolu vers les fichiers statiques du client compilé. */
readonly clientDistPath: string;
/** Dossier racine des sorties de téléchargement (un sous-dossier par élément de file). */
readonly tmpDir: string;
/** Nombre maximal de téléchargements exécutés simultanément. */
readonly maxConcurrentDownloads: number;
}
/**
* Construit la configuration du serveur à partir des variables d'environnement,
* en appliquant des valeurs par défaut adaptées au développement local (le
* client compilé est alors supposé être un dossier `client/dist` voisin de
* client compilé et le dossier temporaire sont alors supposés voisins de
* `server/`).
*
* @returns La configuration runtime résolue.
*/
export function loadConfig(): ServerConfig {
const parsedPort = Number.parseInt(process.env.PORT ?? '3000', 10);
const parsedConcurrency = Number.parseInt(process.env.MAX_CONCURRENT_DOWNLOADS ?? '2', 10);
return {
port: Number.isNaN(parsedPort) ? 3000 : parsedPort,
host: process.env.HOST ?? '0.0.0.0',
clientDistPath: process.env.CLIENT_DIST_PATH ?? resolve(process.cwd(), '../client/dist'),
tmpDir: process.env.TMP_DIR ?? resolve(process.cwd(), 'tmp'),
maxConcurrentDownloads:
Number.isNaN(parsedConcurrency) || parsedConcurrency < 1 ? 2 : parsedConcurrency,
};
}

View file

@ -3,6 +3,7 @@ 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';
import { createQueueRoutes } from './routes/queue.js';
/**
* Construit et configure l'instance Fastify de l'application : fichiers
@ -21,6 +22,7 @@ async function buildServer(config: ServerConfig): Promise<FastifyInstance> {
});
await app.register(healthRoutes);
await app.register(channelRoutes);
await app.register(createQueueRoutes(config));
} catch (error) {
app.log.error({ err: error }, "Échec de l'enregistrement des plugins Fastify");
throw error;

141
server/src/routes/queue.ts Normal file
View file

@ -0,0 +1,141 @@
import { createReadStream } from 'node:fs';
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import type { ServerConfig } from '../config.js';
import { QueueService } from '../services/QueueService.js';
import type { AudioFormat, DownloadMode } from '../services/downloadPlan.js';
import { InvalidVideoUrlError, assertValidVideoUrl } from '../services/videoUrl.js';
/** Corps de requête accepté par `POST /api/queue`. */
interface CreateQueueItemBody {
/** URL de la vidéo YouTube à télécharger. */
url: string;
/** Mode de téléchargement demandé. */
mode: 'video' | 'audio' | 'thumbnail';
/** Mode vidéo uniquement : récupérer et embarquer les sous-titres fr/en. */
subtitles?: boolean;
/** Mode vidéo uniquement : embarquer la miniature. */
embedThumbnail?: boolean;
/** Mode audio uniquement : format de sortie (mp3 par défaut). */
audioFormat?: AudioFormat;
}
const createQueueItemSchema = {
type: 'object',
required: ['url', 'mode'],
properties: {
url: { type: 'string', minLength: 1, maxLength: 500 },
mode: { type: 'string', enum: ['video', 'audio', 'thumbnail'] },
subtitles: { type: 'boolean' },
embedThumbnail: { type: 'boolean' },
audioFormat: { type: 'string', enum: ['mp3', 'm4a', 'opus'] },
},
} as const;
interface QueueItemParams {
id: string;
}
const queueItemParamsSchema = {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string', minLength: 1 },
},
} as const;
/**
* Convertit le corps de requête `POST /api/queue` en mode de téléchargement
* typé, en appliquant les valeurs par défaut des options spécifiques à
* chaque mode.
*
* @param body - Corps de requête déjà validé par le schéma Fastify.
* @returns Le mode de téléchargement correspondant.
*/
function toDownloadMode(body: CreateQueueItemBody): DownloadMode {
switch (body.mode) {
case 'video':
return {
kind: 'video',
options: {
subtitles: body.subtitles ?? false,
embedThumbnail: body.embedThumbnail ?? false,
},
};
case 'audio':
return { kind: 'audio', format: body.audioFormat ?? 'mp3' };
case 'thumbnail':
return { kind: 'thumbnail' };
}
}
/**
* Construit le plugin Fastify exposant les routes de file d'attente :
* `POST /api/queue` (ajout), `GET /api/queue` (liste), `DELETE
* /api/queue/:id` (annulation) et `GET /api/queue/:id/file` (livraison du
* fichier terminé).
*
* @param config - Configuration runtime (dossier temporaire, concurrence).
* @returns Le plugin Fastify à enregistrer.
*/
export function createQueueRoutes(
config: Pick<ServerConfig, 'tmpDir' | 'maxConcurrentDownloads'>,
): FastifyPluginAsync {
return async (app: FastifyInstance): Promise<void> => {
const queueService = new QueueService(app.log, config.tmpDir, config.maxConcurrentDownloads);
app.post<{ Body: CreateQueueItemBody }>(
'/api/queue',
{ schema: { body: createQueueItemSchema } },
async (request: FastifyRequest<{ Body: CreateQueueItemBody }>, reply: FastifyReply) => {
let url: string;
try {
url = assertValidVideoUrl(request.body.url);
} catch (error) {
if (error instanceof InvalidVideoUrlError) {
return reply.status(400).send({ error: error.message });
}
throw error;
}
const item = queueService.enqueue(url, toDownloadMode(request.body));
return reply.status(201).send({ item });
},
);
app.get('/api/queue', async () => {
return { items: queueService.list() };
});
app.delete<{ Params: QueueItemParams }>(
'/api/queue/:id',
{ schema: { params: queueItemParamsSchema } },
async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => {
const cancelled = queueService.cancel(request.params.id);
if (!cancelled) {
return reply.status(404).send({ error: 'Élément introuvable ou déjà terminé.' });
}
return reply.status(204).send();
},
);
app.get<{ Params: QueueItemParams }>(
'/api/queue/:id/file',
{ schema: { params: queueItemParamsSchema } },
async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => {
const result = queueService.consumeCompletedFile(request.params.id);
if (!result) {
return reply
.status(404)
.send({ error: 'Fichier introuvable ou téléchargement non terminé.' });
}
reply.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(result.fileName)}"`,
);
reply.raw.once('finish', result.onSent);
return reply.send(createReadStream(result.path));
},
);
};
}

View file

@ -160,7 +160,7 @@ export class ChannelDiscoveryService {
// `--` 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);
stdout = await runYtDlp(['--flat-playlist', '-j', '--', url], { timeoutMs: 60_000 });
} catch (error) {
this.logger.error(
{ err: error, channel, section },

View file

@ -0,0 +1,321 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import {
type DownloadMode,
buildDownloadArgs,
expectedExtension,
parseProgress,
} from './downloadPlan.js';
import { runYtDlp } from './ytDlpRunner.js';
/** État d'avancement d'un élément de la file d'attente. */
export type QueueItemStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
/** Vue publique (immuable) d'un élément de la file d'attente. */
export interface QueueItem {
/** Identifiant unique de l'élément. */
readonly id: string;
/** URL de la vidéo source. */
readonly url: string;
/** Mode de téléchargement demandé. */
readonly mode: DownloadMode;
/** État d'avancement courant. */
readonly status: QueueItemStatus;
/** Progression estimée (0-100), au mieux selon la sortie yt-dlp. */
readonly progress: number;
/** Message d'erreur si `status` vaut `"failed"`, sinon `null`. */
readonly error: string | null;
/** Nom du fichier produit une fois `status` à `"completed"`, sinon `null`. */
readonly fileName: string | null;
/** Date de création de l'élément, au format ISO 8601. */
readonly createdAt: string;
}
/** Délai maximal accordé à un téléchargement avant arrêt forcé (30 minutes). */
const DOWNLOAD_TIMEOUT_MS = 30 * 60_000;
interface InternalQueueItem {
id: string;
url: string;
mode: DownloadMode;
status: QueueItemStatus;
progress: number;
error: string | null;
fileName: string | null;
createdAt: string;
outputDir: string;
abortController: AbortController | null;
}
/**
* Recherche, dans un dossier, le premier fichier dont l'extension
* correspond à celle attendue pour un mode de téléchargement donné.
*
* @param dir - Dossier à inspecter.
* @param expectedExt - Extension attendue (sans le point).
* @returns Le nom du fichier trouvé, ou `null` si aucun ne correspond.
*/
async function findOutputFile(dir: string, expectedExt: string): Promise<string | null> {
const entries = await readdir(dir);
const suffix = `.${expectedExt.toLowerCase()}`;
return entries.find((name) => name.toLowerCase().endsWith(suffix)) ?? null;
}
/**
* File d'attente de téléchargements : accepte des éléments, les exécute
* avec une concurrence bornée via yt-dlp, et expose leur progression.
*
* Chaque élément obtient son propre sous-dossier sous `baseDir`, supprimé
* après échec/annulation ou après livraison du fichier au client (voir
* {@link QueueService.consumeCompletedFile}).
*/
export class QueueService {
private readonly logger: FastifyBaseLogger;
private readonly baseDir: string;
private readonly maxConcurrent: number;
private readonly items = new Map<string, InternalQueueItem>();
private readonly pendingIds: string[] = [];
private runningCount = 0;
/**
* @param logger - Logger utilisé pour tracer le cycle de vie des éléments.
* @param baseDir - Dossier racine des sorties de téléchargement (un sous-dossier par élément).
* @param maxConcurrent - Nombre maximal de téléchargements exécutés simultanément.
*/
public constructor(logger: FastifyBaseLogger, baseDir: string, maxConcurrent: number) {
this.logger = logger;
this.baseDir = baseDir;
this.maxConcurrent = maxConcurrent;
}
/**
* Ajoute un élément à la file d'attente et déclenche son exécution dès
* qu'un emplacement de concurrence se libère.
*
* @param url - URL de la vidéo à télécharger (déjà validée par l'appelant).
* @param mode - Mode de téléchargement demandé.
* @returns L'élément créé, à l'état `"pending"`.
*/
public enqueue(url: string, mode: DownloadMode): QueueItem {
const id = randomUUID();
const item: InternalQueueItem = {
id,
url,
mode,
status: 'pending',
progress: 0,
error: null,
fileName: null,
createdAt: new Date().toISOString(),
outputDir: join(this.baseDir, id),
abortController: null,
};
this.items.set(id, item);
this.pendingIds.push(id);
this.logger.info({ id, url, mode: mode.kind }, "File d'attente : élément ajouté");
this.pump();
return this.toPublic(item);
}
/**
* @returns Tous les éléments de la file, dans leur ordre d'ajout.
*/
public list(): QueueItem[] {
return [...this.items.values()].map((item) => this.toPublic(item));
}
/**
* @param id - Identifiant de l'élément recherché.
* @returns L'élément correspondant, ou `null` s'il n'existe pas.
*/
public get(id: string): QueueItem | null {
const item = this.items.get(id);
return item ? this.toPublic(item) : null;
}
/**
* Annule un élément : retiré de la file s'il était en attente, ou son
* processus yt-dlp est arrêté s'il était en cours.
*
* @param id - Identifiant de l'élément à annuler.
* @returns `true` si l'élément existait et a pu être annulé, `false` sinon.
*/
public cancel(id: string): boolean {
const item = this.items.get(id);
if (!item) {
return false;
}
if (item.status === 'pending') {
const index = this.pendingIds.indexOf(id);
if (index !== -1) {
this.pendingIds.splice(index, 1);
}
item.status = 'cancelled';
this.logger.info({ id }, "File d'attente : élément en attente annulé");
return true;
}
if (item.status === 'running') {
item.abortController?.abort();
return true;
}
return false;
}
/**
* Retourne le chemin absolu du fichier produit par un élément terminé,
* et programme la suppression de son dossier de sortie une fois `onSent`
* résolu (le fichier a été livré, ou l'envoi a échoué). Le serveur ne
* conserve donc jamais durablement les fichiers : ils sont livrés au
* navigateur puis nettoyés.
*
* @param id - Identifiant de l'élément terminé.
* @returns Le chemin absolu du fichier et son nom, ou `null` si l'élément n'est pas terminé.
*/
public consumeCompletedFile(
id: string,
): { path: string; fileName: string; onSent: () => Promise<void> } | null {
const item = this.items.get(id);
if (!item || item.status !== 'completed' || !item.fileName) {
return null;
}
const path = join(item.outputDir, item.fileName);
const fileName = item.fileName;
return {
path,
fileName,
onSent: () =>
this.cleanupDir(item).catch((error: unknown) => {
this.logger.error(
{ err: error, id: item.id },
"File d'attente : échec du nettoyage après livraison",
);
}),
};
}
/**
* Lance autant d'éléments en attente que la concurrence disponible le
* permet.
*/
private pump(): void {
while (this.runningCount < this.maxConcurrent && this.pendingIds.length > 0) {
const id = this.pendingIds.shift();
if (!id) {
break;
}
const item = this.items.get(id);
if (!item) {
continue;
}
this.runningCount += 1;
this.runItem(item)
.catch((error: unknown) => {
this.logger.error(
{ err: error, id },
"File d'attente : erreur inattendue durant l'exécution",
);
})
.finally(() => {
this.runningCount -= 1;
this.pump();
});
}
}
/**
* Exécute le téléchargement d'un élément et met à jour son état au fil
* de l'avancement.
*
* @param item - Élément interne à exécuter.
*/
private async runItem(item: InternalQueueItem): Promise<void> {
item.status = 'running';
item.abortController = new AbortController();
this.logger.info(
{ id: item.id, url: item.url },
'File d’attente : démarrage du téléchargement',
);
try {
await mkdir(item.outputDir, { recursive: true });
const args = buildDownloadArgs(item.url, item.mode, item.outputDir);
await runYtDlp(args, {
timeoutMs: DOWNLOAD_TIMEOUT_MS,
signal: item.abortController.signal,
onStdoutLine: (line) => {
const progress = parseProgress(line);
if (progress !== null) {
item.progress = progress;
}
},
});
const fileName = await findOutputFile(item.outputDir, expectedExtension(item.mode));
if (!fileName) {
throw new Error('Fichier de sortie introuvable après le téléchargement');
}
item.fileName = fileName;
item.progress = 100;
item.status = 'completed';
this.logger.info({ id: item.id, fileName }, 'File d’attente : téléchargement terminé');
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
item.status = 'cancelled';
this.logger.info({ id: item.id }, 'File d’attente : téléchargement annulé');
} else {
item.status = 'failed';
item.error = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, id: item.id }, 'File d’attente : échec du téléchargement');
}
try {
await this.cleanupDir(item);
} catch (cleanupError) {
this.logger.error(
{ err: cleanupError, id: item.id },
"File d'attente : échec du nettoyage après erreur",
);
}
} finally {
item.abortController = null;
}
}
/**
* Supprime le dossier de sortie d'un élément.
*
* @param item - Élément dont le dossier de sortie doit être supprimé.
*/
private cleanupDir(item: InternalQueueItem): Promise<void> {
return rm(item.outputDir, { recursive: true, force: true });
}
/**
* Convertit un élément interne en vue publique immuable.
*
* @param item - Élément interne à exposer.
* @returns Une copie publique de l'élément.
*/
private toPublic(item: InternalQueueItem): QueueItem {
return {
id: item.id,
url: item.url,
mode: item.mode,
status: item.status,
progress: item.progress,
error: item.error,
fileName: item.fileName,
createdAt: item.createdAt,
};
}
}

View file

@ -0,0 +1,102 @@
/** Options du mode vidéo. */
export interface VideoModeOptions {
/** Récupère et embarque les sous-titres fr/en, quand disponibles. */
readonly subtitles: boolean;
/** Embarque la miniature dans le fichier vidéo. */
readonly embedThumbnail: boolean;
}
/** Formats audio courants proposés en sortie du mode audio. */
export type AudioFormat = 'mp3' | 'm4a' | 'opus';
/** Mode de téléchargement demandé pour un élément de la file d'attente. */
export type DownloadMode =
| { readonly kind: 'video'; readonly options: VideoModeOptions }
| { readonly kind: 'audio'; readonly format: AudioFormat }
| { readonly kind: 'thumbnail' };
/**
* Extension de fichier attendue pour le résultat d'un mode de téléchargement,
* utilisée pour retrouver le fichier produit par yt-dlp dans son dossier de
* sortie.
*
* @param mode - Mode de téléchargement concerné.
* @returns L'extension (sans le point) du fichier attendu.
*/
export function expectedExtension(mode: DownloadMode): string {
switch (mode.kind) {
case 'video':
return 'mp4';
case 'audio':
return mode.format;
case 'thumbnail':
return 'jpg';
}
}
/**
* Construit les arguments yt-dlp permettant de télécharger `url` selon le
* mode demandé, avec la sortie écrite dans `outputDir`. `--newline` force
* une ligne de progression par mise à jour (au lieu d'un retour chariot),
* nécessaire pour un suivi ligne par ligne fiable.
*
* @param url - URL de la vidéo à télécharger (déjà validée par l'appelant).
* @param mode - Mode de téléchargement demandé.
* @param outputDir - Dossier de sortie, propre à cet élément de file.
* @returns Le tableau d'arguments à passer à yt-dlp (jamais un shell).
*/
export function buildDownloadArgs(url: string, mode: DownloadMode, outputDir: string): string[] {
const args: string[] = ['--newline', '--no-playlist', '--add-metadata'];
switch (mode.kind) {
case 'video':
args.push(
'-f',
'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b',
'--merge-output-format',
'mp4',
);
if (mode.options.subtitles) {
args.push('--write-subs', '--write-auto-subs', '--sub-langs', 'fr,en', '--embed-subs');
}
if (mode.options.embedThumbnail) {
args.push('--embed-thumbnail');
}
args.push('-o', `${outputDir}/%(title)s.%(ext)s`);
break;
case 'audio':
args.push('-x', '--audio-format', mode.format);
args.push('-o', `${outputDir}/%(title)s.%(ext)s`);
break;
case 'thumbnail':
args.push('--skip-download', '--write-thumbnail', '--convert-thumbnails', 'jpg');
args.push('-o', `thumbnail:${outputDir}/%(title)s`);
break;
}
// `--` marque la fin des options : l'URL, déjà validée par l'appelant, ne
// pourra jamais être réinterprétée comme une option yt-dlp.
args.push('--', url);
return args;
}
const PROGRESS_PATTERN = /\[download]\s+(\d{1,3}(?:\.\d+)?)%/;
/**
* Extrait un pourcentage de progression d'une ligne de sortie yt-dlp (ex.
* `[download] 42.0% of ...`), si elle en contient un.
*
* @param line - Une ligne de la sortie standard de yt-dlp.
* @returns Le pourcentage (0-100), ou `null` si la ligne n'en contient pas.
*/
export function parseProgress(line: string): number | null {
const match = PROGRESS_PATTERN.exec(line);
if (!match?.[1]) {
return null;
}
const value = Number.parseFloat(match[1]);
if (Number.isNaN(value)) {
return null;
}
return Math.min(100, Math.max(0, value));
}

View file

@ -0,0 +1,39 @@
/**
* Erreur levée quand l'URL de vidéo fournie n'est pas une URL YouTube
* reconnue.
*/
export class InvalidVideoUrlError extends Error {
/**
* @param url - Valeur fournie par l'appelant, refusée telle quelle.
*/
public constructor(url: string) {
super(`URL de vidéo YouTube invalide : "${url}"`);
this.name = 'InvalidVideoUrlError';
}
}
// Identifiant de vidéo YouTube : toujours 11 caractères, alphabet
// [A-Za-z0-9_-]. N'accepte que youtube.com/watch|shorts|live et youtu.be -
// jamais un domaine arbitraire : yt-dlp gère des centaines de sites via un
// extracteur générique capable de récupérer une URL quelconque, ce qui en
// ferait un relais SSRF si on le laissait accepter n'importe quelle URL ici.
const VIDEO_ID = '[\\w-]{11}';
const VIDEO_URL_PATTERN = new RegExp(
`^https://(www\\.)?(youtube\\.com/watch\\?v=${VIDEO_ID}(&\\S*)?|youtube\\.com/shorts/${VIDEO_ID}(\\?\\S*)?|youtube\\.com/live/${VIDEO_ID}(\\?\\S*)?|youtu\\.be/${VIDEO_ID}(\\?\\S*)?)$`,
);
/**
* Valide qu'une chaîne est bien une URL de vidéo YouTube reconnue
* (`youtube.com/watch`, `/shorts/`, `/live/` ou `youtu.be/...`).
*
* @param url - URL fournie par l'appelant.
* @returns L'URL, débarrassée des espaces superflus.
* @throws {InvalidVideoUrlError} Si `url` ne correspond à aucun format accepté.
*/
export function assertValidVideoUrl(url: string): string {
const trimmed = url.trim();
if (!VIDEO_URL_PATTERN.test(trimmed)) {
throw new InvalidVideoUrlError(url);
}
return trimmed;
}

View file

@ -22,6 +22,51 @@ export class YtDlpError extends Error {
}
}
/** Options d'exécution de {@link runYtDlp}. */
export interface RunYtDlpOptions {
/** Délai maximal avant l'arrêt forcé du processus (30s par défaut). */
readonly timeoutMs?: number;
/** Rappelé pour chaque ligne complète reçue sur stdout, au fil de l'exécution. */
readonly onStdoutLine?: (line: string) => void;
/** Signal d'annulation : le processus est tué si ce signal se déclenche. */
readonly signal?: AbortSignal;
}
/**
* Découpe un flux de sortie en lignes complètes au fil des chunks reçus,
* en conservant le fragment incomplet final entre deux appels.
*/
class LineSplitter {
private buffer = '';
/**
* @param onLine - Rappelé pour chaque ligne complète extraite du chunk.
*/
public constructor(private readonly onLine: (line: string) => void) {}
/**
* Ajoute un nouveau chunk de texte et émet toute ligne désormais complète.
*
* @param chunk - Fragment de texte reçu (peut contenir 0, 1 ou plusieurs sauts de ligne).
*/
public push(chunk: string): void {
this.buffer += chunk;
const lines = this.buffer.split('\n');
this.buffer = lines.pop() ?? '';
for (const line of lines) {
this.onLine(line.replace(/\r$/, ''));
}
}
/** Émet le fragment restant, s'il y en a un, en fin de flux. */
public flush(): void {
if (this.buffer.length > 0) {
this.onLine(this.buffer.replace(/\r$/, ''));
this.buffer = '';
}
}
}
/**
* 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
@ -33,21 +78,25 @@ export class YtDlpError extends Error {
* 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.
* @param options - Options d'exécution (délai, suivi ligne par ligne, annulation).
* @returns La sortie standard (stdout) complète 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> {
export function runYtDlp(args: readonly string[], options: RunYtDlpOptions = {}): Promise<string> {
const ytDlpPath = process.env.YTDLP_PATH ?? 'yt-dlp';
const { timeoutMs = 30_000, onStdoutLine, signal } = options;
return new Promise((resolve, reject) => {
const child = spawn(ytDlpPath, [...args], { shell: false, timeout: timeoutMs });
const child = spawn(ytDlpPath, [...args], { shell: false, timeout: timeoutMs, signal });
let stdout = '';
let stderr = '';
const splitter = onStdoutLine ? new LineSplitter(onStdoutLine) : null;
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
const text = chunk.toString('utf8');
stdout += text;
splitter?.push(text);
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
@ -58,6 +107,12 @@ export function runYtDlp(args: readonly string[], timeoutMs = 30_000): Promise<s
});
child.on('close', (exitCode: number | null) => {
splitter?.flush();
if (signal?.aborted) {
reject(new DOMException('yt-dlp annulé', 'AbortError'));
return;
}
if (exitCode !== 0) {
reject(new YtDlpError(exitCode, stderr));
return;

View file

@ -0,0 +1,192 @@
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import { afterEach, beforeEach, 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 { QueueService } from '../src/services/QueueService.js';
import type { DownloadMode } from '../src/services/downloadPlan.js';
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
const videoMode: DownloadMode = {
kind: 'video',
options: { subtitles: false, embedThumbnail: false },
};
/** Extrait le dossier de sortie passé à yt-dlp via `-o` (voir downloadPlan.ts). */
function extractOutputDir(args: readonly string[]): string {
const oIndex = args.indexOf('-o');
const template = args[oIndex + 1] ?? '';
const withoutPrefix = template.replace(/^thumbnail:/, '');
return withoutPrefix.slice(0, withoutPrefix.lastIndexOf('/'));
}
/** Attend qu'une condition devienne vraie, en pollant (pas de timers factices ici : vraie I/O fichier en jeu). */
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('waitFor: timeout');
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
let baseDir: string;
beforeEach(async () => {
baseDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-test-'));
vi.mocked(runYtDlp).mockReset();
});
afterEach(async () => {
await rm(baseDir, { recursive: true, force: true });
});
describe('QueueService', () => {
it('exécute un élément jusqu’à complétion et retrouve le fichier produit', async () => {
vi.mocked(runYtDlp).mockImplementation(async (args, options) => {
options?.onStdoutLine?.('[download] 50.0% of 10.00MiB');
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
return '';
});
const service = new QueueService(noopLogger, baseDir, 2);
const item = service.enqueue(url, videoMode);
await waitFor(() => service.get(item.id)?.status === 'completed');
const final = service.get(item.id);
expect(final?.fileName).toBe('My Video.mp4');
expect(final?.progress).toBe(100);
expect(final?.error).toBeNull();
});
it('respecte la concurrence maximale configurée', async () => {
let concurrentCount = 0;
let maxObservedConcurrent = 0;
const releaseFns: Array<() => void> = [];
vi.mocked(runYtDlp).mockImplementation(async (args) => {
concurrentCount += 1;
maxObservedConcurrent = Math.max(maxObservedConcurrent, concurrentCount);
await new Promise<void>((resolve) => releaseFns.push(resolve));
concurrentCount -= 1;
await writeFile(join(extractOutputDir(args), 'v.mp4'), 'x');
return '';
});
const service = new QueueService(noopLogger, baseDir, 2);
const first = service.enqueue(url, videoMode);
const second = service.enqueue(url, videoMode);
const third = service.enqueue(url, videoMode);
const items = [first, second, third];
await waitFor(() => releaseFns.length === 2);
expect(maxObservedConcurrent).toBe(2);
expect(service.get(third.id)?.status).toBe('pending');
releaseFns[0]?.();
await waitFor(() => releaseFns.length === 3);
releaseFns[1]?.();
releaseFns[2]?.();
await waitFor(() => items.every((item) => service.get(item.id)?.status === 'completed'));
expect(maxObservedConcurrent).toBeLessThanOrEqual(2);
});
it('marque un élément en échec et nettoie son dossier de sortie quand yt-dlp échoue', async () => {
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network unreachable'));
const service = new QueueService(noopLogger, baseDir, 2);
const item = service.enqueue(url, videoMode);
await waitFor(() => service.get(item.id)?.status === 'failed');
expect(service.get(item.id)?.error).toContain('network unreachable');
await expect(stat(join(baseDir, item.id))).rejects.toThrow();
});
it('annule un élément encore en attente sans jamais appeler yt-dlp pour lui', async () => {
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); // occupe le seul slot indéfiniment
const service = new QueueService(noopLogger, baseDir, 1);
service.enqueue(url, videoMode);
const second = service.enqueue(url, videoMode);
expect(service.get(second.id)?.status).toBe('pending');
expect(service.cancel(second.id)).toBe(true);
expect(service.get(second.id)?.status).toBe('cancelled');
// Le premier élément appelle yt-dlp de façon asynchrone (après son
// `mkdir`) : on attend cet appel plutôt que de supposer qu'il a déjà
// eu lieu au moment où enqueue() rend la main.
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
});
it("annule un élément en cours en déclenchant son signal d'annulation", async () => {
vi.mocked(runYtDlp).mockImplementation(
(_args, options) =>
new Promise((_resolve, reject) => {
const rejectAborted = () => {
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
};
// Le signal peut déjà être annulé si cancel() a été appelé avant
// que ce mock ne soit invoqué (après le `mkdir` de runItem) : un
// listener "abort" posé après coup ne se déclencherait jamais.
if (options?.signal?.aborted) {
rejectAborted();
return;
}
options?.signal?.addEventListener('abort', rejectAborted);
}),
);
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode);
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
expect(service.cancel(item.id)).toBe(true);
await waitFor(() => service.get(item.id)?.status === 'cancelled');
});
it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => {
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {}));
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode);
expect(service.consumeCompletedFile('inconnu')).toBeNull();
expect(service.consumeCompletedFile(item.id)).toBeNull(); // encore en cours
});
it('consumeCompletedFile expose le fichier puis onSent nettoie le dossier', async () => {
vi.mocked(runYtDlp).mockImplementation(async (args) => {
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
return '';
});
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode);
await waitFor(() => service.get(item.id)?.status === 'completed');
const result = service.consumeCompletedFile(item.id);
if (!result) {
throw new Error('expected consumeCompletedFile to return a result');
}
expect(result.fileName).toBe('My Video.mp4');
expect(result.path).toBe(join(baseDir, item.id, 'My Video.mp4'));
await result.onSent();
await expect(stat(dirname(result.path))).rejects.toThrow();
});
});

View file

@ -45,7 +45,7 @@ describe('ChannelDiscoveryService', () => {
]);
expect(runYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/streams'],
60_000,
{ timeoutMs: 60_000 },
);
});
@ -62,7 +62,7 @@ describe('ChannelDiscoveryService', () => {
expect(results).toHaveLength(1);
expect(runYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/videos'],
60_000,
{ timeoutMs: 60_000 },
);
});

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import {
type DownloadMode,
buildDownloadArgs,
expectedExtension,
parseProgress,
} from '../src/services/downloadPlan.js';
describe('expectedExtension', () => {
it('retourne mp4 pour le mode vidéo', () => {
const mode: DownloadMode = {
kind: 'video',
options: { subtitles: false, embedThumbnail: false },
};
expect(expectedExtension(mode)).toBe('mp4');
});
it('retourne le format demandé pour le mode audio', () => {
expect(expectedExtension({ kind: 'audio', format: 'opus' })).toBe('opus');
});
it('retourne jpg pour le mode miniature', () => {
expect(expectedExtension({ kind: 'thumbnail' })).toBe('jpg');
});
});
describe('buildDownloadArgs', () => {
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
const outputDir = '/tmp/abc123';
it('mode vidéo sans options : pas de sous-titres ni de miniature embarquée', () => {
const args = buildDownloadArgs(
url,
{ kind: 'video', options: { subtitles: false, embedThumbnail: false } },
outputDir,
);
expect(args).toEqual([
'--newline',
'--no-playlist',
'--add-metadata',
'-f',
'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b',
'--merge-output-format',
'mp4',
'-o',
`${outputDir}/%(title)s.%(ext)s`,
'--',
url,
]);
});
it('mode vidéo avec sous-titres et miniature embarquée', () => {
const args = buildDownloadArgs(
url,
{ kind: 'video', options: { subtitles: true, embedThumbnail: true } },
outputDir,
);
expect(args).toContain('--embed-subs');
expect(args).toContain('--embed-thumbnail');
expect(args).toEqual(
expect.arrayContaining(['--write-subs', '--write-auto-subs', '--sub-langs', 'fr,en']),
);
});
it('mode audio : extraction au format demandé', () => {
const args = buildDownloadArgs(url, { kind: 'audio', format: 'opus' }, outputDir);
expect(args).toEqual([
'--newline',
'--no-playlist',
'--add-metadata',
'-x',
'--audio-format',
'opus',
'-o',
`${outputDir}/%(title)s.%(ext)s`,
'--',
url,
]);
});
it('mode miniature : téléchargement de la vidéo ignoré', () => {
const args = buildDownloadArgs(url, { kind: 'thumbnail' }, outputDir);
expect(args).toEqual([
'--newline',
'--no-playlist',
'--add-metadata',
'--skip-download',
'--write-thumbnail',
'--convert-thumbnails',
'jpg',
'-o',
`thumbnail:${outputDir}/%(title)s`,
'--',
url,
]);
});
it("place toujours l'URL après un séparateur `--`, quel que soit le mode", () => {
for (const mode of [
{ kind: 'video', options: { subtitles: false, embedThumbnail: false } },
{ kind: 'audio', format: 'mp3' },
{ kind: 'thumbnail' },
] satisfies DownloadMode[]) {
const args = buildDownloadArgs(url, mode, outputDir);
const separatorIndex = args.indexOf('--');
expect(separatorIndex).toBeGreaterThanOrEqual(0);
expect(args[separatorIndex + 1]).toBe(url);
expect(args).toHaveLength(separatorIndex + 2);
}
});
});
describe('parseProgress', () => {
it('extrait un pourcentage entier', () => {
expect(parseProgress('[download] 42% of 10.00MiB at 1.00MiB/s ETA 00:05')).toBe(42);
});
it('extrait un pourcentage décimal', () => {
expect(parseProgress('[download] 99.5% of 10.00MiB')).toBe(99.5);
});
it('retourne null pour une ligne sans progression', () => {
expect(parseProgress('[info] Writing video metadata as JSON')).toBeNull();
});
it('borne le résultat entre 0 et 100', () => {
expect(parseProgress('[download] 100.0% of 10.00MiB')).toBe(100);
});
});

View file

@ -0,0 +1,111 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify from 'fastify';
import { afterEach, beforeEach, 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(() => new Promise(() => {})) };
});
import { createQueueRoutes } from '../src/routes/queue.js';
const validUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-route-test-'));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe('routes de file d’attente', () => {
it('POST /api/queue ajoute un élément valide et le retourne', async () => {
const app = Fastify();
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
const response = await app.inject({
method: 'POST',
url: '/api/queue',
payload: { url: validUrl, mode: 'audio', audioFormat: 'opus' },
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.item).toMatchObject({
url: validUrl,
mode: { kind: 'audio', format: 'opus' },
status: expect.stringMatching(/pending|running/),
});
});
it('POST /api/queue renvoie 400 pour une URL non-YouTube', async () => {
const app = Fastify();
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
const response = await app.inject({
method: 'POST',
url: '/api/queue',
payload: { url: 'https://evil.com/x', mode: 'video' },
});
expect(response.statusCode).toBe(400);
});
it('POST /api/queue renvoie 400 pour un mode invalide (validation de schéma)', async () => {
const app = Fastify();
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
const response = await app.inject({
method: 'POST',
url: '/api/queue',
payload: { url: validUrl, mode: 'video-4k-ultra' },
});
expect(response.statusCode).toBe(400);
});
it('GET /api/queue liste les éléments ajoutés', async () => {
const app = Fastify();
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
await app.inject({
method: 'POST',
url: '/api/queue',
payload: { url: validUrl, mode: 'thumbnail' },
});
const response = await app.inject({ method: 'GET', url: '/api/queue' });
expect(response.statusCode).toBe(200);
expect(response.json().items).toHaveLength(1);
});
it('DELETE /api/queue/:id renvoie 404 pour un identifiant inconnu', async () => {
const app = Fastify();
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
const response = await app.inject({ method: 'DELETE', url: '/api/queue/inconnu' });
expect(response.statusCode).toBe(404);
});
it('GET /api/queue/:id/file renvoie 404 tant que le téléchargement n’est pas terminé', async () => {
const app = Fastify();
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
const created = await app.inject({
method: 'POST',
url: '/api/queue',
payload: { url: validUrl, mode: 'video' },
});
const { id } = created.json().item;
const response = await app.inject({ method: 'GET', url: `/api/queue/${id}/file` });
expect(response.statusCode).toBe(404);
});
});

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { InvalidVideoUrlError, assertValidVideoUrl } from '../src/services/videoUrl.js';
describe('assertValidVideoUrl', () => {
it.each([
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
'https://www.youtube.com/live/dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ?t=30',
])('accepte "%s"', (url) => {
expect(assertValidVideoUrl(url)).toBe(url);
});
it.each([
'',
'not a url',
'ftp://youtube.com/watch?v=dQw4w9WgXcQ',
'https://evil.com/watch?v=dQw4w9WgXcQ',
'https://youtube.com.evil.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com/watch?v=short',
'https://www.youtube.com/@somechannel',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ; rm -rf /',
'--exec=touch /tmp/pwned https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'file:///etc/passwd',
])('rejette "%s"', (url) => {
expect(() => assertValidVideoUrl(url)).toThrow(InvalidVideoUrlError);
});
});

View file

@ -22,4 +22,36 @@ describe('runYtDlp', () => {
YtDlpError,
);
});
it('appelle onStdoutLine pour chaque ligne, sans attendre la fin du processus', async () => {
process.env.YTDLP_PATH = process.execPath;
const lines: string[] = [];
await runYtDlp(['-e', "console.log('a'); console.log('b'); console.log('c')"], {
onStdoutLine: (line) => lines.push(line),
});
expect(lines).toEqual(['a', 'b', 'c']);
});
it('émet le fragment final même sans saut de ligne terminal', async () => {
process.env.YTDLP_PATH = process.execPath;
const lines: string[] = [];
await runYtDlp(['-e', "process.stdout.write('sans-newline')"], {
onStdoutLine: (line) => lines.push(line),
});
expect(lines).toEqual(['sans-newline']);
});
it("rejette avec une AbortError quand le signal d'annulation se déclenche", async () => {
process.env.YTDLP_PATH = process.execPath;
const controller = new AbortController();
const promise = runYtDlp(['-e', 'setTimeout(() => {}, 5000)'], { signal: controller.signal });
controller.abort();
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
});
});

View file

@ -19,12 +19,15 @@ 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`, `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/routes/` : routes Fastify enregistrées comme plugins (`health.ts`, `channel.ts`, `queue.ts`). Chaque domaine métier 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).
- `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). Supporte le suivi ligne par ligne de stdout (`onStdoutLine`, pour la progression) et l'annulation via `AbortSignal`.
- `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.
- `videoUrl.ts` : valide qu'une URL de vidéo est bien sur `youtube.com`/`youtu.be` (formats `watch`/`shorts`/`live`/court) avant de la transmettre à yt-dlp. N'accepte aucun autre domaine : yt-dlp sait suivre des centaines de sites via un extracteur générique capable de récupérer une URL quelconque, ce qui en ferait un relais SSRF si cette validation n'existait pas.
- `downloadPlan.ts` : fonctions pures construisant les arguments yt-dlp selon le mode (vidéo/audio/miniature) et parsant la progression (`[download] 42.0% ...`) depuis stdout. Toujours un séparateur `--` avant l'URL (même défense qu'au-dessus).
- `QueueService.ts` : file d'attente en mémoire, exécution avec concurrence bornée (`MAX_CONCURRENT_DOWNLOADS`, défaut 2). Chaque élément a son propre sous-dossier sous `TMP_DIR`, supprimé après échec/annulation ou après livraison du fichier au client (`consumeCompletedFile` -> `onSent`) : le serveur ne conserve jamais durablement les fichiers.
- Le serveur sert aussi les fichiers statiques de `client/dist` via `@fastify/static`.
- 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.
- Tests : Vitest (`server/test/`). Trois niveaux : fonctions pures testées directement (`downloadPlan.ts`, `videoUrl.ts`), services testés avec `ytDlpRunner` mocké mais un vrai système de fichiers temporaire (`QueueService.test.ts`, plus réaliste qu'un mock fs), et routes testées via `app.inject()`. Les trois 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 (voir `channelDiscoveryService.test.ts`/`channelRoute.test.ts`).
## Frontend (`client/`)
@ -36,6 +39,10 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
- `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.
- `POST /api/queue` : ajoute un élément (`{ url, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }`). `201 { item }`, `400` si l'URL n'est pas une URL de vidéo YouTube reconnue.
- `GET /api/queue` : liste tous les éléments (`{ items: [...] }`), avec leur `status` (`pending`/`running`/`completed`/`failed`/`cancelled`), `progress` (0-100, best-effort) et `error`.
- `DELETE /api/queue/:id` : annule un élément (`204`) - retiré de la file s'il était en attente, processus yt-dlp arrêté s'il était en cours. `404` si inconnu ou déjà terminé.
- `GET /api/queue/:id/file` : télécharge le fichier produit par un élément `completed` (`Content-Disposition: attachment`). Le dossier de sortie de l'élément est supprimé juste après l'envoi. `404` tant que l'élément n'est pas `completed`.
## Lint
@ -49,5 +56,5 @@ Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, `
## Statut
- 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).
- Fait : infrastructure (serveur qui répond, client placeholder, Docker, CI), découverte de chaîne (`GET /api/channel`), file d'attente + téléchargement vidéo/audio/miniature avec concurrence bornée (`/api/queue*`).
- À venir (PR successives) : notifications temps réel (WebSocket, progression poussée plutôt que sondage de `GET /api/queue`), vues frontend (parcours de chaîne, file d'attente).