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)} »`);

View File

@@ -949,6 +949,16 @@ export interface StreamSession {
startedAt: number;
/** Nul tant que la diffusion est en cours. */
endedAt: number | null;
/**
* Une vignette de cette diffusion a été copiée sur le serveur, et reste donc
* consultable des mois plus tard.
*
* Nécessaire parce que `WatchTarget.previewUrl` ne vaut que sur l'instant :
* elle pointe vers le CDN de la plateforme, dont l'image expire en une
* trentaine de minutes. Rien de ce qui est affiché en direct ne survit à la
* diffusion elle-même — d'où cette copie, prise une seule fois par diffusion.
*/
hasPreview: boolean;
}
/**

View File

@@ -166,6 +166,17 @@ export const api = {
}),
};
/**
* Vignette conservée d'une diffusion, à poser dans un `src` d'image.
*
* Jeton en query et non en en-tête : une balise `<img>` ne permet pas d'en
* ajouter un, et cette route l'accepte pour cette raison — comme le flux
* temps réel plus bas.
*/
export function sessionPreviewSrc(sessionId: number): string {
return `/api/timeline/sessions/${sessionId}/preview?token=${encodeURIComponent(getToken() ?? '')}`;
}
/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */
export function dashboardSocketUrl(): string {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import type { RecordingSpan, StreamSession } from '@stream-control/shared';
import { sessionPreviewSrc } from '../api';
import { formatDuration } from '../format';
export interface TimelineLane {
@@ -28,8 +29,13 @@ interface Bar {
ms: number;
/** Toujours en cours au moment du rendu. */
open: boolean;
/** Diffusion dont une vignette a été conservée : à montrer au survol. */
previewId?: number;
}
/** Largeur de la vignette de survol, en pixels ; la hauteur suit le 16/9. */
const PREVIEW_WIDTH = 260;
/**
* Frise des diffusions, avec les portions capturées en surimpression.
*
@@ -46,6 +52,15 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
const ticks = useMemo(() => buildTicks(from, to), [from, to]);
const nowLeft = now >= from && now <= to ? percent(now, from, to) : null;
const [preview, setPreview] = useState<{ id: number; x: number; y: number } | null>(null);
/**
* Vignettes annoncées par le serveur mais introuvables à l'affichage — un
* fichier disparu du disque, par exemple. Mémorisées pour ne plus les
* redemander : sans ça, chaque mouvement de souris sur la barre relancerait
* la requête, échouerait, et bouclerait.
*/
const [broken, setBroken] = useState<Set<number>>(new Set());
return (
<div className={hideLabels ? 'timeline timeline-bare' : 'timeline'}>
<div className="timeline-axis">
@@ -113,14 +128,32 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
/>
))}
{sessions.map((bar) => (
<span
key={bar.key}
className={bar.open ? 'timeline-bar live' : 'timeline-bar'}
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
title={bar.title}
/>
))}
{sessions.map((bar) => {
const showPreview =
bar.previewId !== undefined && !broken.has(bar.previewId);
return (
<span
key={bar.key}
className={bar.open ? 'timeline-bar live' : 'timeline-bar'}
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
title={bar.title}
// `mousemove` autant que `mouseenter` : la vignette suit le
// curseur le long de la barre, qui peut traverser tout
// l'écran sur une longue diffusion.
onMouseEnter={
showPreview
? (event) => setPreview({ id: bar.previewId!, ...place(event) })
: undefined
}
onMouseMove={
showPreview
? (event) => setPreview({ id: bar.previewId!, ...place(event) })
: undefined
}
onMouseLeave={showPreview ? () => setPreview(null) : undefined}
/>
);
})}
{spans.map((bar) => (
<span
@@ -142,10 +175,45 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
{lanes.length === 0 && (
<p className="muted small">Aucune diffusion observée sur cette période.</p>
)}
{/* Hors de la piste, et en `fixed` : `.timeline-track` recadre son
contenu (`overflow: hidden`) pour que les barres ne débordent pas de
la fenêtre affichée — une vignette posée dedans s'en trouverait
rognée, et son ancrage suivrait le défilement horizontal de la frise. */}
{preview && (
<div
className="timeline-preview"
style={{ left: preview.x, top: preview.y, width: PREVIEW_WIDTH }}
>
<img
src={sessionPreviewSrc(preview.id)}
alt=""
onError={() => setBroken((current) => new Set(current).add(preview.id))}
/>
</div>
)}
</div>
);
}
/**
* Position de la vignette près du curseur, sans jamais sortir de la fenêtre.
*
* Au-dessus du curseur par défaut — la frise se lit de haut en bas, et une
* vignette posée dessous masquerait les voies suivantes. En dessous seulement
* quand le haut manque de place, sur la première ligne de la frise.
*/
function place(event: { clientX: number; clientY: number }): { x: number; y: number } {
const height = (PREVIEW_WIDTH * 9) / 16;
const margin = 8;
const x = Math.min(
Math.max(event.clientX - PREVIEW_WIDTH / 2, margin),
window.innerWidth - PREVIEW_WIDTH - margin,
);
const above = event.clientY - height - 16;
return { x, y: above >= margin ? above : event.clientY + 16 };
}
const percent = (at: number, from: number, to: number) => ((at - from) / (to - from)) * 100;
/**
@@ -172,6 +240,9 @@ function toBars(
key: `${kind}-${interval.id}`,
left: percent(start, from, to),
width: percent(end, from, to) - percent(start, from, to),
// Seules les diffusions en ont une ; les intervalles de capture partagent
// cette fonction mais ne portent pas le champ.
previewId: 'hasPreview' in interval && interval.hasPreview ? interval.id : undefined,
// Conservé à part des pourcentages : repasser de la largeur affichée aux
// millisecondes ferait perdre une minute sur deux heures, et le total
// annoncé ne collerait plus aux durées listées juste à côté.

View File

@@ -976,6 +976,32 @@ fieldset.group > legend {
background: var(--rec);
}
/*
* Vignette conservée d'une diffusion, montrée au survol de sa barre. En `fixed`
* et positionnée depuis le curseur : la piste qui l'accueillerait sinon recadre
* son contenu et défile horizontalement.
*/
.timeline-preview {
position: fixed;
z-index: 60;
padding: 3px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
/* Le curseur la traverserait sinon en la survolant, ce qui déclencherait le
`mouseleave` de la barre : la vignette clignoterait sous la souris. */
pointer-events: none;
}
.timeline-preview img {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 4px;
background: var(--panel-2);
}
/* --- Journal --- */
/* Vue pleine page depuis l'onglet Journal. Elle défile avec la page, comme la