From 238019616934938d576671cf61a418d010856e1f Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 11 Aug 2026 23:21:35 +0200 Subject: [PATCH] Feat : added and tweaked state machine --- .env.example | 3 + README.md | 44 ++++++ packages/agent/src/index.ts | 5 + packages/agent/src/watcher.ts | 125 +++++++++++++++++- packages/server/src/api.ts | 88 ++++++------ packages/server/src/config.ts | 6 + packages/server/src/db.ts | 15 ++- packages/server/src/recorder.ts | 60 +++++++++ packages/server/src/watchlist.ts | 73 ++++++++++ packages/shared/src/index.ts | 30 +++++ packages/web/src/api.ts | 5 +- packages/web/src/components/AgentCard.tsx | 8 +- packages/web/src/components/AgentSettings.tsx | 27 ++++ packages/web/src/components/StreamersPage.tsx | 33 +++++ 14 files changed, 469 insertions(+), 53 deletions(-) create mode 100644 packages/server/src/recorder.ts diff --git a/.env.example b/.env.example index 2ac59ed..5d3d14c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 24349ed..a03a174 100644 --- a/README.md +++ b/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 `. @@ -245,6 +288,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer { 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; diff --git a/packages/agent/src/watcher.ts b/packages/agent/src/watcher.ts index 3c28c74..8397956 100644 --- a/packages/agent/src/watcher.ts +++ b/packages/agent/src/watcher.ts @@ -26,6 +26,8 @@ export interface WatchActions { recordState(): Promise<{ active: boolean; paused: boolean }>; pauseRecording(): Promise; resumeRecording(): Promise; + /** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */ + stopRecording(): Promise; } /** @@ -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 { + 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 { + 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 { @@ -268,6 +379,16 @@ const TRANSITIONS: Record = { 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': diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 26bb80e..bc54020 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -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 }); } }); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 7888829..c0236c3 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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; diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index ec5e259..dc4b48c 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -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, ); }, diff --git a/packages/server/src/recorder.ts b/packages/server/src/recorder.ts new file mode 100644 index 0000000..506a772 --- /dev/null +++ b/packages/server/src/recorder.ts @@ -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 { + 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'); + } +} diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts index d495859..1032876 100644 --- a/packages/server/src/watchlist.ts +++ b/packages/server/src/watchlist.ts @@ -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(); 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 { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e4fdaed..42641a0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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: diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index c583261..eca28b2 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -112,7 +112,10 @@ export const api = { body: JSON.stringify({ url, agentId }), }), - updateTarget: (id: string, body: Partial>) => + updateTarget: ( + id: string, + body: Partial>, + ) => request<{ target: WatchTarget }>(`/watchlist/${id}`, { method: 'PATCH', body: JSON.stringify(body), diff --git a/packages/web/src/components/AgentCard.tsx b/packages/web/src/components/AgentCard.tsx index db9f08f..e9ec8f6 100644 --- a/packages/web/src/components/AgentCard.tsx +++ b/packages/web/src/components/AgentCard.tsx @@ -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({ + {watch.stopScheduledAt && ( + + Hors-ligne — clôture dans {formatDuration(watch.stopScheduledAt - Date.now())} + + )} + {watch.lastError ? ( Sonde en échec : {watch.lastError} ) : ( diff --git a/packages/web/src/components/AgentSettings.tsx b/packages/web/src/components/AgentSettings.tsx index a1372a5..ef497fe 100644 --- a/packages/web/src/components/AgentSettings.tsx +++ b/packages/web/src/components/AgentSettings.tsx @@ -513,6 +513,33 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr + + + + + +
{recording ? (