Feat : preview timeline vignette
This commit is contained in:
@@ -24,3 +24,10 @@ AGENT_TIMEOUT_MS=15000
|
|||||||
|
|
||||||
# Duree de direct exigee avant un enregistrement automatique (ms).
|
# Duree de direct exigee avant un enregistrement automatique (ms).
|
||||||
AUTO_RECORD_DELAY_MS=60000
|
AUTO_RECORD_DELAY_MS=60000
|
||||||
|
|
||||||
|
# Vignettes de diffusion conservees (une par stream, montrees au survol de la frise)
|
||||||
|
PREVIEWS_DIR=./data/previews
|
||||||
|
|
||||||
|
# Attente avant de copier la vignette d'une diffusion qui demarre (ms).
|
||||||
|
# En deca, un stream trop court n'aura pas de vignette : il n'a rien a montrer.
|
||||||
|
PREVIEW_DELAY_MS=60000
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -112,6 +112,8 @@ node /opt/stream-control-agent/agent.cjs --check
|
|||||||
| `SESSION_SECRET` | Clé HMAC des sessions (32+ octets aléatoires) |
|
| `SESSION_SECRET` | Clé HMAC des sessions (32+ octets aléatoires) |
|
||||||
| `ENROLLMENT_TOKEN` | Jeton d'auto-enregistrement ; vide = désactivé |
|
| `ENROLLMENT_TOKEN` | Jeton d'auto-enregistrement ; vide = désactivé |
|
||||||
| `DB_PATH` | Fichier SQLite |
|
| `DB_PATH` | Fichier SQLite |
|
||||||
|
| `PREVIEWS_DIR` | Vignettes de diffusion conservées, une par stream |
|
||||||
|
| `PREVIEW_DELAY_MS` | Attente avant de copier la vignette d'un stream qui démarre |
|
||||||
| `STATUS_INTERVAL_MS` | Fréquence de remontée d'état des agents |
|
| `STATUS_INTERVAL_MS` | Fréquence de remontée d'état des agents |
|
||||||
| `AGENT_TIMEOUT_MS` | Délai avant de déclarer un agent hors-ligne |
|
| `AGENT_TIMEOUT_MS` | Délai avant de déclarer un agent hors-ligne |
|
||||||
|
|
||||||
@@ -669,6 +671,35 @@ gardée — deux pistes séparées obligeraient l'œil à faire l'alignement lui
|
|||||||
globale rend visibles les chevauchements : deux streamers en direct en même temps sur une
|
globale rend visibles les chevauchements : deux streamers en direct en même temps sur une
|
||||||
seule VM, et ce qui, faute de machine libre, est passé à côté.
|
seule VM, et ce qui, faute de machine libre, est passé à côté.
|
||||||
|
|
||||||
|
### Une vignette par diffusion, conservée
|
||||||
|
|
||||||
|
**Survoler une barre bleue montre une image de ce stream-là**, même des mois après. C'est ce
|
||||||
|
qui permet de reconnaître une diffusion d'un coup d'œil dans une frise qui, sans ça, n'aligne
|
||||||
|
que des rectangles interchangeables.
|
||||||
|
|
||||||
|
L'image est **copiée sur le serveur**, et non pointée vers la plateforme : la vignette servie
|
||||||
|
en direct sur l'onglet **Streamers** vient du CDN de Stripchat, qui la remplace toutes les
|
||||||
|
demi-heures environ. Passé la diffusion, son URL ne renvoie plus rien d'exploitable — rien de
|
||||||
|
ce qui s'affiche en direct ne survit au stream. D'où la copie, prise pendant qu'elle existe
|
||||||
|
encore.
|
||||||
|
|
||||||
|
Une seule par diffusion, jamais remplacée ensuite : c'est une illustration destinée à
|
||||||
|
reconnaître un stream, pas un suivi de son déroulé. Elle est prise **une minute après le début
|
||||||
|
du stream** (`PREVIEW_DELAY_MS`), pour la même raison que le délai d'amorçage de
|
||||||
|
l'enregistrement automatique : les premières secondes d'un flux montrent souvent un écran de
|
||||||
|
démarrage ou une pièce vide, et un 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'en aura donc pas.
|
||||||
|
|
||||||
|
Les fichiers vivent dans `data/previews/` (`PREVIEWS_DIR`), à côté de la base et non dedans —
|
||||||
|
ce sont des dizaines de kilo-octets chacun, que SQLite n'a aucune raison de porter. Ne plus
|
||||||
|
suivre un profil efface ses diffusions en cascade côté SQLite, mais SQLite ne connaît rien du
|
||||||
|
disque : un **ménage quotidien** (et au démarrage) supprime les images que plus aucune
|
||||||
|
diffusion ne réclame.
|
||||||
|
|
||||||
|
La route qui les sert accepte le jeton de session en paramètre d'URL — une balise `<img>` ne
|
||||||
|
permet pas d'ajouter un en-tête, comme pour le flux temps réel. Le fichier ne changeant jamais
|
||||||
|
une fois écrit, il est servi avec un cache immuable.
|
||||||
|
|
||||||
### Ce qui est enregistré, et comment
|
### Ce qui est enregistré, et comment
|
||||||
|
|
||||||
| Table | Écrite par | Une ligne = |
|
| Table | Écrite par | Une ligne = |
|
||||||
@@ -783,6 +814,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
|
|||||||
| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset |
|
| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset |
|
||||||
| `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme, favori, priorité, interruption |
|
| `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme, favori, priorité, interruption |
|
||||||
| `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils |
|
| `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils |
|
||||||
|
| `GET` | `/api/timeline/sessions/:id/preview` | Vignette conservée d'une diffusion (jeton accepté en query) |
|
||||||
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
|
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
|
||||||
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
|
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
|
||||||
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |
|
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { config } from './config.ts';
|
|||||||
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
|
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
|
||||||
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, targetsRepo } from './db.ts';
|
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, targetsRepo } from './db.ts';
|
||||||
import { hub } from './hub.ts';
|
import { hub } from './hub.ts';
|
||||||
|
import { previewFileFor } from './previews.ts';
|
||||||
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
|
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
|
||||||
import { startTargetRecording } from './recorder.ts';
|
import { startTargetRecording } from './recorder.ts';
|
||||||
import { watchlist } from './watchlist.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 -----------------------------------------------------------------
|
// --- Divers -----------------------------------------------------------------
|
||||||
|
|
||||||
/** Historique d'une VM : les mêmes entrées que le journal, filtrées et bornées. */
|
/** 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'),
|
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),
|
statusIntervalMs: int('STATUS_INTERVAL_MS', 2000),
|
||||||
|
|
||||||
/** Fréquence de sondage des profils surveillés (veille). */
|
/** 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', 'preview_url', 'TEXT');
|
||||||
addColumnIfMissing('watch_targets', 'snapshot_at', 'INTEGER');
|
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
|
* `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
|
* la pause dans les versions antérieures. Les agents créés avant ce changement
|
||||||
@@ -662,6 +667,11 @@ const sessionStmts = {
|
|||||||
sinceForTarget: db.prepare(
|
sinceForTarget: db.prepare(
|
||||||
'SELECT * FROM stream_sessions WHERE target_id = ? AND started_at >= ? ORDER BY started_at',
|
'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 {
|
interface SessionRow {
|
||||||
@@ -669,6 +679,7 @@ interface SessionRow {
|
|||||||
target_id: string;
|
target_id: string;
|
||||||
started_at: number;
|
started_at: number;
|
||||||
ended_at: number | null;
|
ended_at: number | null;
|
||||||
|
preview_file: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toSession = (row: SessionRow): StreamSession => ({
|
const toSession = (row: SessionRow): StreamSession => ({
|
||||||
@@ -676,6 +687,7 @@ const toSession = (row: SessionRow): StreamSession => ({
|
|||||||
targetId: row.target_id,
|
targetId: row.target_id,
|
||||||
startedAt: Number(row.started_at),
|
startedAt: Number(row.started_at),
|
||||||
endedAt: num(row.ended_at),
|
endedAt: num(row.ended_at),
|
||||||
|
hasPreview: Boolean(row.preview_file),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sessionsRepo = {
|
export const sessionsRepo = {
|
||||||
@@ -696,6 +708,17 @@ export const sessionsRepo = {
|
|||||||
sessionStmts.close.run(endedAt, targetId);
|
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[] {
|
since(from: number, targetId?: string | null): StreamSession[] {
|
||||||
const rows = (
|
const rows = (
|
||||||
targetId
|
targetId
|
||||||
@@ -704,6 +727,30 @@ export const sessionsRepo = {
|
|||||||
) as unknown as SessionRow[];
|
) as unknown as SessionRow[];
|
||||||
return rows.map(toSession);
|
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 {
|
interface SpanRow {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { verifySession } from './auth.ts';
|
|||||||
import { api } from './api.ts';
|
import { api } from './api.ts';
|
||||||
import { hub } from './hub.ts';
|
import { hub } from './hub.ts';
|
||||||
import { authenticateAgent, handleAgentConnection } from './agentGateway.ts';
|
import { authenticateAgent, handleAgentConnection } from './agentGateway.ts';
|
||||||
|
import { sweepPreviews } from './previews.ts';
|
||||||
import { watchlist } from './watchlist.ts';
|
import { watchlist } from './watchlist.ts';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -92,6 +93,13 @@ const heartbeat = setInterval(() => {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
heartbeat.unref();
|
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();
|
watchlist.start();
|
||||||
|
|
||||||
server.listen(config.port, config.host, () => {
|
server.listen(config.port, config.host, () => {
|
||||||
@@ -106,6 +114,7 @@ server.listen(config.port, config.host, () => {
|
|||||||
function shutdown(signal: string): void {
|
function shutdown(signal: string): void {
|
||||||
console.log(`\n${signal} reçu, arrêt…`);
|
console.log(`\n${signal} reçu, arrêt…`);
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
|
clearInterval(previewSweep);
|
||||||
server.close(() => process.exit(0));
|
server.close(() => process.exit(0));
|
||||||
setTimeout(() => process.exit(1), 5000).unref();
|
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 { config } from './config.ts';
|
||||||
import { sessionsRepo, targetsRepo } from './db.ts';
|
import { sessionsRepo, targetsRepo } from './db.ts';
|
||||||
import { hub } from './hub.ts';
|
import { hub } from './hub.ts';
|
||||||
|
import { capturePreview } from './previews.ts';
|
||||||
import { notifyTargetLive } from './pushover.ts';
|
import { notifyTargetLive } from './pushover.ts';
|
||||||
import { startTargetRecording } from './recorder.ts';
|
import { startTargetRecording } from './recorder.ts';
|
||||||
|
|
||||||
@@ -142,6 +143,15 @@ class Watchlist {
|
|||||||
hub.publishTarget(updated);
|
hub.publishTarget(updated);
|
||||||
this.maybeAutoRecord(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) {
|
if (changed) {
|
||||||
const name = updated.label ?? updated.username;
|
const name = updated.label ?? updated.username;
|
||||||
hub.log(null, 'info', `Veille : ${name} est passé « ${labelOf(next)} »`);
|
hub.log(null, 'info', `Veille : ${name} est passé « ${labelOf(next)} »`);
|
||||||
|
|||||||
@@ -949,6 +949,16 @@ export interface StreamSession {
|
|||||||
startedAt: number;
|
startedAt: number;
|
||||||
/** Nul tant que la diffusion est en cours. */
|
/** Nul tant que la diffusion est en cours. */
|
||||||
endedAt: number | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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). */
|
/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */
|
||||||
export function dashboardSocketUrl(): string {
|
export function dashboardSocketUrl(): string {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { RecordingSpan, StreamSession } from '@stream-control/shared';
|
import type { RecordingSpan, StreamSession } from '@stream-control/shared';
|
||||||
|
import { sessionPreviewSrc } from '../api';
|
||||||
import { formatDuration } from '../format';
|
import { formatDuration } from '../format';
|
||||||
|
|
||||||
export interface TimelineLane {
|
export interface TimelineLane {
|
||||||
@@ -28,8 +29,13 @@ interface Bar {
|
|||||||
ms: number;
|
ms: number;
|
||||||
/** Toujours en cours au moment du rendu. */
|
/** Toujours en cours au moment du rendu. */
|
||||||
open: boolean;
|
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.
|
* 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 ticks = useMemo(() => buildTicks(from, to), [from, to]);
|
||||||
const nowLeft = now >= from && now <= to ? percent(now, from, to) : null;
|
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 (
|
return (
|
||||||
<div className={hideLabels ? 'timeline timeline-bare' : 'timeline'}>
|
<div className={hideLabels ? 'timeline timeline-bare' : 'timeline'}>
|
||||||
<div className="timeline-axis">
|
<div className="timeline-axis">
|
||||||
@@ -113,14 +128,32 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{sessions.map((bar) => (
|
{sessions.map((bar) => {
|
||||||
|
const showPreview =
|
||||||
|
bar.previewId !== undefined && !broken.has(bar.previewId);
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
key={bar.key}
|
key={bar.key}
|
||||||
className={bar.open ? 'timeline-bar live' : 'timeline-bar'}
|
className={bar.open ? 'timeline-bar live' : 'timeline-bar'}
|
||||||
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
|
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
|
||||||
title={bar.title}
|
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) => (
|
{spans.map((bar) => (
|
||||||
<span
|
<span
|
||||||
@@ -142,10 +175,45 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
|
|||||||
{lanes.length === 0 && (
|
{lanes.length === 0 && (
|
||||||
<p className="muted small">Aucune diffusion observée sur cette période.</p>
|
<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>
|
</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;
|
const percent = (at: number, from: number, to: number) => ((at - from) / (to - from)) * 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,6 +240,9 @@ function toBars(
|
|||||||
key: `${kind}-${interval.id}`,
|
key: `${kind}-${interval.id}`,
|
||||||
left: percent(start, from, to),
|
left: percent(start, from, to),
|
||||||
width: percent(end, from, to) - 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
|
// Conservé à part des pourcentages : repasser de la largeur affichée aux
|
||||||
// millisecondes ferait perdre une minute sur deux heures, et le total
|
// millisecondes ferait perdre une minute sur deux heures, et le total
|
||||||
// annoncé ne collerait plus aux durées listées juste à côté.
|
// annoncé ne collerait plus aux durées listées juste à côté.
|
||||||
|
|||||||
@@ -976,6 +976,32 @@ fieldset.group > legend {
|
|||||||
background: var(--rec);
|
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 --- */
|
/* --- Journal --- */
|
||||||
|
|
||||||
/* Vue pleine page depuis l'onglet Journal. Elle défile avec la page, comme la
|
/* Vue pleine page depuis l'onglet Journal. Elle défile avec la page, comme la
|
||||||
|
|||||||
Reference in New Issue
Block a user