Files
stream-control/packages/server/src/previews.ts
jeanotx32 cbbf31cabf
All checks were successful
release / build (push) Successful in 29s
release / verify-windows (push) Successful in 1m24s
Feat : preview timeline vignette
2026-08-12 22:07:39 +02:00

149 lines
5.9 KiB
TypeScript

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<string, string> = {
'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<number>();
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<void> {
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<void> {
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)`);
}