Feat : added and tweaked state machine
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
60
packages/server/src/recorder.ts
Normal file
60
packages/server/src/recorder.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user