Feat : preview timeline vignette
All checks were successful
release / build (push) Successful in 29s
release / verify-windows (push) Successful in 1m24s

This commit is contained in:
jeanotx32
2026-08-12 22:07:39 +02:00
parent 8d45342727
commit cbbf31cabf
12 changed files with 418 additions and 9 deletions

View File

@@ -15,6 +15,7 @@ import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { previewFileFor } from './previews.ts';
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
import { startTargetRecording } from './recorder.ts';
import { watchlist } from './watchlist.ts';
@@ -390,6 +391,26 @@ api.get('/timeline', (req, res) => {
});
});
/**
* Vignette copiée d'une diffusion — l'illustration montrée au survol de la frise.
*
* Servie par le serveur plutôt que pointée vers le CDN de la plateforme : celui-ci
* remplace l'image toutes les demi-heures environ, et l'URL d'une diffusion passée
* ne renvoie plus rien. Le fichier, lui, ne change jamais une fois écrit — d'où le
* cache immuable : le navigateur ne redemandera pas la même image en survolant
* deux fois la même barre.
*/
api.get('/timeline/sessions/:id/preview', (req, res) => {
const id = Number.parseInt(req.params.id, 10);
const file = Number.isFinite(id) ? previewFileFor(id) : null;
if (!file) {
res.status(404).json({ error: 'Aucune vignette pour cette diffusion' });
return;
}
res.setHeader('cache-control', 'private, max-age=604800, immutable');
res.sendFile(file);
});
// --- Divers -----------------------------------------------------------------
/** Historique d'une VM : les mêmes entrées que le journal, filtrées et bornées. */

View File

@@ -44,6 +44,23 @@ export const config = {
dbPath: path.resolve(repoRoot, process.env.DB_PATH ?? './data/stream-control.sqlite'),
/**
* Vignettes de diffusion copiées depuis le CDN de la plateforme, une par
* diffusion. À côté de la base et non dedans : ce sont des dizaines de
* kilo-octets chacune, que SQLite n'a aucune raison de porter.
*/
previewsDir: path.resolve(repoRoot, process.env.PREVIEWS_DIR ?? './data/previews'),
/**
* Attente avant de copier la vignette d'une diffusion qui démarre.
*
* Même raison que `autoRecordDelayMs` : les premières secondes d'un flux
* montrent souvent un écran de démarrage ou une pièce vide, et un faux départ
* (passage éclair en public au sortir d'un show privé) ne mérite pas
* d'illustration. Une diffusion plus courte que ce délai n'aura pas de
* vignette — assumé, elle n'a rien à montrer.
*/
previewDelayMs: int('PREVIEW_DELAY_MS', 60_000),
statusIntervalMs: int('STATUS_INTERVAL_MS', 2000),
/** Fréquence de sondage des profils surveillés (veille). */

View File

@@ -165,6 +165,11 @@ addColumnIfMissing('watch_targets', 'preempt', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfMissing('watch_targets', 'preview_url', 'TEXT');
addColumnIfMissing('watch_targets', 'snapshot_at', 'INTEGER');
// Nom du fichier de vignette copié pour cette diffusion, dans config.previewsDir.
// Le nom plutôt qu'un chemin complet : le répertoire est un réglage, et une base
// déplacée d'une machine à l'autre ne doit pas traîner les chemins de l'ancienne.
addColumnIfMissing('stream_sessions', 'preview_file', 'TEXT');
/**
* `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de
* la pause dans les versions antérieures. Les agents créés avant ce changement
@@ -662,6 +667,11 @@ const sessionStmts = {
sinceForTarget: db.prepare(
'SELECT * FROM stream_sessions WHERE target_id = ? AND started_at >= ? ORDER BY started_at',
),
byId: db.prepare('SELECT * FROM stream_sessions WHERE id = ?'),
setPreview: db.prepare('UPDATE stream_sessions SET preview_file = ? WHERE id = ?'),
previewFiles: db.prepare(
'SELECT preview_file FROM stream_sessions WHERE preview_file IS NOT NULL',
),
};
interface SessionRow {
@@ -669,6 +679,7 @@ interface SessionRow {
target_id: string;
started_at: number;
ended_at: number | null;
preview_file: string | null;
}
const toSession = (row: SessionRow): StreamSession => ({
@@ -676,6 +687,7 @@ const toSession = (row: SessionRow): StreamSession => ({
targetId: row.target_id,
startedAt: Number(row.started_at),
endedAt: num(row.ended_at),
hasPreview: Boolean(row.preview_file),
});
export const sessionsRepo = {
@@ -696,6 +708,17 @@ export const sessionsRepo = {
sessionStmts.close.run(endedAt, targetId);
},
/** La diffusion en cours de ce profil, ou `null` s'il n'y en a pas. */
openFor(targetId: string): StreamSession | null {
const row = sessionStmts.openFor.get(targetId) as unknown as SessionRow | undefined;
return row ? toSession(row) : null;
},
get(id: number): StreamSession | null {
const row = sessionStmts.byId.get(id) as unknown as SessionRow | undefined;
return row ? toSession(row) : null;
},
since(from: number, targetId?: string | null): StreamSession[] {
const rows = (
targetId
@@ -704,6 +727,30 @@ export const sessionsRepo = {
) as unknown as SessionRow[];
return rows.map(toSession);
},
/** Rattache le fichier de vignette copié pour cette diffusion. */
setPreviewFile(id: number, file: string): void {
sessionStmts.setPreview.run(file, id);
},
/** Nom du fichier de vignette, ou `null` si cette diffusion n'en a pas. */
previewFile(id: number): string | null {
const row = sessionStmts.byId.get(id) as unknown as SessionRow | undefined;
return row?.preview_file ?? null;
},
/**
* Tous les fichiers de vignette encore référencés.
*
* Sert au ménage sur disque : la suppression d'un profil emporte ses
* diffusions en cascade côté SQLite, mais laisse leurs fichiers orphelins —
* rien dans la base ne les désigne plus, et sans cette liste il n'existerait
* aucun moyen de savoir lesquels sont devenus inutiles.
*/
allPreviewFiles(): Set<string> {
const rows = sessionStmts.previewFiles.all() as unknown as Array<{ preview_file: string }>;
return new Set(rows.map((row) => row.preview_file));
},
};
interface SpanRow {

View File

@@ -8,6 +8,7 @@ import { verifySession } from './auth.ts';
import { api } from './api.ts';
import { hub } from './hub.ts';
import { authenticateAgent, handleAgentConnection } from './agentGateway.ts';
import { sweepPreviews } from './previews.ts';
import { watchlist } from './watchlist.ts';
const app = express();
@@ -92,6 +93,13 @@ const heartbeat = setInterval(() => {
}, 5000);
heartbeat.unref();
// Ménage des vignettes devenues orphelines (profil qu'on ne suit plus). Une
// fois par jour suffit : rien ne presse à quelques heures près, et le passage
// au démarrage rattrape les suppressions faites serveur éteint.
sweepPreviews();
const previewSweep = setInterval(sweepPreviews, 24 * 3_600_000);
previewSweep.unref();
watchlist.start();
server.listen(config.port, config.host, () => {
@@ -106,6 +114,7 @@ server.listen(config.port, config.host, () => {
function shutdown(signal: string): void {
console.log(`\n${signal} reçu, arrêt…`);
clearInterval(heartbeat);
clearInterval(previewSweep);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();
}

View File

@@ -0,0 +1,148 @@
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)`);
}

View File

@@ -7,6 +7,7 @@ import {
import { config } from './config.ts';
import { sessionsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { capturePreview } from './previews.ts';
import { notifyTargetLive } from './pushover.ts';
import { startTargetRecording } from './recorder.ts';
@@ -142,6 +143,15 @@ class Watchlist {
hub.publishTarget(updated);
this.maybeAutoRecord(updated);
// Détaché du cycle de sonde : un CDN lent ne doit pas retarder les profils
// suivants, et une image manquée n'est jamais une raison d'interrompre la
// veille. Le compte rendu part au journal, sans évènement typé — c'est une
// illustration, pas un incident d'exploitation.
void capturePreview(updated).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
hub.log(null, 'warn', `Vignette de ${updated.username} non copiée : ${message}`);
});
if (changed) {
const name = updated.label ?? updated.username;
hub.log(null, 'info', `Veille : ${name} est passé « ${labelOf(next)} »`);