Feat : added and tweaked state machine
All checks were successful
release / build (push) Successful in 39s
release / verify-windows (push) Successful in 1m17s

This commit is contained in:
jeanotx32
2026-08-11 23:21:35 +02:00
parent a5777ad920
commit 2380196169
14 changed files with 469 additions and 53 deletions

View File

@@ -21,3 +21,6 @@ STATUS_INTERVAL_MS=2000
# Délai avant de considérer un agent comme hors-ligne (ms) # Délai avant de considérer un agent comme hors-ligne (ms)
AGENT_TIMEOUT_MS=15000 AGENT_TIMEOUT_MS=15000
# Duree de direct exigee avant un enregistrement automatique (ms).
AUTO_RECORD_DELAY_MS=60000

View File

@@ -30,6 +30,10 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
- Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur). - Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur).
- [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés) - [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés)
(Stripchat), avec reprise et rappel du plein écran au retour du flux public. (Stripchat), avec reprise et rappel du plein écran au retour du flux public.
- [Clôture différée sur passage hors-ligne](#passage-hors-ligne) : une coupure brève
ne découpe pas le fichier, une vraie fin de diffusion le termine.
- [Enregistrement automatique](#enregistrement-automatique) d'un streamer sur une VM
dédiée, dès qu'il est en direct depuis assez longtemps.
- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface, - [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface,
réglables VM par VM : de « Économe » à « Sans perte ». réglables VM par VM : de « Économe » à « Sans perte ».
@@ -235,6 +239,45 @@ aucun autre moyen de connaître.
La rétention est celle du journal global (`LOG_RETENTION`) : les entrées les plus La rétention est celle du journal global (`LOG_RETENTION`) : les entrées les plus
anciennes sont purgées, toutes VM confondues. anciennes sont purgées, toutes VM confondues.
## Passage hors-ligne
Quand le statut brut passe à « off » (ou « idle »), deux temps distincts :
1. **Pause immédiate.** L'écran d'attente n'a rien à faire dans le fichier. Aucun délai,
aucune confirmation : reprendre ne coûte rien si la lecture était fausse.
2. **Clôture différée**, après le délai réglé sur la fiche de l'agent (1 h par défaut).
Une coupure de quelques minutes est fréquente ; clore tout de suite découperait le
fichier en deux. Le compte à rebours est annulé dès que le flux revient, public **ou**
privé, et l'enregistrement reprend dans le même fichier.
Le décompte s'affiche sur la fiche de l'agent. La clôture ferme aussi la fenêtre du
navigateur si son pilotage est activé, contrairement à une simple pause.
Régler le délai à `0` clôt dès la première lecture hors-ligne ; décocher « Clore
l'enregistrement quand le flux passe hors-ligne » désactive les deux temps, y compris la
pause.
## Enregistrement automatique
Sur la fiche d'un streamer (onglet Streamers), assigne un agent puis coche
**Enregistrer automatiquement**. Dès que le profil est en direct depuis `AUTO_RECORD_DELAY_MS`
(60 s par défaut), le serveur lance la capture sur cet agent, exactement comme le bouton
« Enregistrer » — surveillance des shows privés comprise.
Trois règles cadrent l'automatisme :
- **Un streamer par VM.** Activer l'automatisme sur un agent déjà pris est refusé, avec le
nom du profil qui l'occupe.
- **Aucune préemption.** Si la VM enregistre déjà quoi que ce soit, l'automatisme passe son
tour plutôt que d'écraser la capture en cours.
- **Trois tentatives par diffusion.** Les échecs restants une fois ces garde-fous passés
(OBS injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme
abandonne jusqu'à la diffusion suivante et le dit dans le journal.
Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort d'un show privé
repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la
première lecture produirait des fichiers de dix secondes.
## API HTTP ## API HTTP
Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de session>`. Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de session>`.
@@ -245,6 +288,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
| `GET` | `/api/agents` | Liste des agents et de leur état | | `GET` | `/api/agents` | Liste des agents et de leur état |
| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** | | `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** |
| `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 |
| `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 |

View File

@@ -59,6 +59,11 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
resumeRecording: async () => { resumeRecording: async () => {
await obs.execute('record.resume'); await obs.execute('record.resume');
}, },
// Clôture, et non simple `record.stop` : la fenêtre du navigateur doit se
// fermer aussi, sans quoi la VM resterait sur une page morte.
stopRecording: async () => {
await stopCapture();
},
}); });
let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS; let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS;

View File

@@ -26,6 +26,8 @@ export interface WatchActions {
recordState(): Promise<{ active: boolean; paused: boolean }>; recordState(): Promise<{ active: boolean; paused: boolean }>;
pauseRecording(): Promise<void>; pauseRecording(): Promise<void>;
resumeRecording(): Promise<void>; resumeRecording(): Promise<void>;
/** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */
stopRecording(): Promise<void>;
} }
/** /**
@@ -44,6 +46,8 @@ export class StreamWatcher extends EventEmitter {
private state: WatchState; private state: WatchState;
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private fullscreenTimer: NodeJS.Timeout | null = null; private fullscreenTimer: NodeJS.Timeout | null = null;
/** Compte à rebours de clôture, armé tant que le flux reste hors-ligne. */
private offlineTimer: NodeJS.Timeout | null = null;
private ticking = false; private ticking = false;
/** Le flux public a été interrompu : il faudra rappeler le plein écran. */ /** Le flux public a été interrompu : il faudra rappeler le plein écran. */
private fullscreenPending = false; private fullscreenPending = false;
@@ -88,6 +92,7 @@ export class StreamWatcher extends EventEmitter {
this.state.pendingConfirmations = 0; this.state.pendingConfirmations = 0;
this.state.autoPaused = false; this.state.autoPaused = false;
this.fullscreenPending = false; this.fullscreenPending = false;
this.cancelOfflineStop();
} }
if (restart) this.start(); if (restart) this.start();
} }
@@ -114,6 +119,7 @@ export class StreamWatcher extends EventEmitter {
this.timer = null; this.timer = null;
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer); if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
this.fullscreenTimer = null; this.fullscreenTimer = null;
this.cancelOfflineStop();
} }
/** Sonde immédiate, utilisée par l'action `watch.check`. */ /** Sonde immédiate, utilisée par l'action `watch.check`. */
@@ -167,6 +173,9 @@ export class StreamWatcher extends EventEmitter {
this.state.pendingConfirmations = next === 'private' ? this.state.pendingConfirmations + 1 : 0; this.state.pendingConfirmations = next === 'private' ? this.state.pendingConfirmations + 1 : 0;
// Le retour du flux, public ou privé, annule toute clôture programmée.
if (next !== 'offline') this.cancelOfflineStop();
if (next === 'private') { if (next === 'private') {
// Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît. // Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît.
this.fullscreenPending = true; this.fullscreenPending = true;
@@ -174,8 +183,110 @@ export class StreamWatcher extends EventEmitter {
return; return;
} }
if (next === 'public') await this.handlePublic(); if (next === 'public') {
// 'offline' / 'unknown' : on ne touche à rien, l'opérateur reste maître. await this.handlePublic();
return;
}
if (next === 'offline') {
await this.handleOffline();
return;
}
// 'unknown' : sonde sans verdict exploitable, on ne touche à rien.
}
/**
* Flux hors-ligne : pause immédiate, clôture différée.
*
* Les deux temps répondent à deux risques distincts. Enregistrer l'écran
* d'attente ne sert à rien, d'où la pause sans délai. Mais une coupure de
* quelques minutes est fréquente, et clore tout de suite découperait le
* fichier en deux — d'où le compte à rebours avant l'arrêt réel.
*/
private async handleOffline(): Promise<void> {
if (!this.settings.stopOnOffline) return;
this.state.offlineSince ??= Date.now();
try {
const record = await this.actions.recordState();
if (!record.active) {
// Rien à clore : inutile d'armer quoi que ce soit.
this.cancelOfflineStop();
return;
}
// Le garde `autoPaused` évite de rejouer la pause à chaque sonde, et donc
// de lutter contre un opérateur qui aurait repris la main.
if (!this.state.autoPaused && !record.paused) {
await this.actions.pauseRecording();
this.state.autoPaused = true;
this.emit('log', 'info', 'Flux hors-ligne : enregistrement mis en pause', 'record.paused');
}
} catch (err) {
this.emit(
'log',
'error',
`Pause hors-ligne impossible : ${err instanceof Error ? err.message : String(err)}`,
'command.failed',
);
return;
}
this.scheduleOfflineStop();
}
private scheduleOfflineStop(): void {
if (this.offlineTimer) return; // déjà armé pour cette coupure
const delayMs = this.settings.offlineStopDelayMs;
this.state.stopScheduledAt = Date.now() + delayMs;
this.emit(
'log',
'info',
`Clôture de l'enregistrement dans ${formatDelay(delayMs)} si le flux ne revient pas`,
'watch.offline',
);
this.offlineTimer = setTimeout(() => {
this.offlineTimer = null;
void this.stopForOffline();
}, delayMs);
this.offlineTimer.unref?.();
}
private cancelOfflineStop(): void {
if (this.offlineTimer) clearTimeout(this.offlineTimer);
this.offlineTimer = null;
this.state.offlineSince = undefined;
this.state.stopScheduledAt = undefined;
}
private async stopForOffline(): Promise<void> {
const offlineFor = this.state.offlineSince ? Date.now() - this.state.offlineSince : 0;
this.state.stopScheduledAt = undefined;
// Le flux a pu revenir entre l'armement et l'échéance.
if (this.state.state !== 'offline') return;
try {
const record = await this.actions.recordState();
if (!record.active) return;
await this.actions.stopRecording();
this.state.autoPaused = false;
this.emit(
'log',
'info',
`Flux hors-ligne depuis ${formatDelay(offlineFor)} : enregistrement clos`,
'record.stopped',
);
} catch (err) {
this.emit(
'log',
'error',
`Clôture automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
'command.failed',
);
}
} }
private async handlePrivate(): Promise<void> { private async handlePrivate(): Promise<void> {
@@ -268,6 +379,16 @@ const TRANSITIONS: Record<StreamState, AgentEvent | undefined> = {
unknown: undefined, unknown: undefined,
}; };
/** Durée lisible pour les messages de journal : « 1 h », « 12 min », « 45 s ». */
function formatDelay(ms: number): string {
if (ms >= 3_600_000) {
const hours = ms / 3_600_000;
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} h`;
}
if (ms >= 60_000) return `${Math.round(ms / 60_000)} min`;
return `${Math.round(ms / 1000)} s`;
}
function label(state: StreamState): string { function label(state: StreamState): string {
switch (state) { switch (state) {
case 'public': case 'public':

View File

@@ -14,6 +14,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, targetsRepo } from './db.ts'; import { agentsRepo, logsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts'; import { hub } from './hub.ts';
import { startTargetRecording } from './recorder.ts';
import { watchlist } from './watchlist.ts'; import { watchlist } from './watchlist.ts';
export const api: Router = Router(); export const api: Router = Router();
@@ -249,13 +250,38 @@ api.patch('/watchlist/:id', (req, res) => {
return; return;
} }
const agentId =
req.body?.agentId === null || typeof req.body?.agentId === 'string'
? req.body.agentId
: target.agentId;
const autoRecord =
typeof req.body?.autoRecord === 'boolean' ? req.body.autoRecord : target.autoRecord;
if (autoRecord && !agentId) {
res.status(400).json({ error: "L'automatisme exige un agent assigné" });
return;
}
// Un streamer par VM : deux automatismes sur le même agent se disputeraient la
// machine, et le second échouerait systématiquement sur « déjà en
// enregistrement ». Autant le refuser ici, où l'on peut l'expliquer.
if (autoRecord && agentId) {
const conflict = targetsRepo
.list()
.find((other) => other.id !== target.id && other.autoRecord && other.agentId === agentId);
if (conflict) {
res.status(409).json({
error: `« ${conflict.label ?? conflict.username} » est déjà en automatisme sur cet agent`,
});
return;
}
}
targetsRepo.updateSettings(target.id, { targetsRepo.updateSettings(target.id, {
label: typeof req.body?.label === 'string' ? req.body.label.trim() || null : target.label, label: typeof req.body?.label === 'string' ? req.body.label.trim() || null : target.label,
agentId: agentId,
req.body?.agentId === null || typeof req.body?.agentId === 'string'
? req.body.agentId
: target.agentId,
notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify, notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify,
autoRecord,
}); });
const updated = targetsRepo.get(target.id); const updated = targetsRepo.get(target.id);
@@ -300,58 +326,26 @@ api.post('/watchlist/:id/record', async (req, res) => {
res.status(404).json({ error: 'Profil surveillé introuvable' }); res.status(404).json({ error: 'Profil surveillé introuvable' });
return; return;
} }
if (!target.agentId) {
res.status(400).json({ error: 'Aucun agent assigné à ce profil' });
return;
}
const record = agentsRepo.get(target.agentId);
if (!record) {
res.status(404).json({ error: "L'agent assigné n'existe plus" });
return;
}
agentsRepo.updateSettings(record.id, {
name: record.name,
obs: record.obs,
autoConnectObs: record.autoConnectObs,
watch: normalizeWatchSettings({ ...record.watch, enabled: true, username: target.username }),
browser: record.browser,
recording: record.recording,
notes: record.notes,
});
const configured = agentsRepo.get(record.id);
if (configured) {
hub.pushConfig(configured);
hub.publishAgent(configured.id);
}
try { try {
// Avec le pilotage du navigateur, un seul appel enchaîne ouverture de la await startTargetRecording(target);
// page, plein écran et enregistrement. Sinon on se contente de lancer OBS,
// en supposant la page déjà ouverte par l'opérateur.
if (record.browser.enabled) {
// La séquence attend le chargement de la page : le délai d'attente doit
// dépasser readyDelayMs, sinon la commande expire avant d'avoir abouti.
await hub.sendCommand(
record.id,
'capture.start',
{ url: target.url },
record.browser.readyDelayMs + 30_000,
);
} else {
await hub.sendCommand(record.id, 'record.start');
}
hub.log( hub.log(
record.id, target.agentId,
'info', 'info',
`Enregistrement de « ${target.username} » démarré depuis la veille`, `Enregistrement de « ${target.username} » démarré depuis la veille`,
Date.now(),
'capture.started',
); );
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
hub.log(record.id, 'error', `Démarrage de « ${target.username} » en échec : ${message}`); hub.log(
target.agentId,
'error',
`Démarrage de « ${target.username} » en échec : ${message}`,
Date.now(),
'command.failed',
);
res.status(502).json({ ok: false, error: message }); res.status(502).json({ ok: false, error: message });
} }
}); });

View File

@@ -53,6 +53,12 @@ export const config = {
/** Nombre d'entrées de journal conservées en base. */ /** Nombre d'entrées de journal conservées en base. */
logRetention: int('LOG_RETENTION', 2000), logRetention: int('LOG_RETENTION', 2000),
/**
* Durée de direct exigée avant qu'un enregistrement automatique se déclenche.
* Une minute filtre les faux départs : reconnexions, tests de flux, passages
* éclair en public au sortir d'un show privé.
*/
autoRecordDelayMs: int('AUTO_RECORD_DELAY_MS', 60_000),
webDist: path.resolve(repoRoot, 'packages/web/dist'), webDist: path.resolve(repoRoot, 'packages/web/dist'),
} as const; } as const;

View File

@@ -108,6 +108,9 @@ addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT');
addColumnIfMissing('watch_targets', 'status_changed_at', 'INTEGER'); addColumnIfMissing('watch_targets', 'status_changed_at', 'INTEGER');
addColumnIfMissing('watch_targets', 'last_live_started_at', 'INTEGER'); addColumnIfMissing('watch_targets', 'last_live_started_at', 'INTEGER');
addColumnIfMissing('watch_targets', 'last_live_ended_at', 'INTEGER'); addColumnIfMissing('watch_targets', 'last_live_ended_at', 'INTEGER');
// Enregistrement automatique dès que le profil est en direct depuis assez
// longtemps. Desactivé par défaut : rien ne doit s'enclencher sans un geste.
addColumnIfMissing('watch_targets', 'auto_record', 'INTEGER NOT NULL DEFAULT 0');
export interface AgentRow { export interface AgentRow {
id: string; id: string;
@@ -332,6 +335,7 @@ interface TargetRow {
label: string | null; label: string | null;
agent_id: string | null; agent_id: string | null;
notify: number; notify: number;
auto_record: number;
state: string; state: string;
raw_status: string | null; raw_status: string | null;
state_since: number; state_since: number;
@@ -355,6 +359,7 @@ function toTarget(row: TargetRow): WatchTarget {
url: stripchatProfileUrl(row.username), url: stripchatProfileUrl(row.username),
agentId: row.agent_id, agentId: row.agent_id,
notify: Number(row.notify) === 1, notify: Number(row.notify) === 1,
autoRecord: Number(row.auto_record) === 1,
state: (row.state as StreamState) ?? 'unknown', state: (row.state as StreamState) ?? 'unknown',
rawStatus: row.raw_status, rawStatus: row.raw_status,
stateSince: Number(row.state_since), stateSince: Number(row.state_since),
@@ -378,7 +383,7 @@ const targetStmts = {
VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?) VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?)
`), `),
updateSettings: db.prepare( updateSettings: db.prepare(
'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ? WHERE id = ?', 'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ?, auto_record = ? WHERE id = ?',
), ),
updateState: db.prepare(` updateState: db.prepare(`
UPDATE watch_targets UPDATE watch_targets
@@ -431,12 +436,18 @@ export const targetsRepo = {
updateSettings( updateSettings(
id: string, id: string,
settings: { label: string | null; agentId: string | null; notify: boolean }, settings: {
label: string | null;
agentId: string | null;
notify: boolean;
autoRecord: boolean;
},
): void { ): void {
targetStmts.updateSettings.run( targetStmts.updateSettings.run(
settings.label, settings.label,
settings.agentId, settings.agentId,
settings.notify ? 1 : 0, settings.notify ? 1 : 0,
settings.autoRecord ? 1 : 0,
id, id,
); );
}, },

View File

@@ -0,0 +1,60 @@
import type { WatchTarget } from '@stream-control/shared';
import { normalizeWatchSettings } from '@stream-control/shared';
import { agentsRepo } from './db.ts';
import { hub } from './hub.ts';
/**
* Lance l'enregistrement d'un profil sur l'agent qui lui est assigné.
*
* Partagé entre le bouton du dashboard et l'automatisme de la veille : les deux
* doivent configurer la surveillance de la même façon, sans quoi un
* enregistrement lancé automatiquement ne se mettrait pas en pause pendant les
* shows privés.
*/
export async function startTargetRecording(target: WatchTarget): Promise<void> {
if (!target.agentId) throw new Error('Aucun agent assigné à ce profil');
const record = agentsRepo.get(target.agentId);
if (!record) throw new Error("L'agent assigné n'existe plus");
if (!hub.isOnline(record.id)) throw new Error(`Agent « ${record.name} » hors-ligne`);
// Une VM n'enregistre qu'un flux à la fois : écraser une capture en cours
// perdrait la première sans que personne ne l'ait demandé.
if (hub.statusOf(record.id).recording) {
throw new Error(`Agent « ${record.name} » déjà en enregistrement`);
}
// La surveillance de l'agent suit le profil qu'on enregistre : c'est elle qui
// gérera la pause en show privé et la clôture après un passage hors-ligne.
agentsRepo.updateSettings(record.id, {
name: record.name,
obs: record.obs,
autoConnectObs: record.autoConnectObs,
watch: normalizeWatchSettings({ ...record.watch, enabled: true, username: target.username }),
browser: record.browser,
recording: record.recording,
notes: record.notes,
});
const configured = agentsRepo.get(record.id);
if (configured) {
hub.pushConfig(configured);
hub.publishAgent(configured.id);
}
// Avec le pilotage du navigateur, un seul appel enchaîne ouverture de la page,
// plein écran et enregistrement. Sinon on se contente de lancer OBS, en
// supposant la page déjà ouverte par l'opérateur.
if (record.browser.enabled) {
// La séquence attend le chargement de la page : le délai d'attente doit
// dépasser readyDelayMs, sinon la commande expire avant d'avoir abouti.
await hub.sendCommand(
record.id,
'capture.start',
{ url: target.url },
record.browser.readyDelayMs + 30_000,
);
} else {
await hub.sendCommand(record.id, 'record.start');
}
}

View File

@@ -3,6 +3,15 @@ import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/sh
import { config } from './config.ts'; import { config } from './config.ts';
import { targetsRepo } from './db.ts'; import { targetsRepo } from './db.ts';
import { hub } from './hub.ts'; import { hub } from './hub.ts';
import { startTargetRecording } from './recorder.ts';
/**
* Tentatives d'amorçage automatique par diffusion. Les causes d'échec restantes
* une fois les garde-fous passés (OBS injoignable, par exemple) sont surtout
* persistantes : réessayer indéfiniment toutes les dix secondes noierait le
* journal sans rien changer.
*/
const MAX_AUTO_ATTEMPTS = 3;
/** /**
* Sonde périodiquement les profils surveillés et signale les passages en direct. * Sonde périodiquement les profils surveillés et signale les passages en direct.
@@ -14,6 +23,8 @@ import { hub } from './hub.ts';
class Watchlist { class Watchlist {
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private running = false; private running = false;
/** Tentatives déjà faites pour la diffusion en cours, par profil. */
private readonly autoAttempts = new Map<string, number>();
start(): void { start(): void {
if (this.timer) return; if (this.timer) return;
@@ -102,6 +113,7 @@ class Watchlist {
if (!updated) return; if (!updated) return;
hub.publishTarget(updated); hub.publishTarget(updated);
this.maybeAutoRecord(updated);
if (changed) { if (changed) {
const name = updated.label ?? updated.username; const name = updated.label ?? updated.username;
@@ -113,6 +125,67 @@ class Watchlist {
} }
} }
} }
/**
* Déclenche l'enregistrement d'un profil en automatisme, une fois qu'il est en
* direct depuis assez longtemps.
*
* Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort
* d'un show privé repasse « public » quelques secondes avant de se remettre en
* place, et un flux qui redémarre alterne parfois plusieurs fois. Enregistrer
* sur la première lecture produirait des fichiers de dix secondes.
*/
private maybeAutoRecord(target: WatchTarget): void {
if (target.state !== 'public') {
// Diffusion terminée : la suivante aura droit à ses propres tentatives.
this.autoAttempts.delete(target.id);
return;
}
if (!target.autoRecord || !target.agentId) return;
if ((this.autoAttempts.get(target.id) ?? 0) >= MAX_AUTO_ATTEMPTS) return;
// `statusChangedAt` vient de la plateforme et vaut mieux que notre première
// observation : il survit à un redémarrage du serveur.
const liveSince = target.statusChangedAt ?? target.stateSince;
if (Date.now() - liveSince < config.autoRecordDelayMs) return;
if (!hub.isOnline(target.agentId)) return;
// Une VM déjà occupée n'est jamais préemptée : l'enregistrement en cours
// prime sur celui qu'on allait lancer.
if (hub.statusOf(target.agentId).recording) return;
const agentId = target.agentId;
const name = target.label ?? target.username;
// Marqué avant l'appel : la séquence de capture dure plusieurs secondes, et
// le cycle suivant ne doit pas en lancer une seconde en parallèle.
const attempt = (this.autoAttempts.get(target.id) ?? 0) + 1;
this.autoAttempts.set(target.id, attempt);
void startTargetRecording(target)
.then(() => {
this.autoAttempts.set(target.id, MAX_AUTO_ATTEMPTS);
hub.log(
agentId,
'info',
`Automatisme : enregistrement de ${name} démarré`,
Date.now(),
'capture.started',
);
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
const giveUp = attempt >= MAX_AUTO_ATTEMPTS;
hub.log(
agentId,
'warn',
`Automatisme : démarrage de ${name} en échec (${attempt}/${MAX_AUTO_ATTEMPTS}) — ${message}` +
(giveUp ? '. Abandon jusqu\'à la prochaine diffusion.' : ''),
Date.now(),
'command.failed',
);
});
}
} }
function labelOf(state: StreamState): string { function labelOf(state: StreamState): string {

View File

@@ -346,6 +346,21 @@ export interface WatchSettings {
confirmations: number; confirmations: number;
pauseOnPrivate: boolean; pauseOnPrivate: boolean;
resumeOnPublic: boolean; resumeOnPublic: boolean;
/**
* Clore l'enregistrement quand le flux passe hors-ligne (statut brut « off »,
* « idle »…), une fois le délai ci-dessous écoulé.
*
* Le passage hors-ligne met de toute façon l'enregistrement en pause : l'écran
* d'attente n'a rien à faire dans le fichier. Ce réglage ne décide que de la
* clôture définitive.
*/
stopOnOffline: boolean;
/**
* Temps hors-ligne toléré avant de clore. Une coupure brève ne doit pas
* découper le fichier en deux ; une vraie fin de diffusion, si. Le compte à
* rebours est annulé dès que le flux revient, public ou privé.
*/
offlineStopDelayMs: number;
fullscreen: FullscreenSettings; fullscreen: FullscreenSettings;
} }
@@ -406,6 +421,8 @@ export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
confirmations: 2, confirmations: 2,
pauseOnPrivate: true, pauseOnPrivate: true,
resumeOnPublic: true, resumeOnPublic: true,
stopOnOffline: true,
offlineStopDelayMs: 3_600_000,
fullscreen: { fullscreen: {
enabled: true, enabled: true,
key: 'f', key: 'f',
@@ -428,6 +445,10 @@ export interface WatchState {
lastError?: string; lastError?: string;
/** Vrai si c'est la surveillance — et non l'opérateur — qui a mis en pause. */ /** Vrai si c'est la surveillance — et non l'opérateur — qui a mis en pause. */
autoPaused: boolean; autoPaused: boolean;
/** Depuis quand le flux est hors-ligne, absent s'il ne l'est pas. */
offlineSince?: number;
/** Échéance de la clôture automatique, absente si aucun compte à rebours ne court. */
stopScheduledAt?: number;
/** Lectures « privé » accumulées, en attente du seuil de confirmation. */ /** Lectures « privé » accumulées, en attente du seuil de confirmation. */
pendingConfirmations: number; pendingConfirmations: number;
} }
@@ -702,6 +723,12 @@ export interface WatchTarget {
/** Agent qui enregistrera ce streamer, s'il est assigné. */ /** Agent qui enregistrera ce streamer, s'il est assigné. */
agentId: string | null; agentId: string | null;
notify: boolean; notify: boolean;
/**
* Lancer l'enregistrement sans intervention dès que ce profil est en direct
* depuis assez longtemps. Exige un agent assigné, et un seul profil en
* automatisme par agent : une VM n'enregistre qu'un flux à la fois.
*/
autoRecord: boolean;
state: StreamState; state: StreamState;
rawStatus: string | null; rawStatus: string | null;
/** Depuis quand l'état est stable, selon nos propres observations. */ /** Depuis quand l'état est stable, selon nos propres observations. */
@@ -782,6 +809,9 @@ export function normalizeWatchSettings(raw: unknown): WatchSettings {
confirmations: clamp(input.confirmations, base.confirmations, 1, 10), confirmations: clamp(input.confirmations, base.confirmations, 1, 10),
pauseOnPrivate: input.pauseOnPrivate !== false, pauseOnPrivate: input.pauseOnPrivate !== false,
resumeOnPublic: input.resumeOnPublic !== false, resumeOnPublic: input.resumeOnPublic !== false,
stopOnOffline: input.stopOnOffline !== false,
// Plafond à 24 h : au-delà, l'enregistrement mobiliserait une VM pour rien.
offlineStopDelayMs: clamp(input.offlineStopDelayMs, base.offlineStopDelayMs, 0, 86_400_000),
fullscreen: { fullscreen: {
enabled: fullscreen.enabled !== false, enabled: fullscreen.enabled !== false,
key: key:

View File

@@ -112,7 +112,10 @@ export const api = {
body: JSON.stringify({ url, agentId }), body: JSON.stringify({ url, agentId }),
}), }),
updateTarget: (id: string, body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify'>>) => updateTarget: (
id: string,
body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify' | 'autoRecord'>>,
) =>
request<{ target: WatchTarget }>(`/watchlist/${id}`, { request<{ target: WatchTarget }>(`/watchlist/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(body), body: JSON.stringify(body),

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { AgentAction, AgentView, WatchState } from '@stream-control/shared'; import type { AgentAction, AgentView, WatchState } from '@stream-control/shared';
import { findRecordingPreset } from '@stream-control/shared'; import { findRecordingPreset } from '@stream-control/shared';
import { formatBytes, formatPercent, formatRelative, formatTimecode } from '../format'; import { formatBytes, formatDuration, formatPercent, formatRelative, formatTimecode } from '../format';
interface Props { interface Props {
agent: AgentView; agent: AgentView;
@@ -237,6 +237,12 @@ function WatchStrip({
</button> </button>
</div> </div>
{watch.stopScheduledAt && (
<span className="small warn-text">
Hors-ligne clôture dans {formatDuration(watch.stopScheduledAt - Date.now())}
</span>
)}
{watch.lastError ? ( {watch.lastError ? (
<span className="small error">Sonde en échec : {watch.lastError}</span> <span className="small error">Sonde en échec : {watch.lastError}</span>
) : ( ) : (

View File

@@ -513,6 +513,33 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
</span> </span>
</label> </label>
<label className="checkbox">
<input
type="checkbox"
checked={watch.stopOnOffline}
onChange={(event) => patchWatch({ stopOnOffline: event.target.checked })}
/>
<span>Clore l'enregistrement quand le flux passe hors-ligne</span>
</label>
<label className="field">
<span>Attente avant clôture (minutes)</span>
<input
value={String(Math.round(watch.offlineStopDelayMs / 60_000))}
disabled={!watch.stopOnOffline}
onChange={(event) =>
patchWatch({ offlineStopDelayMs: (Number(event.target.value) || 0) * 60_000 })
}
inputMode="numeric"
/>
<span className="muted small">
Le passage hors-ligne met l'enregistrement en pause immédiatement l'écran
d'attente n'entre jamais dans le fichier. Ce délai ne décide que de la clôture :
en deçà, un retour du flux reprend le même fichier plutôt que d'en ouvrir un
second.
</span>
</label>
<label className="checkbox"> <label className="checkbox">
<input <input
type="checkbox" type="checkbox"

View File

@@ -88,6 +88,20 @@ export function StreamersPage({
); );
} }
/**
* Explique, en une ligne, ce que l'automatisme fera ou pourquoi il ne fera rien.
* Un interrupteur coché qui reste sans effet est plus déroutant qu'un
* interrupteur absent.
*/
function autoRecordHint(target: WatchTarget, agent: AgentView | null): string {
if (!target.autoRecord) return ' — dès une minute de direct';
if (!agent) return ' — agent introuvable';
if (!agent.online) return ` — en attente : ${agent.name} est hors-ligne`;
if (agent.status.recording) return ` — en attente : ${agent.name} enregistre déjà`;
if (target.state === 'public') return ' — armé, direct en cours';
return ' — armé, en attente du prochain direct';
}
function StreamerCard({ function StreamerCard({
target, target,
agents, agents,
@@ -209,6 +223,25 @@ function StreamerCard({
</select> </select>
</label> </label>
<label className="checkbox">
<input
type="checkbox"
checked={target.autoRecord}
disabled={busy || !target.agentId}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { autoRecord: event.target.checked }))
}
/>
<span>
Enregistrer automatiquement
<span className="muted small">
{!target.agentId
? ' assigne d\'abord un agent'
: autoRecordHint(target, agent)}
</span>
</span>
</label>
<div className="actions"> <div className="actions">
{recording ? ( {recording ? (
<button <button