Feat : preview timeline vignette
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
148
packages/server/src/previews.ts
Normal file
148
packages/server/src/previews.ts
Normal 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)`);
|
||||
}
|
||||
@@ -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)} »`);
|
||||
|
||||
Reference in New Issue
Block a user