149 lines
5.9 KiB
TypeScript
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)`);
|
|
}
|