From 63ef42cc203161e6f2dbb0977346223d727f587d Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 09:02:20 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(server):=20file=20d'attente=20+=20t?= =?UTF-8?q?=C3=A9l=C3=A9chargement=20(vid=C3=A9o/audio/miniature)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/config.ts | 10 +- server/src/index.ts | 2 + server/src/routes/queue.ts | 141 ++++++++ .../src/services/ChannelDiscoveryService.ts | 2 +- server/src/services/QueueService.ts | 321 ++++++++++++++++++ server/src/services/downloadPlan.ts | 102 ++++++ server/src/services/videoUrl.ts | 39 +++ server/src/services/ytDlpRunner.ts | 65 +++- server/test/QueueService.test.ts | 192 +++++++++++ server/test/channelDiscoveryService.test.ts | 4 +- server/test/downloadPlan.test.ts | 133 ++++++++ server/test/queueRoute.test.ts | 111 ++++++ server/test/videoUrl.test.ts | 31 ++ server/test/ytDlpRunner.test.ts | 32 ++ spec/architecture.md | 17 +- 15 files changed, 1188 insertions(+), 14 deletions(-) create mode 100644 server/src/routes/queue.ts create mode 100644 server/src/services/QueueService.ts create mode 100644 server/src/services/downloadPlan.ts create mode 100644 server/src/services/videoUrl.ts create mode 100644 server/test/QueueService.test.ts create mode 100644 server/test/downloadPlan.test.ts create mode 100644 server/test/queueRoute.test.ts create mode 100644 server/test/videoUrl.test.ts 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). From 725c5a5a7f1bfbfdbf2a5ed6ff6b5dfdb200a8a0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 09:28:49 +0200 Subject: [PATCH 2/3] feat(client): frontend (formulaire d'ajout + file d'attente) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interface web complète : formulaire d'ajout (URL directe ou recherche de chaîne) et panneau de file d'attente avec sondage de la progression. - views/downloadForm/ : sélecteur de mode (vidéo/audio/miniature) partagé, ajout direct par URL, recherche de chaîne (GET /api/channel) avec ajout par résultat. Construction DOM via createElement/textContent (jamais d'innerHTML avec du texte venant de YouTube), pas juste par prudence : ça évite aussi tout risque d'injection depuis un titre de vidéo. - views/queuePanel/ : sonde GET /api/queue toutes les 1,5s, affiche statut/ progression/erreur par élément, annulation et lien de téléchargement natif (, pas de fetch+blob). - api/ : clients fetch typés (channelApi, queueApi), chaque await dans son propre try/catch avec message d'erreur adapté (réseau vs HTTP vs JSON). - QueueItem gagne un champ title optionnel (purement informatif, jamais utilisé pour construire la commande yt-dlp) pour un affichage plus parlant qu'une URL brute. - client/tsconfig.json + build.mjs : cible ES2022 (nécessaire pour Error.cause, jusque-là ES2020). - Vitest ajouté côté client (fonctions pures de src/format.ts ; le câblage DOM est vérifié manuellement, pas de jsdom pour l'instant). Deux bugs réels trouvés et corrigés en testant dans un vrai navigateur (pas seulement en lisant le code) : - CSS : un attribut `hidden` sur un conteneur `display: flex` se faisait écraser par cette règle (même spécificité, la feuille de l'auteur gagne) - les deux panneaux d'options restaient visibles en même temps. - server/src/routes/channel.ts : ne mappait vers 502 que les YtDlpError (code de sortie non nul), pas les erreurs brutes de spawn (ex. binaire introuvable - ENOENT), qui remontaient donc en 500 générique. Corrigé + test de non-régression. Co-Authored-By: Claude Sonnet 5 --- .forgejo/workflows/publish-docker.yml | 4 + client/build.mjs | 2 +- client/package-lock.json | 1443 ++++++++++++++++- client/package.json | 6 +- client/src/api/channelApi.ts | 63 + client/src/api/queueApi.ts | 137 ++ client/src/app.ts | 12 +- client/src/format.ts | 37 + client/src/shell/Shell.html | 9 +- client/src/shell/Shell.scss | 40 +- client/src/shell/Shell.ts | 20 +- .../src/views/downloadForm/DownloadForm.html | 59 + .../src/views/downloadForm/DownloadForm.scss | 120 ++ client/src/views/downloadForm/DownloadForm.ts | 281 ++++ client/src/views/queuePanel/QueuePanel.html | 5 + client/src/views/queuePanel/QueuePanel.scss | 82 + client/src/views/queuePanel/QueuePanel.ts | 168 ++ client/test/format.test.ts | 35 + client/tsconfig.json | 6 +- server/src/routes/channel.ts | 15 +- server/src/routes/queue.ts | 9 +- server/src/services/QueueService.ts | 8 +- server/test/channelRoute.test.ts | 16 +- spec/architecture.md | 19 +- 24 files changed, 2556 insertions(+), 40 deletions(-) create mode 100644 client/src/api/channelApi.ts create mode 100644 client/src/api/queueApi.ts create mode 100644 client/src/format.ts create mode 100644 client/src/views/downloadForm/DownloadForm.html create mode 100644 client/src/views/downloadForm/DownloadForm.scss create mode 100644 client/src/views/downloadForm/DownloadForm.ts create mode 100644 client/src/views/queuePanel/QueuePanel.html create mode 100644 client/src/views/queuePanel/QueuePanel.scss create mode 100644 client/src/views/queuePanel/QueuePanel.ts create mode 100644 client/test/format.test.ts diff --git a/.forgejo/workflows/publish-docker.yml b/.forgejo/workflows/publish-docker.yml index 36cdc3e..7c73d42 100644 --- a/.forgejo/workflows/publish-docker.yml +++ b/.forgejo/workflows/publish-docker.yml @@ -84,6 +84,10 @@ jobs: working-directory: client run: npm run typecheck + - name: Client - lancer les tests + working-directory: client + run: npm test + - name: Client - build working-directory: client run: npm run build diff --git a/client/build.mjs b/client/build.mjs index 05b4880..fae877e 100644 --- a/client/build.mjs +++ b/client/build.mjs @@ -36,7 +36,7 @@ const buildOptions = { outfile: path.join(outdir, 'app.js'), bundle: true, format: 'esm', - target: 'es2020', + target: 'es2022', sourcemap: true, // Chaque vue importe son propre gabarit comme chaîne brute (`import html // from './Vue.html'` - voir global.d.ts) et son propre style Sass ; les diff --git a/client/package-lock.json b/client/package-lock.json index f62b51a..e7deb0c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,7 +10,8 @@ "devDependencies": { "esbuild": "^0.28.2", "sass": "^1.83.4", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^5.0.1" } }, "node_modules/@esbuild/aix-ppc64": { @@ -455,6 +456,52 @@ "node": ">=18" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@parcel/watcher": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", @@ -743,6 +790,464 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -770,6 +1275,13 @@ "node": ">=8" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -812,6 +1324,60 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/immutable": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", @@ -844,6 +1410,36 @@ "node": ">=0.10.0" } }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -852,13 +1448,34 @@ "license": "MIT", "optional": true }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=12" }, @@ -866,6 +1483,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/readdirp": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", @@ -880,6 +1527,53 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, "node_modules/sass": { "version": "1.104.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.104.1.tgz", @@ -901,6 +1595,13 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -911,6 +1612,57 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -924,6 +1676,693 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/client/package.json b/client/package.json index 985ee4e..58147b1 100644 --- a/client/package.json +++ b/client/package.json @@ -5,11 +5,13 @@ "scripts": { "build": "node build.mjs", "watch": "node build.mjs --watch", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "devDependencies": { "esbuild": "^0.28.2", "sass": "^1.83.4", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^5.0.1" } } diff --git a/client/src/api/channelApi.ts b/client/src/api/channelApi.ts new file mode 100644 index 0000000..558703a --- /dev/null +++ b/client/src/api/channelApi.ts @@ -0,0 +1,63 @@ +/** Vidéo/stream listé depuis une chaîne (miroir du DTO serveur). */ +export interface ChannelVideoDto { + /** Identifiant YouTube de la vidéo. */ + id: string; + /** Titre de la vidéo. */ + title: string; + /** URL complète de la vidéo. */ + url: string; + /** URL de la miniature, si connue. */ + thumbnailUrl: string | null; + /** Durée en secondes, si connue. */ + durationSeconds: number | null; + /** Date de mise en ligne au format `YYYYMMDD`, si connue. */ + uploadDate: string | null; +} + +/** Section d'une chaîne YouTube à parcourir. */ +export type ChannelSection = 'videos' | 'streams'; + +/** + * Interroge `GET /api/channel` pour lister les vidéos/streams d'une chaîne. + * + * @param channel - Handle (`@nom`) ou URL de la chaîne à parcourir. + * @param section - Section à parcourir. + * @param keywords - Mots-clés séparés par des virgules (optionnel). + * @returns La liste des vidéos correspondantes. + * @throws {Error} Si la requête échoue ou si le serveur répond une erreur. + */ +export async function searchChannel( + channel: string, + section: ChannelSection, + keywords: string, +): Promise { + const params = new URLSearchParams({ channel, section }); + if (keywords.trim().length > 0) { + params.set('keywords', keywords.trim()); + } + + let response: Response; + try { + response = await fetch(`/api/channel?${params.toString()}`); + } catch (error) { + throw new Error('Impossible de contacter le serveur.', { cause: error }); + } + + if (!response.ok) { + let message = `Échec de la recherche (${response.status}).`; + try { + const body = (await response.json()) as { error?: string }; + message = body.error ?? message; + } catch { + // Réponse d'erreur sans JSON exploitable : on garde le message par défaut. + } + throw new Error(message); + } + + try { + const body = (await response.json()) as { videos: ChannelVideoDto[] }; + return body.videos; + } catch (error) { + throw new Error('Réponse du serveur invalide.', { cause: error }); + } +} diff --git a/client/src/api/queueApi.ts b/client/src/api/queueApi.ts new file mode 100644 index 0000000..d841432 --- /dev/null +++ b/client/src/api/queueApi.ts @@ -0,0 +1,137 @@ +/** État d'avancement d'un élément de la file d'attente (miroir du DTO serveur). */ +export type QueueItemStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + +/** Mode de téléchargement d'un élément (miroir du DTO serveur). */ +export type QueueItemMode = + | { kind: 'video'; options: { subtitles: boolean; embedThumbnail: boolean } } + | { kind: 'audio'; format: 'mp3' | 'm4a' | 'opus' } + | { kind: 'thumbnail' }; + +/** Élément de la file d'attente, tel que renvoyé par le serveur. */ +export interface QueueItemDto { + id: string; + url: string; + title: string | null; + mode: QueueItemMode; + status: QueueItemStatus; + progress: number; + error: string | null; + fileName: string | null; + createdAt: string; +} + +/** Entrée acceptée par {@link enqueueItem} pour choisir le mode et ses options. */ +export type EnqueueMode = + | { mode: 'video'; subtitles: boolean; embedThumbnail: boolean } + | { mode: 'audio'; audioFormat: 'mp3' | 'm4a' | 'opus' } + | { mode: 'thumbnail' }; + +/** + * Extrait le message d'erreur d'une réponse HTTP en échec, avec un message + * de repli si le corps n'est pas un JSON exploitable. + * + * @param response - Réponse HTTP dont le statut n'est pas OK. + * @returns Le message d'erreur à afficher. + */ +async function readErrorMessage(response: Response): Promise { + try { + const body = (await response.json()) as { error?: string }; + return body.error ?? `Échec de la requête (${response.status}).`; + } catch { + return `Échec de la requête (${response.status}).`; + } +} + +/** + * Ajoute un élément à la file d'attente via `POST /api/queue`. + * + * @param url - URL de la vidéo YouTube à télécharger. + * @param mode - Mode de téléchargement et ses options. + * @param title - Titre connu de la vidéo, s'il y en a un (affichage uniquement). + * @returns L'élément créé. + * @throws {Error} Si la requête échoue ou si le serveur refuse l'ajout. + */ +export async function enqueueItem( + url: string, + mode: EnqueueMode, + title: string | null, +): Promise { + let response: Response; + try { + response = await fetch('/api/queue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, title: title ?? undefined, ...mode }), + }); + } catch (error) { + throw new Error('Impossible de contacter le serveur.', { cause: error }); + } + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + try { + const body = (await response.json()) as { item: QueueItemDto }; + return body.item; + } catch (error) { + throw new Error('Réponse du serveur invalide.', { cause: error }); + } +} + +/** + * Liste tous les éléments de la file d'attente via `GET /api/queue`. + * + * @returns Les éléments de la file, dans leur ordre d'ajout. + * @throws {Error} Si la requête échoue. + */ +export async function listQueueItems(): Promise { + let response: Response; + try { + response = await fetch('/api/queue'); + } catch (error) { + throw new Error('Impossible de contacter le serveur.', { cause: error }); + } + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + try { + const body = (await response.json()) as { items: QueueItemDto[] }; + return body.items; + } catch (error) { + throw new Error('Réponse du serveur invalide.', { cause: error }); + } +} + +/** + * Annule un élément de la file d'attente via `DELETE /api/queue/:id`. + * + * @param id - Identifiant de l'élément à annuler. + * @throws {Error} Si la requête échoue ou si l'élément est introuvable. + */ +export async function cancelQueueItem(id: string): Promise { + let response: Response; + try { + response = await fetch(`/api/queue/${encodeURIComponent(id)}`, { method: 'DELETE' }); + } catch (error) { + throw new Error('Impossible de contacter le serveur.', { cause: error }); + } + + if (!response.ok && response.status !== 204) { + throw new Error(await readErrorMessage(response)); + } +} + +/** + * Construit l'URL de téléchargement du fichier produit par un élément + * terminé (`GET /api/queue/:id/file`) — destinée à un lien ``, pas à un + * appel `fetch` : c'est le navigateur qui doit gérer le téléchargement. + * + * @param id - Identifiant de l'élément terminé. + * @returns L'URL à utiliser comme `href`. + */ +export function queueItemFileUrl(id: string): string { + return `/api/queue/${encodeURIComponent(id)}/file`; +} diff --git a/client/src/app.ts b/client/src/app.ts index 0fa4d90..825b10b 100644 --- a/client/src/app.ts +++ b/client/src/app.ts @@ -1,15 +1,21 @@ import { mountShell } from './shell/Shell'; +import { mountDownloadForm } from './views/downloadForm/DownloadForm'; +import { mountQueuePanel } from './views/queuePanel/QueuePanel'; /** - * Point d'entrée du client : récupère l'élément racine et y monte le shell - * initial de l'application. + * Point d'entrée du client : monte le shell puis les deux vues qu'il + * héberge (formulaire d'ajout et panneau de file d'attente), et les relie + * entre elles (un ajout réussi rafraîchit immédiatement la file). */ function main(): void { const root = document.getElementById('app'); if (!root) { throw new Error("Élément racine '#app' introuvable dans le document."); } - mountShell(root); + + const { downloadFormRoot, queuePanelRoot } = mountShell(root); + const queuePanel = mountQueuePanel(queuePanelRoot); + mountDownloadForm(downloadFormRoot, { onItemQueued: () => queuePanel.refresh() }); } main(); diff --git a/client/src/format.ts b/client/src/format.ts new file mode 100644 index 0000000..6071820 --- /dev/null +++ b/client/src/format.ts @@ -0,0 +1,37 @@ +/** + * Formate une durée en secondes au format `m:ss` (ou `h:mm:ss` au-delà d'une heure). + * + * @param seconds - Durée en secondes, ou `null` si inconnue. + * @returns La durée formatée, ou une chaîne vide si inconnue. + */ +export function formatDuration(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) { + return ''; + } + const totalSeconds = Math.max(0, Math.round(seconds)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; + } + return `${minutes}:${String(secs).padStart(2, '0')}`; +} + +/** + * Formate une date de mise en ligne au format `YYYYMMDD` (tel que renvoyé + * par yt-dlp) en `JJ/MM/AAAA`. + * + * @param uploadDate - Date au format `YYYYMMDD`, ou `null` si inconnue. + * @returns La date formatée, ou une chaîne vide si inconnue ou mal formée. + */ +export function formatUploadDate(uploadDate: string | null): string { + if (!uploadDate || !/^\d{8}$/.test(uploadDate)) { + return ''; + } + const year = uploadDate.slice(0, 4); + const month = uploadDate.slice(4, 6); + const day = uploadDate.slice(6, 8); + return `${day}/${month}/${year}`; +} diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index 580dc0e..f95df64 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -1,4 +1,9 @@
-

youtubeDownloader

-

L'interface arrive bientôt — pour l'instant, seul le serveur est en place.

+
+

youtubeDownloader

+
+
+
+
+
diff --git a/client/src/shell/Shell.scss b/client/src/shell/Shell.scss index c0930b0..43f0f06 100644 --- a/client/src/shell/Shell.scss +++ b/client/src/shell/Shell.scss @@ -1,19 +1,41 @@ .shell { display: flex; flex-direction: column; - align-items: center; - justify-content: center; - height: 100vh; + min-height: 100vh; margin: 0; font-family: system-ui, sans-serif; - text-align: center; + color: #222; - h1 { - font-size: 1.5rem; - margin-bottom: 0.5rem; + &__header { + padding: 1rem 1.5rem; + border-bottom: 1px solid #eee; + + h1 { + font-size: 1.25rem; + margin: 0; + } } - p { - color: #666; + &__main { + flex: 1; + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + padding: 1.5rem; + max-width: 960px; + margin: 0 auto; + width: 100%; + box-sizing: border-box; + } + + @media (min-width: 800px) { + &__main { + grid-template-columns: 3fr 2fr; + align-items: start; + } + } + + &__panel { + min-width: 0; } } diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index 8f02ad4..9bc801c 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -1,11 +1,29 @@ import shellTemplate from './Shell.html'; import './Shell.scss'; +/** Points de montage exposés par le shell pour les vues qu'il héberge. */ +export interface ShellMounts { + /** Racine du formulaire d'ajout (URL directe + recherche de chaîne). */ + downloadFormRoot: HTMLElement; + /** Racine du panneau de file d'attente. */ + queuePanelRoot: HTMLElement; +} + /** * Monte la coquille (shell) de l'application dans l'élément racine fourni. * * @param root - Élément DOM dans lequel injecter le gabarit du shell. + * @returns Les points de montage des vues hébergées par le shell. + * @throws {Error} Si le gabarit ne contient pas les points de montage attendus. */ -export function mountShell(root: HTMLElement): void { +export function mountShell(root: HTMLElement): ShellMounts { root.innerHTML = shellTemplate; + + const downloadFormRoot = root.querySelector('#download-form'); + const queuePanelRoot = root.querySelector('#queue-panel'); + if (!downloadFormRoot || !queuePanelRoot) { + throw new Error('Gabarit du shell invalide : points de montage introuvables.'); + } + + return { downloadFormRoot, queuePanelRoot }; } diff --git a/client/src/views/downloadForm/DownloadForm.html b/client/src/views/downloadForm/DownloadForm.html new file mode 100644 index 0000000..461c838 --- /dev/null +++ b/client/src/views/downloadForm/DownloadForm.html @@ -0,0 +1,59 @@ +
+

Ajouter des vidéos

+ +
+ Mode + + + +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + + +
+ + + +
    +
    diff --git a/client/src/views/downloadForm/DownloadForm.scss b/client/src/views/downloadForm/DownloadForm.scss new file mode 100644 index 0000000..e3d58eb --- /dev/null +++ b/client/src/views/downloadForm/DownloadForm.scss @@ -0,0 +1,120 @@ +.download-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + + h2 { + margin: 0; + font-size: 1.1rem; + } + + &__mode { + display: flex; + gap: 1rem; + border: 1px solid #ddd; + border-radius: 4px; + padding: 0.5rem 0.75rem; + + label { + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: normal; + } + } + + &__options { + display: flex; + gap: 1rem; + align-items: center; + + &[hidden] { + display: none; + } + + label { + display: flex; + align-items: center; + gap: 0.25rem; + } + } + + &__form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: end; + } + + &__field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + flex: 1 1 160px; + + input, + select { + padding: 0.4rem; + } + } + + &__error { + color: #b00020; + font-size: 0.85rem; + margin: 0; + } + + &__status { + color: #555; + font-size: 0.85rem; + margin: 0; + } + + &__results { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 320px; + overflow-y: auto; + } + + &__result { + display: flex; + align-items: center; + gap: 0.75rem; + border: 1px solid #eee; + border-radius: 4px; + padding: 0.4rem; + } + + &__result-thumb { + width: 96px; + height: 54px; + object-fit: cover; + border-radius: 2px; + flex-shrink: 0; + } + + &__result-info { + flex: 1; + min-width: 0; + } + + &__result-title { + margin: 0; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__result-meta { + margin: 0; + font-size: 0.75rem; + color: #666; + } +} diff --git a/client/src/views/downloadForm/DownloadForm.ts b/client/src/views/downloadForm/DownloadForm.ts new file mode 100644 index 0000000..2ec3bd2 --- /dev/null +++ b/client/src/views/downloadForm/DownloadForm.ts @@ -0,0 +1,281 @@ +import { type ChannelSection, type ChannelVideoDto, searchChannel } from '../../api/channelApi'; +import { type EnqueueMode, enqueueItem } from '../../api/queueApi'; +import { formatDuration, formatUploadDate } from '../../format'; +import downloadFormTemplate from './DownloadForm.html'; +import './DownloadForm.scss'; + +/** Rappels fournis par l'appelant pour réagir aux actions du formulaire. */ +export interface DownloadFormCallbacks { + /** Appelé juste après l'ajout réussi d'un élément à la file d'attente. */ + onItemQueued: () => void; +} + +/** + * Affiche ou efface un message dans un élément de statut/erreur. + * + * @param el - Élément cible, ou `null` s'il n'a pas été trouvé dans le gabarit. + * @param message - Message à afficher, ou `null` pour masquer l'élément. + */ +function setMessage(el: HTMLElement | null, message: string | null): void { + if (!el) { + return; + } + el.hidden = message === null; + el.textContent = message ?? ''; +} + +/** + * Lit le mode de téléchargement et ses options depuis les contrôles + * actuellement sélectionnés dans le formulaire. + * + * @param root - Racine du formulaire monté. + * @returns Le mode à utiliser pour le prochain élément ajouté. + */ +function readCurrentMode(root: HTMLElement): EnqueueMode { + const selected = + root.querySelector('input[name="mode"]:checked')?.value ?? 'video'; + + if (selected === 'audio') { + const audioFormat = + (root.querySelector('[data-field="audioFormat"]')?.value as + | 'mp3' + | 'm4a' + | 'opus' + | undefined) ?? 'mp3'; + return { mode: 'audio', audioFormat }; + } + if (selected === 'thumbnail') { + return { mode: 'thumbnail' }; + } + return { + mode: 'video', + subtitles: root.querySelector('[data-field="subtitles"]')?.checked ?? false, + embedThumbnail: + root.querySelector('[data-field="embedThumbnail"]')?.checked ?? false, + }; +} + +/** + * Affiche/masque les panneaux d'options quand le mode sélectionné change. + * + * @param root - Racine du formulaire monté. + */ +function wireModeToggle(root: HTMLElement): void { + const fieldset = root.querySelector('.download-form__mode'); + const optionPanels = root.querySelectorAll('[data-mode-options]'); + + fieldset?.addEventListener('change', () => { + const selected = root.querySelector('input[name="mode"]:checked')?.value; + for (const panel of optionPanels) { + panel.hidden = panel.dataset.modeOptions !== selected; + } + }); +} + +/** + * Ajoute un résultat de recherche de chaîne à la file d'attente, avec + * retour visuel sur le bouton concerné. + * + * @param video - Vidéo à ajouter. + * @param formRoot - Racine du formulaire (pour lire le mode courant). + * @param button - Bouton "Ajouter" cliqué, pour le désactiver pendant la requête. + * @param errorEl - Élément où afficher une éventuelle erreur. + * @param callbacks - Rappels du formulaire. + */ +async function handleAddResult( + video: ChannelVideoDto, + formRoot: HTMLElement, + button: HTMLButtonElement, + errorEl: HTMLElement | null, + callbacks: DownloadFormCallbacks, +): Promise { + button.disabled = true; + setMessage(errorEl, null); + + try { + await enqueueItem(video.url, readCurrentMode(formRoot), video.title); + button.textContent = 'Ajouté ✓'; + callbacks.onItemQueued(); + } catch (error) { + button.disabled = false; + setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout."); + } +} + +/** + * Affiche la liste des résultats d'une recherche de chaîne. + * + * @param container - Élément `
      ` dans lequel rendre les résultats. + * @param videos - Vidéos à afficher. + * @param formRoot - Racine du formulaire (pour lire le mode courant lors d'un ajout). + * @param errorEl - Élément où afficher une éventuelle erreur d'ajout. + * @param callbacks - Rappels du formulaire. + */ +function renderResults( + container: HTMLElement, + videos: readonly ChannelVideoDto[], + formRoot: HTMLElement, + errorEl: HTMLElement | null, + callbacks: DownloadFormCallbacks, +): void { + container.replaceChildren(); + + for (const video of videos) { + const item = document.createElement('li'); + item.className = 'download-form__result'; + + if (video.thumbnailUrl) { + const img = document.createElement('img'); + img.src = video.thumbnailUrl; + img.alt = ''; + img.className = 'download-form__result-thumb'; + item.append(img); + } + + const info = document.createElement('div'); + info.className = 'download-form__result-info'; + + const title = document.createElement('p'); + title.className = 'download-form__result-title'; + title.textContent = video.title; + info.append(title); + + const meta = document.createElement('p'); + meta.className = 'download-form__result-meta'; + meta.textContent = [formatDuration(video.durationSeconds), formatUploadDate(video.uploadDate)] + .filter((part) => part.length > 0) + .join(' · '); + info.append(meta); + + item.append(info); + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.textContent = 'Ajouter'; + addButton.addEventListener('click', () => { + handleAddResult(video, formRoot, addButton, errorEl, callbacks).catch((error: unknown) => { + console.error('Erreur inattendue lors de l’ajout depuis la recherche', error); + }); + }); + item.append(addButton); + + container.append(item); + } +} + +/** + * Câble le formulaire d'ajout direct par URL. + * + * @param root - Racine du formulaire monté. + * @param callbacks - Rappels du formulaire. + */ +function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { + const form = root.querySelector('form[data-form="url"]'); + const errorEl = root.querySelector('[data-role="url-error"]'); + if (!form) { + return; + } + + const handleSubmit = async (event: SubmitEvent): Promise => { + event.preventDefault(); + const url = form.querySelector('input[name="url"]')?.value.trim() ?? ''; + if (!url) { + return; + } + + setMessage(errorEl, null); + const submitButton = form.querySelector('button[type="submit"]'); + if (submitButton) { + submitButton.disabled = true; + } + + try { + await enqueueItem(url, readCurrentMode(root), null); + form.reset(); + callbacks.onItemQueued(); + } catch (error) { + setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout à la file."); + } finally { + if (submitButton) { + submitButton.disabled = false; + } + } + }; + + form.addEventListener('submit', (event) => { + handleSubmit(event).catch((error: unknown) => { + console.error('Erreur inattendue lors de l’ajout par URL', error); + }); + }); +} + +/** + * Câble le formulaire de recherche de chaîne. + * + * @param root - Racine du formulaire monté. + * @param callbacks - Rappels du formulaire. + */ +function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { + const form = root.querySelector('form[data-form="channel"]'); + const errorEl = root.querySelector('[data-role="channel-error"]'); + const statusEl = root.querySelector('[data-role="channel-status"]'); + const resultsEl = root.querySelector('[data-role="results"]'); + if (!form || !resultsEl) { + return; + } + + const handleSubmit = async (event: SubmitEvent): Promise => { + event.preventDefault(); + const formData = new FormData(form); + const channel = String(formData.get('channel') ?? '').trim(); + const section = String(formData.get('section') ?? 'videos') as ChannelSection; + const keywords = String(formData.get('keywords') ?? ''); + if (!channel) { + return; + } + + setMessage(errorEl, null); + setMessage(statusEl, 'Recherche en cours...'); + resultsEl.replaceChildren(); + const submitButton = form.querySelector('button[type="submit"]'); + if (submitButton) { + submitButton.disabled = true; + } + + try { + const videos = await searchChannel(channel, section, keywords); + setMessage( + statusEl, + videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`, + ); + renderResults(resultsEl, videos, root, errorEl, callbacks); + } catch (error) { + setMessage(statusEl, null); + setMessage(errorEl, error instanceof Error ? error.message : 'Échec de la recherche.'); + } finally { + if (submitButton) { + submitButton.disabled = false; + } + } + }; + + form.addEventListener('submit', (event) => { + handleSubmit(event).catch((error: unknown) => { + console.error('Erreur inattendue lors de la recherche de chaîne', error); + }); + }); +} + +/** + * Monte le formulaire d'ajout (URL directe + recherche de chaîne) dans + * l'élément racine fourni. + * + * @param root - Élément DOM dans lequel injecter le formulaire. + * @param callbacks - Rappels invoqués sur les actions du formulaire. + */ +export function mountDownloadForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { + root.innerHTML = downloadFormTemplate; + wireModeToggle(root); + wireUrlForm(root, callbacks); + wireChannelForm(root, callbacks); +} diff --git a/client/src/views/queuePanel/QueuePanel.html b/client/src/views/queuePanel/QueuePanel.html new file mode 100644 index 0000000..93546ad --- /dev/null +++ b/client/src/views/queuePanel/QueuePanel.html @@ -0,0 +1,5 @@ +
      +

      File d'attente

      +

      Aucun élément pour l'instant.

      +
        +
        diff --git a/client/src/views/queuePanel/QueuePanel.scss b/client/src/views/queuePanel/QueuePanel.scss new file mode 100644 index 0000000..4256cc0 --- /dev/null +++ b/client/src/views/queuePanel/QueuePanel.scss @@ -0,0 +1,82 @@ +.queue-panel { + display: flex; + flex-direction: column; + gap: 0.75rem; + + h2 { + margin: 0; + font-size: 1.1rem; + } + + &__empty { + color: #666; + font-size: 0.85rem; + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &__item { + border: 1px solid #eee; + border-left-width: 4px; + border-radius: 4px; + padding: 0.5rem 0.75rem; + + &--completed { + border-left-color: #2e7d32; + } + &--failed { + border-left-color: #b00020; + } + &--running { + border-left-color: #1565c0; + } + &--cancelled { + border-left-color: #999; + } + &--pending { + border-left-color: #999; + } + } + + &__item-title { + margin: 0; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__item-meta { + margin: 0.15rem 0 0; + font-size: 0.75rem; + color: #666; + } + + &__item-progress { + width: 100%; + margin-top: 0.35rem; + } + + &__item-error { + margin: 0.35rem 0 0; + font-size: 0.75rem; + color: #b00020; + } + + &__item-actions { + margin-top: 0.35rem; + display: flex; + gap: 0.5rem; + } + + &__item-download { + font-size: 0.85rem; + } +} diff --git a/client/src/views/queuePanel/QueuePanel.ts b/client/src/views/queuePanel/QueuePanel.ts new file mode 100644 index 0000000..2fec7e2 --- /dev/null +++ b/client/src/views/queuePanel/QueuePanel.ts @@ -0,0 +1,168 @@ +import { + type QueueItemDto, + cancelQueueItem, + listQueueItems, + queueItemFileUrl, +} from '../../api/queueApi'; +import queuePanelTemplate from './QueuePanel.html'; +import './QueuePanel.scss'; + +/** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */ +const POLL_INTERVAL_MS = 1500; + +const STATUS_LABELS: Record = { + pending: 'En attente', + running: 'En cours', + completed: 'Terminé', + failed: 'Échec', + cancelled: 'Annulé', +}; + +const MODE_LABELS: Record = { + video: 'Vidéo', + audio: 'Audio', + thumbnail: 'Miniature', +}; + +/** Contrôleur exposé par {@link mountQueuePanel}. */ +export interface QueuePanelController { + /** Recharge immédiatement la liste, sans attendre le prochain sondage. */ + refresh: () => void; +} + +/** + * Annule un élément de la file au clic sur son bouton "Annuler". + * + * @param id - Identifiant de l'élément à annuler. + * @param button - Bouton cliqué, désactivé pendant la requête. + * @param onDone - Rappelé après la requête (succès ou échec) pour rafraîchir la liste. + */ +async function handleCancel( + id: string, + button: HTMLButtonElement, + onDone: () => void, +): Promise { + button.disabled = true; + try { + await cancelQueueItem(id); + } catch (error) { + console.error("Échec de l'annulation", error); + } finally { + onDone(); + } +} + +/** + * Construit la ligne d'affichage d'un élément de la file. + * + * @param item - Élément à afficher. + * @param onChanged - Rappelé après une action (annulation) pour rafraîchir la liste. + * @returns L'élément `
      • ` prêt à être inséré dans la liste. + */ +function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { + const li = document.createElement('li'); + li.className = `queue-panel__item queue-panel__item--${item.status}`; + + const title = document.createElement('p'); + title.className = 'queue-panel__item-title'; + title.textContent = item.title ?? item.url; + li.append(title); + + const meta = document.createElement('p'); + meta.className = 'queue-panel__item-meta'; + meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`; + li.append(meta); + + if (item.status === 'running' || item.status === 'pending') { + const progress = document.createElement('progress'); + progress.className = 'queue-panel__item-progress'; + progress.max = 100; + progress.value = item.progress; + li.append(progress); + } + + if (item.status === 'failed' && item.error) { + const error = document.createElement('p'); + error.className = 'queue-panel__item-error'; + error.textContent = item.error; + li.append(error); + } + + const actions = document.createElement('div'); + actions.className = 'queue-panel__item-actions'; + + if (item.status === 'pending' || item.status === 'running') { + const cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.textContent = 'Annuler'; + cancelButton.addEventListener('click', () => { + handleCancel(item.id, cancelButton, onChanged).catch((error: unknown) => { + console.error("Erreur inattendue lors de l'annulation", error); + }); + }); + actions.append(cancelButton); + } + + if (item.status === 'completed') { + const link = document.createElement('a'); + link.href = queueItemFileUrl(item.id); + link.textContent = 'Télécharger'; + link.className = 'queue-panel__item-download'; + actions.append(link); + } + + li.append(actions); + return li; +} + +/** + * Rend la liste des éléments dans le panneau. + * + * @param root - Racine du panneau monté. + * @param items - Éléments à afficher. + * @param onChanged - Rappelé après une action sur un élément (pour rafraîchir la liste). + */ +function render(root: HTMLElement, items: readonly QueueItemDto[], onChanged: () => void): void { + const list = root.querySelector('[data-role="list"]'); + const empty = root.querySelector('[data-role="empty"]'); + if (!list || !empty) { + return; + } + + empty.hidden = items.length > 0; + list.replaceChildren(...items.map((item) => renderItem(item, onChanged))); +} + +/** + * Monte le panneau de file d'attente dans l'élément racine fourni, et + * démarre le sondage périodique de `GET /api/queue`. + * + * @param root - Élément DOM dans lequel injecter le panneau. + * @returns Un contrôleur permettant de déclencher un rafraîchissement immédiat. + */ +export function mountQueuePanel(root: HTMLElement): QueuePanelController { + root.innerHTML = queuePanelTemplate; + let isRefreshing = false; + + const refresh = (): void => { + if (isRefreshing) { + return; + } + isRefreshing = true; + listQueueItems() + .then((items) => { + render(root, items, refresh); + }) + .catch((error: unknown) => { + console.error('Échec du chargement de la file d’attente', error); + }) + .finally(() => { + isRefreshing = false; + }); + }; + + refresh(); + setInterval(refresh, POLL_INTERVAL_MS); + + return { refresh }; +} diff --git a/client/test/format.test.ts b/client/test/format.test.ts new file mode 100644 index 0000000..d176636 --- /dev/null +++ b/client/test/format.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { formatDuration, formatUploadDate } from '../src/format'; + +describe('formatDuration', () => { + it('formate en m:ss sous une heure', () => { + expect(formatDuration(65)).toBe('1:05'); + }); + + it('complète les secondes avec un zéro', () => { + expect(formatDuration(60)).toBe('1:00'); + }); + + it('formate en h:mm:ss au-delà d’une heure', () => { + expect(formatDuration(3725)).toBe('1:02:05'); + }); + + it('retourne une chaîne vide si inconnue', () => { + expect(formatDuration(null)).toBe(''); + }); +}); + +describe('formatUploadDate', () => { + it('formate YYYYMMDD en JJ/MM/AAAA', () => { + expect(formatUploadDate('20260315')).toBe('15/03/2026'); + }); + + it('retourne une chaîne vide si inconnue', () => { + expect(formatUploadDate(null)).toBe(''); + }); + + it('retourne une chaîne vide si mal formée', () => { + expect(formatUploadDate('abc')).toBe(''); + expect(formatUploadDate('2026-03-15')).toBe(''); + }); +}); diff --git a/client/tsconfig.json b/client/tsconfig.json index 1b81c34..e2ddba6 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "strict": true, @@ -12,5 +12,5 @@ "noEmit": true, "isolatedModules": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/server/src/routes/channel.ts b/server/src/routes/channel.ts index 653df5e..a7fb17d 100644 --- a/server/src/routes/channel.ts +++ b/server/src/routes/channel.ts @@ -4,7 +4,6 @@ import { type ChannelSection, InvalidChannelError, } from '../services/ChannelDiscoveryService.js'; -import { YtDlpError } from '../services/ytDlpRunner.js'; /** Paramètres de requête acceptés par `GET /api/channel`. */ interface ListChannelQuerystring { @@ -72,12 +71,14 @@ export const channelRoutes: FastifyPluginAsync = async (app: FastifyInstance): P if (error instanceof InvalidChannelError) { return reply.status(400).send({ error: error.message }); } - if (error instanceof YtDlpError) { - return reply - .status(502) - .send({ error: 'Échec de la récupération depuis YouTube (yt-dlp).' }); - } - throw error; + // buildChannelUrl() lève InvalidChannelError de façon synchrone, + // avant tout appel à yt-dlp : toute autre erreur ici vient donc + // forcément de runYtDlp (binaire introuvable, code de sortie non + // nul, etc.), qu'elle soit une YtDlpError ou une erreur brute de + // spawn (ex. ENOENT) - les deux sont un échec côté yt-dlp/YouTube. + return reply + .status(502) + .send({ error: 'Échec de la récupération depuis YouTube (yt-dlp).' }); } }, ); diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts index fbfad15..6ae5435 100644 --- a/server/src/routes/queue.ts +++ b/server/src/routes/queue.ts @@ -9,6 +9,8 @@ import { InvalidVideoUrlError, assertValidVideoUrl } from '../services/videoUrl. interface CreateQueueItemBody { /** URL de la vidéo YouTube à télécharger. */ url: string; + /** Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. */ + title?: 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. */ @@ -24,6 +26,7 @@ const createQueueItemSchema = { required: ['url', 'mode'], properties: { url: { type: 'string', minLength: 1, maxLength: 500 }, + title: { type: 'string', maxLength: 300 }, mode: { type: 'string', enum: ['video', 'audio', 'thumbnail'] }, subtitles: { type: 'boolean' }, embedThumbnail: { type: 'boolean' }, @@ -97,7 +100,11 @@ export function createQueueRoutes( throw error; } - const item = queueService.enqueue(url, toDownloadMode(request.body)); + const item = queueService.enqueue( + url, + toDownloadMode(request.body), + request.body.title ?? null, + ); return reply.status(201).send({ item }); }, ); diff --git a/server/src/services/QueueService.ts b/server/src/services/QueueService.ts index f8ac01e..9ad9c69 100644 --- a/server/src/services/QueueService.ts +++ b/server/src/services/QueueService.ts @@ -19,6 +19,8 @@ export interface QueueItem { readonly id: string; /** URL de la vidéo source. */ readonly url: string; + /** Titre de la vidéo, si connu au moment de l'ajout (purement informatif, pour l'affichage). */ + readonly title: string | null; /** Mode de téléchargement demandé. */ readonly mode: DownloadMode; /** État d'avancement courant. */ @@ -39,6 +41,7 @@ const DOWNLOAD_TIMEOUT_MS = 30 * 60_000; interface InternalQueueItem { id: string; url: string; + title: string | null; mode: DownloadMode; status: QueueItemStatus; progress: number; @@ -96,13 +99,15 @@ export class QueueService { * * @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 title - Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. * @returns L'élément créé, à l'état `"pending"`. */ - public enqueue(url: string, mode: DownloadMode): QueueItem { + public enqueue(url: string, mode: DownloadMode, title: string | null = null): QueueItem { const id = randomUUID(); const item: InternalQueueItem = { id, url, + title, mode, status: 'pending', progress: 0, @@ -310,6 +315,7 @@ export class QueueService { return { id: item.id, url: item.url, + title: item.title, mode: item.mode, status: item.status, progress: item.progress, diff --git a/server/test/channelRoute.test.ts b/server/test/channelRoute.test.ts index 06cad34..849e5f6 100644 --- a/server/test/channelRoute.test.ts +++ b/server/test/channelRoute.test.ts @@ -56,7 +56,7 @@ describe('GET /api/channel', () => { expect(response.statusCode).toBe(400); }); - it('renvoie 502 quand yt-dlp échoue', async () => { + it('renvoie 502 quand yt-dlp échoue avec un code de sortie non nul', async () => { vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network error')); const app = Fastify(); @@ -66,4 +66,18 @@ describe('GET /api/channel', () => { expect(response.statusCode).toBe(502); }); + + it('renvoie 502 (pas 500) quand le binaire yt-dlp est introuvable (ENOENT)', async () => { + // Node rejette avec une Error brute (pas une YtDlpError) quand le spawn + // lui-même échoue - voir ytDlpRunner.ts, l'événement 'error' du child. + const spawnError = Object.assign(new Error('spawn yt-dlp ENOENT'), { code: 'ENOENT' }); + vi.mocked(runYtDlp).mockRejectedValue(spawnError); + + const app = Fastify(); + await app.register(channelRoutes); + + const response = await app.inject({ method: 'GET', url: '/api/channel?channel=@test' }); + + expect(response.statusCode).toBe(502); + }); }); diff --git a/spec/architecture.md b/spec/architecture.md index 43fbb67..5d48641 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -31,15 +31,20 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe ## Frontend (`client/`) -- TypeScript vanilla, pas de framework. Chaque vue = un dossier avec `Vue.ts` / `Vue.html` / `Vue.scss` (gabarit importé comme chaîne brute, style Sass importé et compilé). Voir `src/shell/` pour l'exemple actuel (placeholder). -- Build via `build.mjs` (esbuild), pas d'outil de build plus lourd (Vite, webpack) : le script compile `src/app.ts` en bundle ESM, copie `src/index.html`, et compile/regroupe les `.scss` importés en un `app.css` voisin (plugin esbuild maison qui délègue au paquet `sass`). -- Pas encore de router : une seule vue (shell) pour l'instant. Un routeur sera introduit avec la première vraie vue (parcours de chaîne). +- TypeScript vanilla, pas de framework. Chaque vue = un dossier avec `Vue.ts` / `Vue.html` / `Vue.scss` (gabarit importé comme chaîne brute, style Sass importé et compilé). +- Build via `build.mjs` (esbuild), pas d'outil de build plus lourd (Vite, webpack) : le script compile `src/app.ts` en bundle ESM, copie `src/index.html`, et compile/regroupe les `.scss` importés en un `app.css` voisin (plugin esbuild maison qui délègue au paquet `sass`). Cible ES2022 (nécessaire pour `Error.cause`, utilisé dans `api/`). +- Pas de router : `src/shell/Shell.ts` monte deux vues fixes côte à côte (`views/downloadForm/`, `views/queuePanel/`), reliées par un callback simple (`onItemQueued`) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire. +- `src/api/` : clients fetch vers le backend (`channelApi.ts`, `queueApi.ts`), avec leurs propres types DTO - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm). +- `src/views/downloadForm/` : formulaire d'ajout, deux entrées possibles (URL directe, ou recherche de chaîne via `GET /api/channel` puis ajout par résultat) partageant un même sélecteur de mode (vidéo/audio/miniature + options). Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé. +- `src/views/queuePanel/` : sonde `GET /api/queue` toutes les 1,5s (pas de push temps réel pour l'instant, voir Statut) et affiche chaque élément (titre ou URL à défaut, statut, barre de progression, erreur, bouton annuler/lien de téléchargement selon le statut). Expose `refresh()` pour un rafraîchissement immédiat après un ajout. +- Piège CSS relevé en testant dans un vrai navigateur (pas seulement les tests) : un attribut `hidden` combiné à une règle `display: flex` sur le même sélecteur se fait écraser par cette règle (même spécificité, la feuille de l'auteur gagne sur celle du user-agent) - toujours ajouter `&[hidden] { display: none; }` quand un conteneur togglé porte aussi un `display` explicite. +- Tests : Vitest (`client/test/`), uniquement sur les fonctions pures (`format.ts`) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement. ## API - `GET /health` : statut du service (utilisé par le `HEALTHCHECK` Docker). - `GET /api/channel?channel=<@handle|URL>§ion=&keywords=` : liste les vidéos/streams d'une chaîne, filtrés par mots-clés (ET, insensible à la casse) sur le titre. `section` par défaut `videos`. Réponses : `200 { videos: [...] }`, `400` si `channel` est absent/invalide, `502` si yt-dlp échoue. -- `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. +- `POST /api/queue` : ajoute un élément (`{ url, title?, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }`). `title` est purement informatif (affichage), jamais utilisé pour construire la commande yt-dlp. `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`. @@ -52,9 +57,9 @@ Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, ` - `server/Dockerfile` : image multi-étapes construite avec pour contexte la racine du dépôt (`docker build -f server/Dockerfile .`), car elle a besoin à la fois de `server/` et `client/`. Étapes : build client (esbuild) → build serveur (tsc) → image runtime (Node 22 Alpine + `ffmpeg` + binaire autonome `yt-dlp`, utilisateur non-root `node`). - `yt-dlp` et `ffmpeg` sont installés dans l'image (pas vendorisés dans le dépôt, contrairement aux scripts `.cmd` d'origine qui embarquaient `yt-dlp.exe` et un build ffmpeg complet). -- `.forgejo/workflows/publish-docker.yml` : job `test` (lint racine, typecheck/tests/build serveur, typecheck/build client) puis job `publish` (build + push de l'image vers `git.dev.kyuno.fr/kyuno/youtubedownloader`, seulement si `test` passe et hors PR). Tags : `latest` + sha court sur push `main`, `X.Y.Z`/`X.Y`/`latest` sur tag `vX.Y.Z`. Nécessite le secret de dépôt `REGISTRY_TOKEN` (à créer manuellement, voir l'en-tête du workflow - un jeton d'API n'a pas le droit d'écrire dans le registre de paquets). +- `.forgejo/workflows/publish-docker.yml` : job `test` (lint racine, typecheck/tests/build serveur, typecheck/tests/build client) puis job `publish` (build + push de l'image vers `git.dev.kyuno.fr/kyuno/youtubedownloader`, seulement si `test` passe et hors PR). Tags : `latest` + sha court sur push `main`, `X.Y.Z`/`X.Y`/`latest` sur tag `vX.Y.Z`. Nécessite le secret de dépôt `REGISTRY_TOKEN` (à créer manuellement, voir l'en-tête du workflow - un jeton d'API n'a pas le droit d'écrire dans le registre de paquets). ## Statut -- 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). +- Fait : infrastructure (serveur, 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*`), frontend complet (formulaire d'ajout + file d'attente, sondage 1,5s). +- À venir (PR successives) : notifications temps réel (WebSocket, progression poussée plutôt que sondage), tests client sur le câblage DOM (jsdom), sélection de qualité vidéo (actuellement fixe : meilleur mp4 disponible). From cbfce996ed6154d77d8990f580d40127ef025ee8 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 19:27:42 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(server):=20rendre=20les=20chemins=20par?= =?UTF-8?q?=20d=C3=A9faut=20ind=C3=A9pendants=20du=20cwd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLIENT_DIST_PATH et TMP_DIR se résolvaient via process.cwd(), donc uniquement correct si le process est lancé depuis server/. Repéré en mettant en place la prévisualisation locale (.claude/launch.json, non versionné) : npm --prefix server run dev depuis la racine du dépôt ne change pas le cwd du script lancé, `npm --prefix server exec -- node -e "console.log(process.cwd())"` le confirme (reste la racine du dépôt). Résout désormais les deux chemins depuis l'emplacement du module (import.meta.url) plutôt que process.cwd() - invariant quel que soit le répertoire de lancement, y compris en Docker. Co-Authored-By: Claude Sonnet 5 --- server/src/config.ts | 14 +++++++++++--- spec/architecture.md | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index 64433b4..adb1852 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; /** * Configuration runtime du serveur, dérivée des variables d'environnement. @@ -16,11 +17,18 @@ export interface ServerConfig { readonly maxConcurrentDownloads: number; } +// Dossier contenant ce module compilé (server/dist/ en prod, server/src/ sous +// tsx en dev - même profondeur sous server/ dans les deux cas). Sert de base +// aux chemins par défaut ci-dessous : contrairement à process.cwd(), invariant +// quel que soit le répertoire depuis lequel le process a été lancé (ex. `npm +// --prefix server run dev` depuis la racine du dépôt ne change pas le cwd). +const here = fileURLToPath(new URL('.', import.meta.url)); + /** * 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é et le dossier temporaire sont alors supposés voisins de - * `server/`). + * `server/`, indépendamment du répertoire de travail courant). * * @returns La configuration runtime résolue. */ @@ -31,8 +39,8 @@ export function loadConfig(): ServerConfig { 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'), + clientDistPath: process.env.CLIENT_DIST_PATH ?? resolve(here, '../../client/dist'), + tmpDir: process.env.TMP_DIR ?? resolve(here, '../tmp'), maxConcurrentDownloads: Number.isNaN(parsedConcurrency) || parsedConcurrency < 1 ? 2 : parsedConcurrency, }; diff --git a/spec/architecture.md b/spec/architecture.md index 5d48641..818c18b 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -18,7 +18,7 @@ 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/config.ts` : configuration runtime depuis l'environnement (`PORT`, `HOST`, `CLIENT_DIST_PATH`, `TMP_DIR`, `MAX_CONCURRENT_DOWNLOADS`). Les valeurs par défaut de `CLIENT_DIST_PATH`/`TMP_DIR` sont résolues depuis l'emplacement du module (`import.meta.url`), pas `process.cwd()` : reste correct quel que soit le répertoire depuis lequel le process est lancé (ex. `npm --prefix server run dev` depuis la racine du dépôt, utilisé par `.claude/launch.json` pour la prévisualisation - non versionné, voir `.gitignore`). - `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). Supporte le suivi ligne par ligne de stdout (`onStdoutLine`, pour la progression) et l'annulation via `AbortSignal`.