Feat : added and tweaked state machine
This commit is contained in:
@@ -21,3 +21,6 @@ STATUS_INTERVAL_MS=2000
|
||||
|
||||
# Délai avant de considérer un agent comme hors-ligne (ms)
|
||||
AGENT_TIMEOUT_MS=15000
|
||||
|
||||
# Duree de direct exigee avant un enregistrement automatique (ms).
|
||||
AUTO_RECORD_DELAY_MS=60000
|
||||
|
||||
44
README.md
44
README.md
@@ -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).
|
||||
- [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.
|
||||
- [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,
|
||||
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
|
||||
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
|
||||
|
||||
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 |
|
||||
| `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/watchlist/:id` | Libellé, agent assigné, notifications, automatisme |
|
||||
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
|
||||
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
|
||||
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |
|
||||
|
||||
@@ -59,6 +59,11 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
|
||||
resumeRecording: async () => {
|
||||
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;
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface WatchActions {
|
||||
recordState(): Promise<{ active: boolean; paused: boolean }>;
|
||||
pauseRecording(): 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 timer: 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;
|
||||
/** Le flux public a été interrompu : il faudra rappeler le plein écran. */
|
||||
private fullscreenPending = false;
|
||||
@@ -88,6 +92,7 @@ export class StreamWatcher extends EventEmitter {
|
||||
this.state.pendingConfirmations = 0;
|
||||
this.state.autoPaused = false;
|
||||
this.fullscreenPending = false;
|
||||
this.cancelOfflineStop();
|
||||
}
|
||||
if (restart) this.start();
|
||||
}
|
||||
@@ -114,6 +119,7 @@ export class StreamWatcher extends EventEmitter {
|
||||
this.timer = null;
|
||||
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
|
||||
this.fullscreenTimer = null;
|
||||
this.cancelOfflineStop();
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
// Le retour du flux, public ou privé, annule toute clôture programmée.
|
||||
if (next !== 'offline') this.cancelOfflineStop();
|
||||
|
||||
if (next === 'private') {
|
||||
// Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît.
|
||||
this.fullscreenPending = true;
|
||||
@@ -174,8 +183,110 @@ export class StreamWatcher extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === 'public') await this.handlePublic();
|
||||
// 'offline' / 'unknown' : on ne touche à rien, l'opérateur reste maître.
|
||||
if (next === 'public') {
|
||||
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> {
|
||||
@@ -268,6 +379,16 @@ const TRANSITIONS: Record<StreamState, AgentEvent | 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 {
|
||||
switch (state) {
|
||||
case 'public':
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
targetsRepo.updateSettings(target.id, {
|
||||
label: typeof req.body?.label === 'string' ? req.body.label.trim() || null : target.label,
|
||||
agentId:
|
||||
const agentId =
|
||||
req.body?.agentId === null || typeof req.body?.agentId === 'string'
|
||||
? req.body.agentId
|
||||
: target.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,
|
||||
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 {
|
||||
|
||||
@@ -346,6 +346,21 @@ export interface WatchSettings {
|
||||
confirmations: number;
|
||||
pauseOnPrivate: 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;
|
||||
}
|
||||
|
||||
@@ -406,6 +421,8 @@ export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
|
||||
confirmations: 2,
|
||||
pauseOnPrivate: true,
|
||||
resumeOnPublic: true,
|
||||
stopOnOffline: true,
|
||||
offlineStopDelayMs: 3_600_000,
|
||||
fullscreen: {
|
||||
enabled: true,
|
||||
key: 'f',
|
||||
@@ -428,6 +445,10 @@ export interface WatchState {
|
||||
lastError?: string;
|
||||
/** Vrai si c'est la surveillance — et non l'opérateur — qui a mis en pause. */
|
||||
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. */
|
||||
pendingConfirmations: number;
|
||||
}
|
||||
@@ -702,6 +723,12 @@ export interface WatchTarget {
|
||||
/** Agent qui enregistrera ce streamer, s'il est assigné. */
|
||||
agentId: string | null;
|
||||
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;
|
||||
rawStatus: string | null;
|
||||
/** 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),
|
||||
pauseOnPrivate: input.pauseOnPrivate !== 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: {
|
||||
enabled: fullscreen.enabled !== false,
|
||||
key:
|
||||
|
||||
@@ -112,7 +112,10 @@ export const api = {
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import type { AgentAction, AgentView, WatchState } 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 {
|
||||
agent: AgentView;
|
||||
@@ -237,6 +237,12 @@ function WatchStrip({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{watch.stopScheduledAt && (
|
||||
<span className="small warn-text">
|
||||
Hors-ligne — clôture dans {formatDuration(watch.stopScheduledAt - Date.now())}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{watch.lastError ? (
|
||||
<span className="small error">Sonde en échec : {watch.lastError}</span>
|
||||
) : (
|
||||
|
||||
@@ -513,6 +513,33 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
||||
</span>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -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({
|
||||
target,
|
||||
agents,
|
||||
@@ -209,6 +223,25 @@ function StreamerCard({
|
||||
</select>
|
||||
</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">
|
||||
{recording ? (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user