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)
|
# 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
|
||||||
|
|||||||
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).
|
- 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 |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
targetsRepo.updateSettings(target.id, {
|
const agentId =
|
||||||
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 === null || typeof req.body?.agentId === 'string'
|
||||||
? req.body.agentId
|
? 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,
|
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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
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 { 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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user