import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import type { WatchTarget } from '@stream-control/shared'; import { config } from './config.ts'; import { sessionsRepo } from './db.ts'; /** * Copie locale d'une vignette par diffusion, pour que la frise reste illustrée * des mois après coup. * * La vignette servie en direct (`WatchTarget.previewUrl`) pointe vers le CDN de * la plateforme, qui la remplace toutes les demi-heures environ : passé la * diffusion, l'URL ne renvoie plus rien d'exploitable. Survoler une barre de * frise vieille de trois jours ne pouvait donc rien montrer sans en garder une * copie au moment où elle existait encore. * * Une seule par diffusion, jamais remplacée ensuite : c'est une illustration * destinée à reconnaître un stream d'un coup d'œil, pas un suivi de son * déroulé. */ /** Au-delà, ce n'est plus une vignette — on refuse plutôt que de remplir le disque. */ const MAX_BYTES = 4 * 1024 * 1024; const FETCH_TIMEOUT_MS = 8000; const EXTENSIONS: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/avif': '.avif', }; /** * Diffusions déjà tentées depuis le démarrage du serveur, réussites comprises. * * Sans ce garde-fou, une diffusion dont la copie échoue verrait une nouvelle * tentative à chaque sonde — toutes les 30 s pendant des heures. En mémoire et * non en base, à dessein : un redémarrage rend sa chance à une diffusion * toujours en cours, sans jamais graver un échec passager dans le schéma. */ const attempted = new Set(); function previewPath(file: string): string { // `basename` par principe : le nom vient de la base, mais rien ne justifie de // laisser un chemin relatif remonter hors du répertoire prévu. return path.join(config.previewsDir, path.basename(file)); } /** * Copie la vignette de la diffusion en cours de ce profil, si l'heure est venue. * * Sans effet — et sans erreur — dans tous les cas où il n'y a rien à faire : * profil hors diffusion, vignette déjà copiée, diffusion trop jeune, plateforme * qui ne fournit pas d'image. Appelée à chaque sonde, elle doit rester * silencieuse la quasi-totalité du temps. */ export async function capturePreview(target: WatchTarget): Promise { if (target.state !== 'public' || !target.previewUrl) return; const session = sessionsRepo.openFor(target.id); if (!session || session.hasPreview) return; if (attempted.has(session.id)) return; // Le délai se mesure au début de la diffusion, pas à notre première // observation : un serveur qui redémarre au milieu d'un stream doit pouvoir // en tirer une vignette immédiatement plutôt que d'attendre une minute de plus. if (Date.now() - session.startedAt < config.previewDelayMs) return; attempted.add(session.id); await download(session.id, target.previewUrl); } async function download(sessionId: number, url: string): Promise { const response = await fetch(url, { headers: { accept: 'image/*' }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const type = (response.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase(); if (!type.startsWith('image/')) { throw new Error(`type inattendu : ${type || 'inconnu'}`); } const bytes = Buffer.from(await response.arrayBuffer()); if (bytes.byteLength === 0) throw new Error('image vide'); if (bytes.byteLength > MAX_BYTES) { throw new Error(`image trop lourde (${Math.round(bytes.byteLength / 1024)} ko)`); } // Suffixe aléatoire plutôt que le seul identifiant de diffusion : une base // repartie de zéro réattribuerait les mêmes identifiants, et un fichier // resté d'une base précédente illustrerait alors la mauvaise diffusion. const file = `${sessionId}-${randomUUID().slice(0, 8)}${EXTENSIONS[type] ?? '.jpg'}`; const target = previewPath(file); fs.mkdirSync(config.previewsDir, { recursive: true }); // Écriture puis renommage : le fichier n'apparaît sous son nom définitif // qu'entier. Servi à mi-écriture, il donnerait une image tronquée. const temp = `${target}.part`; fs.writeFileSync(temp, bytes); fs.renameSync(temp, target); sessionsRepo.setPreviewFile(sessionId, file); } /** Chemin du fichier à servir pour cette diffusion, ou `null`. */ export function previewFileFor(sessionId: number): string | null { const file = sessionsRepo.previewFile(sessionId); if (!file) return null; const full = previewPath(file); return fs.existsSync(full) ? full : null; } /** * Supprime les vignettes que plus aucune diffusion ne réclame. * * Ne plus suivre un profil efface ses diffusions en cascade côté SQLite, mais * SQLite ne connaît rien du disque : sans ce ménage, leurs images resteraient * indéfiniment, sans plus rien pour les désigner ni les afficher. */ export function sweepPreviews(): void { if (!fs.existsSync(config.previewsDir)) return; const referenced = sessionsRepo.allPreviewFiles(); // Une écriture en cours porte encore son nom provisoire et n'est référencée // nulle part : la fenêtre est d'une fraction de seconde, mais le ménage // tourne en parallèle des sondes, et rien ne garantit qu'ils ne se croisent // jamais. L'âge du fichier tranche sans avoir à les synchroniser. const cutoff = Date.now() - 3_600_000; let removed = 0; for (const entry of fs.readdirSync(config.previewsDir)) { if (referenced.has(entry)) continue; const full = path.join(config.previewsDir, entry); try { if (fs.statSync(full).mtimeMs > cutoff) continue; fs.unlinkSync(full); removed += 1; } catch { // Fichier disparu entre-temps, ou droits refusés : rien à signaler, le // prochain passage retombera dessus si le problème persiste. } } if (removed > 0) console.log(`previews · ${removed} vignette(s) orpheline(s) supprimée(s)`); }