diff --git a/server/src/config.ts b/server/src/config.ts index 956cb26..64433b4 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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, }; } diff --git a/server/src/index.ts b/server/src/index.ts index 6f6093d..8f764be 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { }); 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; diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts new file mode 100644 index 0000000..fbfad15 --- /dev/null +++ b/server/src/routes/queue.ts @@ -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, +): FastifyPluginAsync { + return async (app: FastifyInstance): Promise => { + 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)); + }, + ); + }; +} diff --git a/server/src/services/ChannelDiscoveryService.ts b/server/src/services/ChannelDiscoveryService.ts index 2407508..7a0de1d 100644 --- a/server/src/services/ChannelDiscoveryService.ts +++ b/server/src/services/ChannelDiscoveryService.ts @@ -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 }, diff --git a/server/src/services/QueueService.ts b/server/src/services/QueueService.ts new file mode 100644 index 0000000..f8ac01e --- /dev/null +++ b/server/src/services/QueueService.ts @@ -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 { + 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(); + 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 } | 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 { + 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 { + 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, + }; + } +} diff --git a/server/src/services/downloadPlan.ts b/server/src/services/downloadPlan.ts new file mode 100644 index 0000000..ac0a563 --- /dev/null +++ b/server/src/services/downloadPlan.ts @@ -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)); +} diff --git a/server/src/services/videoUrl.ts b/server/src/services/videoUrl.ts new file mode 100644 index 0000000..c811a74 --- /dev/null +++ b/server/src/services/videoUrl.ts @@ -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; +} diff --git a/server/src/services/ytDlpRunner.ts b/server/src/services/ytDlpRunner.ts index 5738857..d921d41 100644 --- a/server/src/services/ytDlpRunner.ts +++ b/server/src/services/ytDlpRunner.ts @@ -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 { +export function runYtDlp(args: readonly string[], options: RunYtDlpOptions = {}): Promise { 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 { + splitter?.flush(); + + if (signal?.aborted) { + reject(new DOMException('yt-dlp annulé', 'AbortError')); + return; + } if (exitCode !== 0) { reject(new YtDlpError(exitCode, stderr)); return; diff --git a/server/test/QueueService.test.ts b/server/test/QueueService.test.ts new file mode 100644 index 0000000..f489f05 --- /dev/null +++ b/server/test/QueueService.test.ts @@ -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(); + 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 { + 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((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(); + }); +}); diff --git a/server/test/channelDiscoveryService.test.ts b/server/test/channelDiscoveryService.test.ts index 9cb9b7f..8f8da76 100644 --- a/server/test/channelDiscoveryService.test.ts +++ b/server/test/channelDiscoveryService.test.ts @@ -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 }, ); }); diff --git a/server/test/downloadPlan.test.ts b/server/test/downloadPlan.test.ts new file mode 100644 index 0000000..f67c1f1 --- /dev/null +++ b/server/test/downloadPlan.test.ts @@ -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); + }); +}); diff --git a/server/test/queueRoute.test.ts b/server/test/queueRoute.test.ts new file mode 100644 index 0000000..084aeda --- /dev/null +++ b/server/test/queueRoute.test.ts @@ -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(); + 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); + }); +}); diff --git a/server/test/videoUrl.test.ts b/server/test/videoUrl.test.ts new file mode 100644 index 0000000..04a7c97 --- /dev/null +++ b/server/test/videoUrl.test.ts @@ -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); + }); +}); diff --git a/server/test/ytDlpRunner.test.ts b/server/test/ytDlpRunner.test.ts index a7a3d99..a270d94 100644 --- a/server/test/ytDlpRunner.test.ts +++ b/server/test/ytDlpRunner.test.ts @@ -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' }); + }); }); diff --git a/spec/architecture.md b/spec/architecture.md index 83c466f..43fbb67 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -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>§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. +- `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).