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

@@ -14,6 +14,7 @@ import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { startTargetRecording } from './recorder.ts';
import { watchlist } from './watchlist.ts';
export const api: Router = Router();
@@ -249,13 +250,38 @@ api.patch('/watchlist/:id', (req, res) => {
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, {
label: typeof req.body?.label === 'string' ? req.body.label.trim() || null : target.label,
agentId:
req.body?.agentId === null || typeof req.body?.agentId === 'string'
? req.body.agentId
: target.agentId,
agentId,
notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify,
autoRecord,
});
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' });
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 {
// 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');
}
await startTargetRecording(target);
hub.log(
record.id,
target.agentId,
'info',
`Enregistrement de « ${target.username} » démarré depuis la veille`,
Date.now(),
'capture.started',
);
res.json({ ok: true });
} catch (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 });
}
});

View File

@@ -53,6 +53,12 @@ export const config = {
/** Nombre d'entrées de journal conservées en base. */
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'),
} as const;

View File

@@ -108,6 +108,9 @@ addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT');
addColumnIfMissing('watch_targets', 'status_changed_at', 'INTEGER');
addColumnIfMissing('watch_targets', 'last_live_started_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 {
id: string;
@@ -332,6 +335,7 @@ interface TargetRow {
label: string | null;
agent_id: string | null;
notify: number;
auto_record: number;
state: string;
raw_status: string | null;
state_since: number;
@@ -355,6 +359,7 @@ function toTarget(row: TargetRow): WatchTarget {
url: stripchatProfileUrl(row.username),
agentId: row.agent_id,
notify: Number(row.notify) === 1,
autoRecord: Number(row.auto_record) === 1,
state: (row.state as StreamState) ?? 'unknown',
rawStatus: row.raw_status,
stateSince: Number(row.state_since),
@@ -378,7 +383,7 @@ const targetStmts = {
VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?)
`),
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(`
UPDATE watch_targets
@@ -431,12 +436,18 @@ export const targetsRepo = {
updateSettings(
id: string,
settings: { label: string | null; agentId: string | null; notify: boolean },
settings: {
label: string | null;
agentId: string | null;
notify: boolean;
autoRecord: boolean;
},
): void {
targetStmts.updateSettings.run(
settings.label,
settings.agentId,
settings.notify ? 1 : 0,
settings.autoRecord ? 1 : 0,
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 { targetsRepo } from './db.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.
@@ -14,6 +23,8 @@ import { hub } from './hub.ts';
class Watchlist {
private timer: NodeJS.Timeout | null = null;
private running = false;
/** Tentatives déjà faites pour la diffusion en cours, par profil. */
private readonly autoAttempts = new Map<string, number>();
start(): void {
if (this.timer) return;
@@ -102,6 +113,7 @@ class Watchlist {
if (!updated) return;
hub.publishTarget(updated);
this.maybeAutoRecord(updated);
if (changed) {
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 {