From b529417940211b18348218d5ce8a71476b1c159d Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 11 Aug 2026 00:52:33 +0200 Subject: [PATCH] Feat : STRCHA Stream status --- README.md | 69 ++++- packages/agent/src/hotkey.ts | 188 ++++++++++++ packages/agent/src/index.ts | 48 ++- packages/agent/src/obs.ts | 15 +- packages/agent/src/watcher.ts | 284 ++++++++++++++++++ packages/server/src/agentGateway.ts | 1 + packages/server/src/api.ts | 8 +- packages/server/src/db.ts | 44 ++- packages/server/src/hub.ts | 2 + packages/shared/src/index.ts | 155 ++++++++++ packages/web/src/components/AgentCard.tsx | 66 +++- packages/web/src/components/AgentSettings.tsx | 133 +++++++- packages/web/src/styles.css | 39 +++ 13 files changed, 1038 insertions(+), 14 deletions(-) create mode 100644 packages/agent/src/hotkey.ts create mode 100644 packages/agent/src/watcher.ts diff --git a/README.md b/README.md index d74ac9b..3118952 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,16 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`. changer le dossier d'enregistrement. - Journal d'évènements horodaté, persistant et diffusé en direct. - 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. ## Prérequis - Node.js **22+** sur le serveur et sur chaque VM (le serveur utilise `node:sqlite`). - OBS **28+** sur chaque VM, avec *Outils → Paramètres du serveur WebSocket* activé. Note le port (4455 par défaut) et le mot de passe. +- Pour le rappel du plein écran sous Ubuntu : `xdotool` et une session X11 + (voir [Prérequis pour le rappel du plein écran](#prérequis-pour-le-rappel-du-plein-écran)). ## Démarrage rapide (développement) @@ -108,6 +112,65 @@ planifiée « à l'ouverture de session » fournie : Les paramètres OBS édités dans le dashboard sont poussés à chaud vers l'agent : pas besoin de se connecter à la VM pour changer un mot de passe obs-websocket. +## Pause automatique pendant les shows privés + +Quand un streamer bascule en show privé, le flux public est remplacé par un écran +d'attente : l'enregistrement continue mais ne capte plus rien d'utile, et le lecteur +sort du plein écran. L'agent peut surveiller le statut du streamer et réagir seul. + +Configuration par agent, dans *Configuration → Surveillance du stream* : + +| Réglage | Effet | +| --- | --- | +| Pseudo du streamer | Celui de l'URL de sa page | +| Intervalle de sonde | Fréquence d'interrogation de l'API (10 s par défaut, plancher 3 s) | +| Lectures avant pause | Lectures « privé » consécutives exigées avant d'agir (2 par défaut) | +| Statuts « privé » | Statuts déclenchant la pause — retire `groupShow` pour continuer à enregistrer les shows de groupe | +| Touche / fenêtre / délai | Raccourci plein écran à renvoyer au lecteur après le show | + +### Ce que fait l'agent + +1. Il interroge `GET /api/front/v2/models/username/{pseudo}/cam` et lit `user.user.status`. + Valeurs relevées en production : `public`, `private`, `p2p`, `groupShow`, `idle`. +2. Statut privé confirmé → `PauseRecord`, en mémorisant que **c'est lui** qui a mis en pause. +3. Retour au public → `ResumeRecord`, puis envoi de la touche plein écran après le délai + configuré, le temps que le lecteur ait rechargé le flux. + +Trois garde-fous, parce qu'une automatisation qui coupe un enregistrement au mauvais +moment coûte plus cher que quelques secondes d'écran d'attente enregistrées : + +- **La pause exige plusieurs lectures consécutives, la reprise agit immédiatement.** Une + fausse pause perd du contenu réel ; une fausse reprise ne coûte rien. +- **Une sonde en échec ne déclenche jamais rien.** API injoignable ou réponse inattendue : + l'agent conserve le dernier état connu et ne touche pas à l'enregistrement. +- **Une pause manuelle n'est jamais reprise automatiquement.** L'agent ne reprend que ce + qu'il a lui-même mis en pause. + +Un passage `hors-ligne` (`idle`) ne provoque ni pause ni reprise : seul le retour effectif +du flux public relance l'enregistrement. + +### Prérequis pour le rappel du plein écran + +L'envoi de touche se fait au niveau du système, pas via OBS. + +| OS | Mécanisme | À prévoir | +| --- | --- | --- | +| Ubuntu | `xdotool windowactivate` + XTEST | `apt install xdotool`, session **X11** (pas Wayland), `DISPLAY` accessible à l'agent | +| Windows | `SetForegroundWindow` + `SendKeys` | L'agent doit tourner dans la session interactive — d'où la tâche planifiée plutôt qu'un service | +| macOS | AppleScript System Events | Autorisation Accessibilité (prévu pour le développement) | + +Dans les deux cas la fenêtre du lecteur passe **au premier plan** : les navigateurs +ignorent les évènements clavier synthétiques envoyés sans focus (`XSendEvent`). Sans +conséquence sur une VM d'enregistrement dédiée, gênant si quelqu'un s'en sert en même +temps. + +Le bouton ⛶ sur la fiche de l'agent renvoie la touche à la demande, et ⟳ force une sonde +immédiate — les deux servent à valider le titre de fenêtre sans attendre un vrai show privé. + +Si le rappel du plein écran s'avère fragile sur ta VM, l'alternative sans clavier est de +lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein +écran à restaurer. + ## API HTTP Toutes les routes hors `/api/login` exigent `Authorization: Bearer `. @@ -128,7 +191,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer { + const key = assertKey(request.key); + const match = request.windowMatch.trim(); + if (!match) throw new Error('Aucun titre de fenêtre à cibler (windowMatch vide)'); + + switch (process.platform) { + case 'linux': + return sendLinux(key, match); + case 'win32': + return sendWindows(key, match); + case 'darwin': + return sendDarwin(key, match); + default: + throw new Error(`Envoi de touche non supporté sur ${process.platform}`); + } +} + +// --- X11 -------------------------------------------------------------------- + +async function sendLinux(key: string, match: string): Promise { + const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }; + + let ids: string[]; + try { + const { stdout } = await run( + 'xdotool', + ['search', '--onlyvisible', '--name', match], + { env, timeout: 5000 }, + ); + ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + throw new Error('xdotool est absent — installe-le : apt install xdotool'); + } + // xdotool sort en code 1 quand rien ne correspond. + throw new Error(`Aucune fenêtre visible ne correspond à « ${match} »`); + } + if (ids.length === 0) throw new Error(`Aucune fenêtre visible ne correspond à « ${match} »`); + + // La dernière fenêtre listée est en général la plus récemment mappée. + const windowId = ids[ids.length - 1]!; + + let title = windowId; + try { + const { stdout } = await run('xdotool', ['getwindowname', windowId], { env, timeout: 5000 }); + title = stdout.trim() || windowId; + } catch { + /* le titre n'est qu'informatif */ + } + + await run('xdotool', ['windowactivate', '--sync', windowId], { env, timeout: 5000 }); + await run('xdotool', ['key', '--clearmodifiers', key], { env, timeout: 5000 }); + + return { method: 'xdotool', window: title }; +} + +// --- Windows ---------------------------------------------------------------- + +/** Échappe une valeur pour une chaîne littérale PowerShell entre apostrophes. */ +function psLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +async function sendWindows(key: string, match: string): Promise { + // SendKeys interprète certains caractères ; les touches nommées se notent {F11}. + const sendKeysArg = key.length === 1 ? key.toLowerCase() : `{${key.toUpperCase()}}`; + + const script = ` +$ErrorActionPreference = 'Stop' +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; +public class Win32 { + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); +} +"@ +$needle = ${psLiteral(match)} +$found = [IntPtr]::Zero +$foundTitle = '' +$callback = [Win32+EnumWindowsProc]{ + param($hWnd, $lParam) + if (-not [Win32]::IsWindowVisible($hWnd)) { return $true } + $len = [Win32]::GetWindowTextLength($hWnd) + if ($len -eq 0) { return $true } + $sb = New-Object System.Text.StringBuilder($len + 1) + [void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity) + $title = $sb.ToString() + if ($title -like ('*' + $needle + '*')) { $script:found = $hWnd; $script:foundTitle = $title; return $false } + return $true +} +[void][Win32]::EnumWindows($callback, [IntPtr]::Zero) +if ($script:found -eq [IntPtr]::Zero) { Write-Error ('Aucune fenetre ne correspond a ' + $needle); exit 1 } +[void][Win32]::ShowWindow($script:found, 9) +[void][Win32]::SetForegroundWindow($script:found) +Start-Sleep -Milliseconds 300 +Add-Type -AssemblyName System.Windows.Forms +[System.Windows.Forms.SendKeys]::SendWait(${psLiteral(sendKeysArg)}) +Write-Output $script:foundTitle +`; + + // -EncodedCommand évite tout problème de guillemets entre cmd.exe et PowerShell. + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + + try { + const { stdout } = await run( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], + { timeout: 20_000, windowsHide: true }, + ); + return { method: 'SendKeys', window: stdout.trim() || undefined }; + } catch (err) { + const stderr = (err as { stderr?: string }).stderr?.trim(); + throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`); + } +} + +// --- macOS (confort de développement) --------------------------------------- + +async function sendDarwin(key: string, match: string): Promise { + const script = ` +tell application "System Events" + set matches to (every process whose name contains "${match.replace(/["\\]/g, '')}") + if (count of matches) = 0 then error "Aucun processus ne correspond" + set target to item 1 of matches + set frontmost of target to true + delay 0.3 + keystroke "${key.toLowerCase()}" +end tell`; + + try { + await run('osascript', ['-e', script], { timeout: 15_000 }); + return { method: 'osascript', window: match }; + } catch (err) { + const stderr = (err as { stderr?: string }).stderr?.trim(); + throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`); + } +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 5184ff0..e7d0623 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,14 +2,24 @@ import os from 'node:os'; import { WebSocket } from 'ws'; import type { + AgentAction, AgentStatus, AgentToServer, LogLevel, ServerToAgent, + WatchSettings, +} from '@stream-control/shared'; +import { + DEFAULT_WATCH_SETTINGS, + PROTOCOL_VERSION, + detectPlatform, + emptyStatus, + normalizeWatchSettings, + safeJsonParse, } from '@stream-control/shared'; -import { PROTOCOL_VERSION, detectPlatform, emptyStatus, safeJsonParse } from '@stream-control/shared'; import { loadConfig, persistIdentity, type AgentConfig } from './config.ts'; import { ObsController } from './obs.ts'; +import { StreamWatcher } from './watcher.ts'; import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts'; const AGENT_VERSION = '0.1.0'; @@ -26,6 +36,16 @@ try { const obs = new ObsController(config.obs); +const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, { + recordState: () => obs.recordState(), + pauseRecording: async () => { + await obs.execute('record.pause'); + }, + resumeRecording: async () => { + await obs.execute('record.resume'); + }, +}); + let socket: WebSocket | null = null; let statusTimer: NodeJS.Timeout | null = null; let reconnectDelay = RECONNECT_MIN_MS; @@ -47,6 +67,7 @@ function report(level: LogLevel, message: string): void { } obs.on('log', (level: LogLevel, message: string) => report(level, message)); +watcher.on('log', (level: LogLevel, message: string) => report(level, message)); function connect(): void { if (shuttingDown) return; @@ -114,6 +135,7 @@ async function handleServerMessage(message: ServerToAgent): Promise { } obs.applySettings(message.obs); + applyWatchSettings(message.watch); report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`); if (message.autoConnectObs && !obs.isConnected) { @@ -125,6 +147,7 @@ async function handleServerMessage(message: ServerToAgent): Promise { case 'config': { obs.applySettings(message.obs); + applyWatchSettings(message.watch); if (message.autoConnectObs && !obs.isConnected) { obs.connect().catch((err: Error) => report('warn', err.message)); } @@ -133,7 +156,7 @@ async function handleServerMessage(message: ServerToAgent): Promise { case 'command': { try { - const data = await obs.execute(message.action, message.params ?? {}); + const data = await runAction(message.action, message.params ?? {}); send({ type: 'result', requestId: message.requestId, ok: true, data }); void pushStatus(); // état rafraîchi immédiatement après l'action } catch (err) { @@ -151,6 +174,25 @@ async function handleServerMessage(message: ServerToAgent): Promise { } } +/** + * Aiguille une action : la surveillance et le clavier sont gérés par l'agent, + * tout le reste part vers obs-websocket. + */ +async function runAction(action: AgentAction, params: Record): Promise { + switch (action) { + case 'watch.check': + return watcher.checkNow(); + case 'hotkey.fullscreen': + return watcher.restoreFullscreen(); + default: + return obs.execute(action, params); + } +} + +function applyWatchSettings(raw: WatchSettings | undefined): void { + watcher.applySettings(normalizeWatchSettings(raw ?? DEFAULT_WATCH_SETTINGS)); +} + // --- Boucle de statut -------------------------------------------------------- async function buildStatus(): Promise { @@ -161,6 +203,7 @@ async function buildStatus(): Promise { return { ...emptyStatus(), ...snapshot, + watch: watcher.snapshot, lastRecordingPath: obs.recordingPath, systemCpu: cpuUsagePercent(), systemMemoryUsed: memory.used, @@ -199,6 +242,7 @@ async function shutdown(signal: string): Promise { shuttingDown = true; console.log(`\n${signal} reçu, arrêt de l'agent…`); stopStatusLoop(); + watcher.stop(); // L'enregistrement OBS en cours n'est volontairement pas interrompu. await obs.disconnect().catch(() => undefined); socket?.close(1000, 'Arrêt de l\'agent'); diff --git a/packages/agent/src/obs.ts b/packages/agent/src/obs.ts index 1fddb3a..69a390d 100644 --- a/packages/agent/src/obs.ts +++ b/packages/agent/src/obs.ts @@ -2,6 +2,12 @@ import { EventEmitter } from 'node:events'; import OBSWebSocket from 'obs-websocket-js'; import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared'; +/** + * Actions relevant d'OBS. `watch.*` et `hotkey.*` sont traitées en amont par + * l'agent : elles ne concernent pas la session obs-websocket. + */ +export type ObsAction = Exclude; + type ObsSnapshot = Pick< AgentStatus, | 'obsConnected' @@ -149,9 +155,16 @@ export class ObsController extends EventEmitter { this.reconnectTimer = null; } + /** État d'enregistrement à la demande, sans passer par l'instantané complet. */ + async recordState(): Promise<{ active: boolean; paused: boolean }> { + if (!this.connected) return { active: false, paused: false }; + const status = await this.obs.call('GetRecordStatus'); + return { active: status.outputActive, paused: status.outputPaused }; + } + // --- Exécution des actions du protocole --------------------------------- - async execute(action: AgentAction, params: Record = {}): Promise { + async execute(action: ObsAction, params: Record = {}): Promise { switch (action) { case 'obs.connect': await this.connect(); diff --git a/packages/agent/src/watcher.ts b/packages/agent/src/watcher.ts new file mode 100644 index 0000000..15bebb0 --- /dev/null +++ b/packages/agent/src/watcher.ts @@ -0,0 +1,284 @@ +import { EventEmitter } from 'node:events'; +import type { LogLevel, StreamState, WatchSettings, WatchState } from '@stream-control/shared'; +import { emptyWatchState, mapStreamStatus } from '@stream-control/shared'; +import { sendHotkey } from './hotkey.ts'; + +const PROBE_TIMEOUT_MS = 8000; +const BROWSER_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; + +export interface ProbeResult { + /** Statut brut renvoyé par la plateforme. */ + raw: string; + state: StreamState; +} + +/** + * Sonde Stripchat. + * + * Le champ autoritatif est `user.user.status` sur + * `/api/front/v2/models/username/{pseudo}/cam`. Valeurs relevées en production : + * `public`, `private`, `p2p`, `groupShow`, `idle`. + * (`cam.privateMode` existe mais reste vide y compris pendant un privé : ne pas + * s'en servir.) + */ +export async function probeStripchat( + username: string, + privateStatuses: string[], +): Promise { + const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent( + username, + )}/cam`; + + const response = await fetch(url, { + headers: { 'user-agent': BROWSER_UA, accept: 'application/json' }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + + if (response.status === 404) return { raw: 'notFound', state: 'offline' }; + if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`); + + const payload = (await response.json()) as { + user?: { user?: { status?: string; isLive?: boolean } }; + }; + const raw = payload?.user?.user?.status; + if (typeof raw !== 'string') { + throw new Error('Réponse Stripchat inattendue : user.user.status absent'); + } + + return { raw, state: mapStreamStatus(raw, privateStatuses) }; +} + +/** Ce que le surveillant doit pouvoir demander à OBS. */ +export interface WatchActions { + recordState(): Promise<{ active: boolean; paused: boolean }>; + pauseRecording(): Promise; + resumeRecording(): Promise; +} + +/** + * Surveille le statut du stream source et met l'enregistrement en pause pendant + * les shows privés, puis le reprend et rappelle le plein écran à la reprise du + * flux public. + * + * Deux garde-fous délibérés : + * - la mise en pause exige N lectures « privé » consécutives, la reprise agit + * immédiatement (une fausse pause perd du contenu, une fausse reprise ne + * coûte que quelques secondes d'écran d'attente) ; + * - une erreur de sonde ne déclenche jamais d'action : on conserve l'état connu. + */ +export class StreamWatcher extends EventEmitter { + private settings: WatchSettings; + private state: WatchState; + private timer: NodeJS.Timeout | null = null; + private fullscreenTimer: NodeJS.Timeout | null = null; + private ticking = false; + /** Le flux public a été interrompu : il faudra rappeler le plein écran. */ + private fullscreenPending = false; + + constructor( + settings: WatchSettings, + private readonly actions: WatchActions, + ) { + super(); + this.settings = settings; + this.state = emptyWatchState(settings); + } + + get snapshot(): WatchState { + return { ...this.state }; + } + + applySettings(settings: WatchSettings): void { + const restart = + settings.enabled !== this.settings.enabled || + settings.username !== this.settings.username || + settings.provider !== this.settings.provider || + settings.pollIntervalMs !== this.settings.pollIntervalMs; + + const identityChanged = + settings.username !== this.settings.username || settings.provider !== this.settings.provider; + + this.settings = settings; + this.state.enabled = settings.enabled; + this.state.provider = settings.provider; + this.state.username = settings.username; + + if (identityChanged) { + this.state.state = 'unknown'; + this.state.rawStatus = undefined; + this.state.since = Date.now(); + this.state.pendingConfirmations = 0; + this.state.autoPaused = false; + this.fullscreenPending = false; + } + if (restart) this.start(); + } + + start(): void { + this.stop(); + if (!this.settings.enabled || !this.settings.username) return; + + this.emit( + 'log', + 'info', + `Surveillance de « ${this.settings.username} » (${this.settings.provider}) toutes les ${ + this.settings.pollIntervalMs / 1000 + } s`, + ); + void this.tick(); + this.timer = setInterval(() => void this.tick(), this.settings.pollIntervalMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer); + this.fullscreenTimer = null; + } + + /** Sonde immédiate, utilisée par l'action `watch.check`. */ + async checkNow(): Promise { + await this.tick(); + return this.snapshot; + } + + private async tick(): Promise { + if (this.ticking) return; // une sonde lente ne doit pas s'empiler + if (!this.settings.enabled || !this.settings.username) return; + this.ticking = true; + + try { + const result = await probeStripchat(this.settings.username, this.settings.privateStatuses); + this.state.lastCheckedAt = Date.now(); + this.state.lastError = undefined; + this.state.rawStatus = result.raw; + await this.transition(result.state); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.state.lastCheckedAt = Date.now(); + // Sonde en échec : on ne met surtout pas l'enregistrement en pause. + if (this.state.lastError !== message) { + this.emit('log', 'warn', `Sonde « ${this.settings.username} » en échec : ${message}`); + } + this.state.lastError = message; + } finally { + this.ticking = false; + } + } + + private async transition(next: StreamState): Promise { + const previous = this.state.state; + + if (next !== previous) { + this.state.state = next; + this.state.since = Date.now(); + this.emit( + 'log', + 'info', + `Stream « ${this.settings.username} » : ${label(previous)} → ${label(next)} (${this.state.rawStatus})`, + ); + } + + this.state.pendingConfirmations = next === 'private' ? this.state.pendingConfirmations + 1 : 0; + + if (next === 'private') { + // Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît. + this.fullscreenPending = true; + await this.handlePrivate(); + return; + } + + if (next === 'public') await this.handlePublic(); + // 'offline' / 'unknown' : on ne touche à rien, l'opérateur reste maître. + } + + private async handlePrivate(): Promise { + if (!this.settings.pauseOnPrivate || this.state.autoPaused) return; + if (this.state.pendingConfirmations < this.settings.confirmations) return; + + try { + const record = await this.actions.recordState(); + if (!record.active || record.paused) return; + await this.actions.pauseRecording(); + this.state.autoPaused = true; + this.emit('log', 'info', 'Show privé détecté : enregistrement mis en pause'); + } catch (err) { + this.emit( + 'log', + 'error', + `Mise en pause automatique impossible : ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + private async handlePublic(): Promise { + if (this.state.autoPaused && this.settings.resumeOnPublic) { + try { + const record = await this.actions.recordState(); + if (record.active && record.paused) { + await this.actions.resumeRecording(); + this.emit('log', 'info', 'Flux public rétabli : enregistrement repris'); + } + } catch (err) { + this.emit( + 'log', + 'error', + `Reprise automatique impossible : ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + this.state.autoPaused = false; + } + } + + if (this.fullscreenPending && this.settings.fullscreen.enabled) { + this.fullscreenPending = false; + this.scheduleFullscreen(); + } else { + this.fullscreenPending = false; + } + } + + /** Laisse au lecteur le temps de recharger le flux public avant d'envoyer la touche. */ + private scheduleFullscreen(): void { + if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer); + this.fullscreenTimer = setTimeout(() => { + this.fullscreenTimer = null; + void this.restoreFullscreen(); + }, this.settings.fullscreen.delayMs); + this.fullscreenTimer.unref?.(); + } + + async restoreFullscreen(): Promise<{ method: string; window?: string }> { + const { key, windowMatch } = this.settings.fullscreen; + try { + const result = await sendHotkey({ key, windowMatch }); + this.emit( + 'log', + 'info', + `Plein écran rappelé : touche « ${key} » envoyée à « ${result.window ?? windowMatch} »`, + ); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.emit('log', 'warn', `Rappel du plein écran impossible : ${message}`); + throw err; + } + } +} + +function label(state: StreamState): string { + switch (state) { + case 'public': + return 'public'; + case 'private': + return 'privé'; + case 'offline': + return 'hors-ligne'; + default: + return 'inconnu'; + } +} + +export type { LogLevel }; diff --git a/packages/server/src/agentGateway.ts b/packages/server/src/agentGateway.ts index 62ba350..e29e861 100644 --- a/packages/server/src/agentGateway.ts +++ b/packages/server/src/agentGateway.ts @@ -110,6 +110,7 @@ export function handleAgentConnection( obs: record.obs, statusIntervalMs: config.statusIntervalMs, autoConnectObs: record.autoConnectObs, + watch: record.watch, }); break; } diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index e4bf74f..11fc583 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,7 +1,12 @@ import { randomUUID } from 'node:crypto'; import { Router } from 'express'; import type { ObsSettings } from '@stream-control/shared'; -import { AGENT_ACTIONS, DEFAULT_OBS_SETTINGS, isAgentAction } from '@stream-control/shared'; +import { + AGENT_ACTIONS, + DEFAULT_OBS_SETTINGS, + isAgentAction, + normalizeWatchSettings, +} from '@stream-control/shared'; import { config } from './config.ts'; import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts'; import { agentsRepo, logsRepo } from './db.ts'; @@ -81,6 +86,7 @@ api.patch('/agents/:id', (req, res) => { obs, autoConnectObs: typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs, + watch: normalizeWatchSettings(req.body?.watch ?? record.watch), notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes, }); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index bc232a0..a9fea40 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -1,8 +1,19 @@ import fs from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; -import type { LogEntry, LogLevel, ObsSettings, Platform } from '@stream-control/shared'; -import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared'; +import type { + LogEntry, + LogLevel, + ObsSettings, + Platform, + WatchSettings, +} from '@stream-control/shared'; +import { + DEFAULT_OBS_SETTINGS, + DEFAULT_WATCH_SETTINGS, + normalizeWatchSettings, + safeJsonParse, +} from '@stream-control/shared'; import { config } from './config.ts'; fs.mkdirSync(path.dirname(config.dbPath), { recursive: true }); @@ -40,6 +51,19 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts DESC); `); +/** Migrations additives : sûres à rejouer sur une base déjà peuplée. */ +function addColumnIfMissing(table: string, column: string, definition: string): void { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{ + name: string; + }>; + if (columns.some((entry) => entry.name === column)) return; + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); +} + +// Surveillance du stream source : stockée en JSON, le schéma évolue plus vite +// que la table (nouveaux fournisseurs, nouveaux statuts). +addColumnIfMissing('agents', 'watch_json', 'TEXT'); + export interface AgentRow { id: string; name: string; @@ -51,6 +75,7 @@ export interface AgentRow { obs_port: number; obs_password: string; auto_connect: number; + watch_json: string | null; notes: string | null; created_at: number; last_seen_at: number | null; @@ -65,6 +90,7 @@ export interface AgentRecord { tokenHash: string; obs: ObsSettings; autoConnectObs: boolean; + watch: WatchSettings; notes: string | null; createdAt: number; lastSeenAt: number | null; @@ -84,6 +110,9 @@ function toRecord(row: AgentRow): AgentRecord { password: row.obs_password ?? '', }, autoConnectObs: Number(row.auto_connect) === 1, + watch: normalizeWatchSettings( + row.watch_json ? safeJsonParse(row.watch_json) : DEFAULT_WATCH_SETTINGS, + ), notes: row.notes, createdAt: Number(row.created_at), lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at), @@ -96,8 +125,9 @@ const stmts = { getAgentByTokenHash: db.prepare('SELECT * FROM agents WHERE token_hash = ?'), insertAgent: db.prepare(` INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash, - obs_host, obs_port, obs_password, auto_connect, notes, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + obs_host, obs_port, obs_password, auto_connect, watch_json, + notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), updateIdentity: db.prepare(` UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ? @@ -105,7 +135,7 @@ const stmts = { `), updateSettings: db.prepare(` UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?, - auto_connect = ?, notes = ? + auto_connect = ?, watch_json = ?, notes = ? WHERE id = ? `), touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'), @@ -147,6 +177,7 @@ export const agentsRepo = { agentVersion?: string | null; obs?: Partial; autoConnectObs?: boolean; + watch?: WatchSettings; notes?: string | null; }): AgentRecord { const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs }; @@ -161,6 +192,7 @@ export const agentsRepo = { obs.port, obs.password, input.autoConnectObs === false ? 0 : 1, + JSON.stringify(normalizeWatchSettings(input.watch ?? DEFAULT_WATCH_SETTINGS)), input.notes ?? null, Date.now(), ); @@ -188,6 +220,7 @@ export const agentsRepo = { name: string; obs: ObsSettings; autoConnectObs: boolean; + watch: WatchSettings; notes: string | null; }, ): void { @@ -197,6 +230,7 @@ export const agentsRepo = { settings.obs.port, settings.obs.password, settings.autoConnectObs ? 1 : 0, + JSON.stringify(normalizeWatchSettings(settings.watch)), settings.notes, id, ); diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index ad06fc9..e0ad7e1 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -135,6 +135,7 @@ class Hub { type: 'config', obs: record.obs, autoConnectObs: record.autoConnectObs, + watch: record.watch, }; connection.socket.send(JSON.stringify(message)); } @@ -171,6 +172,7 @@ class Hub { // Le mot de passe OBS n'est jamais renvoyé au navigateur. obs: { ...record.obs, password: record.obs.password ? '********' : '' }, autoConnectObs: record.autoConnectObs, + watch: record.watch, notes: record.notes, status: this.statusOf(record.id), }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4ba99ba..5ed289f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -43,6 +43,8 @@ export const AGENT_ACTIONS = [ 'profile.set', 'collection.set', 'recordDirectory.set', + 'watch.check', + 'hotkey.fullscreen', 'agent.ping', ] as const; @@ -60,6 +62,97 @@ export interface AgentActionParams { 'recordDirectory.set': { directory: string }; } +// --------------------------------------------------------------------------- +// Surveillance du statut d'un stream (pause auto pendant les shows privés) +// --------------------------------------------------------------------------- + +export const WATCH_PROVIDERS = ['stripchat'] as const; +export type WatchProvider = (typeof WATCH_PROVIDERS)[number]; + +/** État normalisé du stream surveillé. */ +export type StreamState = 'public' | 'private' | 'offline' | 'unknown'; + +export interface FullscreenSettings { + /** Rappeler le plein écran à la fin d'un show privé. */ + enabled: boolean; + /** Touche à envoyer au lecteur (raccourci plein écran, « f » sur Stripchat). */ + key: string; + /** Fragment de titre de fenêtre identifiant le lecteur (insensible à la casse). */ + windowMatch: string; + /** Délai avant l'envoi, le temps que le flux public soit rechargé. */ + delayMs: number; +} + +export interface WatchSettings { + enabled: boolean; + provider: WatchProvider; + /** Pseudo du streamer, tel qu'il apparaît dans l'URL de sa page. */ + username: string; + pollIntervalMs: number; + /** + * Statuts bruts de l'API considérés comme « pas de flux public ». + * Valeurs observées côté Stripchat : public, private, p2p, groupShow, idle. + */ + privateStatuses: string[]; + /** + * Lectures « privé » consécutives exigées avant de mettre en pause. + * Asymétrique volontairement : une fausse pause coûte du contenu perdu, + * une fausse reprise ne coûte que quelques secondes d'écran d'attente. + */ + confirmations: number; + pauseOnPrivate: boolean; + resumeOnPublic: boolean; + fullscreen: FullscreenSettings; +} + +export const DEFAULT_WATCH_SETTINGS: WatchSettings = { + enabled: false, + provider: 'stripchat', + username: '', + pollIntervalMs: 10_000, + privateStatuses: ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow'], + confirmations: 2, + pauseOnPrivate: true, + resumeOnPublic: true, + fullscreen: { + enabled: true, + key: 'f', + windowMatch: 'Stripchat', + delayMs: 4000, + }, +}; + +/** État courant de la surveillance, remonté avec le statut de l'agent. */ +export interface WatchState { + enabled: boolean; + provider: WatchProvider; + username: string; + state: StreamState; + /** Statut brut renvoyé par l'API, utile pour diagnostiquer un mapping. */ + rawStatus?: string; + /** Depuis quand l'état normalisé est stable. */ + since: number; + lastCheckedAt: number; + lastError?: string; + /** Vrai si c'est la surveillance — et non l'opérateur — qui a mis en pause. */ + autoPaused: boolean; + /** Lectures « privé » accumulées, en attente du seuil de confirmation. */ + pendingConfirmations: number; +} + +export function emptyWatchState(settings: WatchSettings): WatchState { + return { + enabled: settings.enabled, + provider: settings.provider, + username: settings.username, + state: 'unknown', + since: Date.now(), + lastCheckedAt: 0, + autoPaused: false, + pendingConfirmations: 0, + }; +} + // --------------------------------------------------------------------------- // Statut remonté par un agent // --------------------------------------------------------------------------- @@ -102,6 +195,9 @@ export interface AgentStatus { diskFreeBytes?: number; diskTotalBytes?: number; + /** Surveillance du stream source, absente si elle n'est pas configurée. */ + watch?: WatchState; + updatedAt: number; } @@ -181,6 +277,7 @@ export interface WelcomeMessage { statusIntervalMs: number; /** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */ autoConnectObs: boolean; + watch: WatchSettings; } export interface CommandMessage { @@ -194,6 +291,7 @@ export interface ConfigMessage { type: 'config'; obs: ObsSettings; autoConnectObs: boolean; + watch: WatchSettings; } export interface PingMessage { @@ -218,6 +316,7 @@ export interface AgentView { createdAt: number; obs: ObsSettings; autoConnectObs: boolean; + watch: WatchSettings; notes: string | null; status: AgentStatus; } @@ -255,3 +354,59 @@ export function safeJsonParse(raw: string): T | null { return null; } } + +/** + * Normalise une configuration de surveillance venue du réseau ou de la base : + * champs manquants complétés, valeurs numériques bornées. + */ +export function normalizeWatchSettings(raw: unknown): WatchSettings { + const input = (raw ?? {}) as Partial; + const base = DEFAULT_WATCH_SETTINGS; + const fullscreen = (input.fullscreen ?? {}) as Partial; + + const clamp = (value: unknown, fallback: number, min: number, max: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(Math.max(Math.round(parsed), min), max); + }; + + const statuses = Array.isArray(input.privateStatuses) + ? input.privateStatuses.filter((s): s is string => typeof s === 'string' && s.trim() !== '') + : base.privateStatuses; + + return { + enabled: input.enabled === true, + provider: WATCH_PROVIDERS.includes(input.provider as WatchProvider) + ? (input.provider as WatchProvider) + : base.provider, + username: typeof input.username === 'string' ? input.username.trim() : base.username, + // Plancher à 3 s : inutile de marteler l'API, un show privé dure des minutes. + pollIntervalMs: clamp(input.pollIntervalMs, base.pollIntervalMs, 3000, 300_000), + privateStatuses: statuses.length > 0 ? statuses : base.privateStatuses, + confirmations: clamp(input.confirmations, base.confirmations, 1, 10), + pauseOnPrivate: input.pauseOnPrivate !== false, + resumeOnPublic: input.resumeOnPublic !== false, + fullscreen: { + enabled: fullscreen.enabled !== false, + key: + typeof fullscreen.key === 'string' && fullscreen.key.trim() + ? fullscreen.key.trim() + : base.fullscreen.key, + windowMatch: + typeof fullscreen.windowMatch === 'string' + ? fullscreen.windowMatch.trim() + : base.fullscreen.windowMatch, + delayMs: clamp(fullscreen.delayMs, base.fullscreen.delayMs, 0, 120_000), + }, + }; +} + +/** Traduit un statut brut de l'API en état normalisé. */ +export function mapStreamStatus(raw: string | undefined, privateStatuses: string[]): StreamState { + if (!raw) return 'unknown'; + const value = raw.toLowerCase(); + if (privateStatuses.some((status) => status.toLowerCase() === value)) return 'private'; + if (value === 'public') return 'public'; + // idle / off / offline / deleted : le modèle ne diffuse pas. + return 'offline'; +} diff --git a/packages/web/src/components/AgentCard.tsx b/packages/web/src/components/AgentCard.tsx index 932c4dc..572595a 100644 --- a/packages/web/src/components/AgentCard.tsx +++ b/packages/web/src/components/AgentCard.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { AgentAction, AgentView } from '@stream-control/shared'; +import type { AgentAction, AgentView, WatchState } from '@stream-control/shared'; import { formatBytes, formatPercent, formatRelative, formatTimecode } from '../format'; interface Props { @@ -26,13 +26,16 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe const busy = pending !== null; const obsReady = agent.online && status.obsConnected; + const watch = status.watch; + const pausedLabel = watch?.autoPaused ? 'Pause · show privé' : 'En pause'; + const state = !agent.online ? { label: 'Hors-ligne', tone: 'offline' as const } : !status.obsConnected ? { label: 'OBS déconnecté', tone: 'warn' as const } : status.recording ? { - label: status.recordPaused ? 'En pause' : 'Enregistre', + label: status.recordPaused ? pausedLabel : 'Enregistre', tone: status.recordPaused ? ('warn' as const) : ('rec' as const), } : { label: 'Prêt', tone: 'ok' as const }; @@ -65,6 +68,15 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe

{status.obsError}

)} + {watch?.enabled && ( + void run('watch.check')} + onFullscreen={() => void run('hotkey.fullscreen')} + /> + )} +
@@ -150,6 +162,56 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe ); } +const WATCH_LABELS: Record = { + public: { text: 'public', tone: 'ok' }, + private: { text: 'show privé', tone: 'rec' }, + offline: { text: 'hors-ligne', tone: 'offline' }, + unknown: { text: 'inconnu', tone: 'offline' }, +}; + +function WatchStrip({ + watch, + busy, + onCheck, + onFullscreen, +}: { + watch: WatchState; + busy: boolean; + onCheck: () => void; + onFullscreen: () => void; +}) { + const info = WATCH_LABELS[watch.state]; + const stale = watch.lastCheckedAt > 0 && Date.now() - watch.lastCheckedAt > 60_000; + + return ( +
+
+ {info.text} + + {watch.username || '(aucun pseudo)'} + +
+ + +
+ + {watch.lastError ? ( + Sonde en échec : {watch.lastError} + ) : ( + + {watch.rawStatus ? `statut brut « ${watch.rawStatus} »` : 'pas encore sondé'} + {watch.autoPaused && ' · pause automatique active'} + {stale && ' · dernière sonde ancienne'} + + )} +
+ ); +} + function Metric({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
diff --git a/packages/web/src/components/AgentSettings.tsx b/packages/web/src/components/AgentSettings.tsx index c26cddf..8681dbc 100644 --- a/packages/web/src/components/AgentSettings.tsx +++ b/packages/web/src/components/AgentSettings.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { AgentAction, AgentView } from '@stream-control/shared'; +import type { AgentAction, AgentView, WatchSettings } from '@stream-control/shared'; import { api } from '../api'; interface Props { @@ -19,6 +19,15 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) { const [directory, setDirectory] = useState(agent.status.recordDirectory ?? ''); const [token, setToken] = useState(null); const [busy, setBusy] = useState(false); + const [watch, setWatch] = useState(agent.watch); + + function patchWatch(patch: Partial) { + setWatch((current) => ({ ...current, ...patch })); + } + + function patchFullscreen(patch: Partial) { + setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...patch } })); + } async function save() { setBusy(true); @@ -28,6 +37,7 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) { notes, autoConnectObs: autoConnect, obs: { host, port: Number(port), password }, + watch, } as Partial); notify('Configuration enregistrée'); onClose(); @@ -129,6 +139,127 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
+
+ Surveillance du stream + + + +
+ + +
+ +
+ + +
+ + + + + +
+ + + +
+ +
+ + +
+
+
Jeton d'agent diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 8a31fd4..2f3f096 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -349,6 +349,45 @@ textarea:focus { gap: 8px; } +/* --- Bandeau de surveillance --- */ + +.watch { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + background: var(--panel-2); + border-radius: 8px; +} +.watch-head { + display: flex; + align-items: center; + gap: 8px; +} +.watch-name { + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +fieldset.group { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; +} +fieldset.group > legend { + padding: 0 6px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + .card-foot { display: flex; justify-content: space-between;