Feat : STRCHA Stream status
This commit is contained in:
69
README.md
69
README.md
@@ -27,12 +27,16 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
|
|||||||
changer le dossier d'enregistrement.
|
changer le dossier d'enregistrement.
|
||||||
- Journal d'évènements horodaté, persistant et diffusé en direct.
|
- Journal d'évènements horodaté, persistant et diffusé en direct.
|
||||||
- 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)
|
||||||
|
(Stripchat), avec reprise et rappel du plein écran au retour du flux public.
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
- Node.js **22+** sur le serveur et sur chaque VM (le serveur utilise `node:sqlite`).
|
- 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é.
|
- 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.
|
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)
|
## 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
|
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.
|
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
|
## 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>`.
|
||||||
@@ -128,7 +191,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
|
|||||||
Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.start`,
|
Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.start`,
|
||||||
`record.stop`, `record.pause`, `record.resume`, `record.split`, `stream.start`,
|
`record.stop`, `record.pause`, `record.resume`, `record.split`, `stream.start`,
|
||||||
`stream.stop`, `scene.set`, `profile.set`, `collection.set`, `recordDirectory.set`,
|
`stream.stop`, `scene.set`, `profile.set`, `collection.set`, `recordDirectory.set`,
|
||||||
`agent.ping`.
|
`watch.check`, `hotkey.fullscreen`, `agent.ping`.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -142,7 +205,9 @@ Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.st
|
|||||||
Points d'entrée utiles : [packages/shared/src/index.ts](packages/shared/src/index.ts)
|
Points d'entrée utiles : [packages/shared/src/index.ts](packages/shared/src/index.ts)
|
||||||
(le protocole), [packages/server/src/hub.ts](packages/server/src/hub.ts) (état central et
|
(le protocole), [packages/server/src/hub.ts](packages/server/src/hub.ts) (état central et
|
||||||
dispatch), [packages/agent/src/obs.ts](packages/agent/src/obs.ts) (traduction
|
dispatch), [packages/agent/src/obs.ts](packages/agent/src/obs.ts) (traduction
|
||||||
action → obs-websocket).
|
action → obs-websocket), [packages/agent/src/watcher.ts](packages/agent/src/watcher.ts)
|
||||||
|
(sonde de statut et machine à états pause/reprise),
|
||||||
|
[packages/agent/src/hotkey.ts](packages/agent/src/hotkey.ts) (envoi de touche par OS).
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
|
|||||||
188
packages/agent/src/hotkey.ts
Normal file
188
packages/agent/src/hotkey.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
export interface HotkeyRequest {
|
||||||
|
/** Touche à envoyer : « f », « F11 », « space »… */
|
||||||
|
key: string;
|
||||||
|
/** Fragment du titre de la fenêtre cible (insensible à la casse). */
|
||||||
|
windowMatch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotkeyResult {
|
||||||
|
method: string;
|
||||||
|
window?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jeu de touches autorisé. Ces valeurs viennent de la configuration serveur et
|
||||||
|
* finissent dans une ligne de commande : on refuse tout ce qui sort du lot
|
||||||
|
* plutôt que d'échapper au cas par cas.
|
||||||
|
*/
|
||||||
|
const KEY_PATTERN = /^(?:[A-Za-z0-9]|F[1-9]|F1[0-2]|space|Return|Escape|Tab)$/;
|
||||||
|
|
||||||
|
function assertKey(key: string): string {
|
||||||
|
if (!KEY_PATTERN.test(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`Touche « ${key} » non autorisée (lettres, chiffres, F1-F12, space, Return, Escape, Tab)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie une touche au lecteur vidéo, par exemple pour rebasculer en plein
|
||||||
|
* écran après un show privé.
|
||||||
|
*
|
||||||
|
* Limite assumée : sous X11 comme sous Windows, la fenêtre cible doit passer au
|
||||||
|
* premier plan. Les navigateurs ignorent les évènements clavier synthétiques
|
||||||
|
* envoyés par XSendEvent (`xdotool key --window`), donc on active la fenêtre et
|
||||||
|
* on injecte la touche via XTEST. Sur une VM d'enregistrement dédiée c'est sans
|
||||||
|
* conséquence, mais ça vole le focus si quelqu'un est en train de s'en servir.
|
||||||
|
*/
|
||||||
|
export async function sendHotkey(request: HotkeyRequest): Promise<HotkeyResult> {
|
||||||
|
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<HotkeyResult> {
|
||||||
|
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<HotkeyResult> {
|
||||||
|
// 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<HotkeyResult> {
|
||||||
|
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} »`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,24 @@
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import type {
|
import type {
|
||||||
|
AgentAction,
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
AgentToServer,
|
AgentToServer,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
ServerToAgent,
|
ServerToAgent,
|
||||||
|
WatchSettings,
|
||||||
|
} from '@stream-control/shared';
|
||||||
|
import {
|
||||||
|
DEFAULT_WATCH_SETTINGS,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
detectPlatform,
|
||||||
|
emptyStatus,
|
||||||
|
normalizeWatchSettings,
|
||||||
|
safeJsonParse,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import { PROTOCOL_VERSION, detectPlatform, emptyStatus, safeJsonParse } from '@stream-control/shared';
|
|
||||||
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
|
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
|
||||||
import { ObsController } from './obs.ts';
|
import { ObsController } from './obs.ts';
|
||||||
|
import { StreamWatcher } from './watcher.ts';
|
||||||
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
||||||
|
|
||||||
const AGENT_VERSION = '0.1.0';
|
const AGENT_VERSION = '0.1.0';
|
||||||
@@ -26,6 +36,16 @@ try {
|
|||||||
|
|
||||||
const obs = new ObsController(config.obs);
|
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 socket: WebSocket | null = null;
|
||||||
let statusTimer: NodeJS.Timeout | null = null;
|
let statusTimer: NodeJS.Timeout | null = null;
|
||||||
let reconnectDelay = RECONNECT_MIN_MS;
|
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));
|
obs.on('log', (level: LogLevel, message: string) => report(level, message));
|
||||||
|
watcher.on('log', (level: LogLevel, message: string) => report(level, message));
|
||||||
|
|
||||||
function connect(): void {
|
function connect(): void {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
@@ -114,6 +135,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
obs.applySettings(message.obs);
|
obs.applySettings(message.obs);
|
||||||
|
applyWatchSettings(message.watch);
|
||||||
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
||||||
|
|
||||||
if (message.autoConnectObs && !obs.isConnected) {
|
if (message.autoConnectObs && !obs.isConnected) {
|
||||||
@@ -125,6 +147,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
|||||||
|
|
||||||
case 'config': {
|
case 'config': {
|
||||||
obs.applySettings(message.obs);
|
obs.applySettings(message.obs);
|
||||||
|
applyWatchSettings(message.watch);
|
||||||
if (message.autoConnectObs && !obs.isConnected) {
|
if (message.autoConnectObs && !obs.isConnected) {
|
||||||
obs.connect().catch((err: Error) => report('warn', err.message));
|
obs.connect().catch((err: Error) => report('warn', err.message));
|
||||||
}
|
}
|
||||||
@@ -133,7 +156,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
|||||||
|
|
||||||
case 'command': {
|
case 'command': {
|
||||||
try {
|
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 });
|
send({ type: 'result', requestId: message.requestId, ok: true, data });
|
||||||
void pushStatus(); // état rafraîchi immédiatement après l'action
|
void pushStatus(); // état rafraîchi immédiatement après l'action
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -151,6 +174,25 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>): Promise<unknown> {
|
||||||
|
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 --------------------------------------------------------
|
// --- Boucle de statut --------------------------------------------------------
|
||||||
|
|
||||||
async function buildStatus(): Promise<AgentStatus> {
|
async function buildStatus(): Promise<AgentStatus> {
|
||||||
@@ -161,6 +203,7 @@ async function buildStatus(): Promise<AgentStatus> {
|
|||||||
return {
|
return {
|
||||||
...emptyStatus(),
|
...emptyStatus(),
|
||||||
...snapshot,
|
...snapshot,
|
||||||
|
watch: watcher.snapshot,
|
||||||
lastRecordingPath: obs.recordingPath,
|
lastRecordingPath: obs.recordingPath,
|
||||||
systemCpu: cpuUsagePercent(),
|
systemCpu: cpuUsagePercent(),
|
||||||
systemMemoryUsed: memory.used,
|
systemMemoryUsed: memory.used,
|
||||||
@@ -199,6 +242,7 @@ async function shutdown(signal: string): Promise<void> {
|
|||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
console.log(`\n${signal} reçu, arrêt de l'agent…`);
|
console.log(`\n${signal} reçu, arrêt de l'agent…`);
|
||||||
stopStatusLoop();
|
stopStatusLoop();
|
||||||
|
watcher.stop();
|
||||||
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
|
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
|
||||||
await obs.disconnect().catch(() => undefined);
|
await obs.disconnect().catch(() => undefined);
|
||||||
socket?.close(1000, 'Arrêt de l\'agent');
|
socket?.close(1000, 'Arrêt de l\'agent');
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { EventEmitter } from 'node:events';
|
|||||||
import OBSWebSocket from 'obs-websocket-js';
|
import OBSWebSocket from 'obs-websocket-js';
|
||||||
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
|
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<AgentAction, 'watch.check' | 'hotkey.fullscreen'>;
|
||||||
|
|
||||||
type ObsSnapshot = Pick<
|
type ObsSnapshot = Pick<
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
| 'obsConnected'
|
| 'obsConnected'
|
||||||
@@ -149,9 +155,16 @@ export class ObsController extends EventEmitter {
|
|||||||
this.reconnectTimer = null;
|
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 ---------------------------------
|
// --- Exécution des actions du protocole ---------------------------------
|
||||||
|
|
||||||
async execute(action: AgentAction, params: Record<string, unknown> = {}): Promise<unknown> {
|
async execute(action: ObsAction, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'obs.connect':
|
case 'obs.connect':
|
||||||
await this.connect();
|
await this.connect();
|
||||||
|
|||||||
284
packages/agent/src/watcher.ts
Normal file
284
packages/agent/src/watcher.ts
Normal file
@@ -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<ProbeResult> {
|
||||||
|
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<void>;
|
||||||
|
resumeRecording(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<WatchState> {
|
||||||
|
await this.tick();
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
@@ -110,6 +110,7 @@ export function handleAgentConnection(
|
|||||||
obs: record.obs,
|
obs: record.obs,
|
||||||
statusIntervalMs: config.statusIntervalMs,
|
statusIntervalMs: config.statusIntervalMs,
|
||||||
autoConnectObs: record.autoConnectObs,
|
autoConnectObs: record.autoConnectObs,
|
||||||
|
watch: record.watch,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { ObsSettings } from '@stream-control/shared';
|
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 { 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 } from './db.ts';
|
import { agentsRepo, logsRepo } from './db.ts';
|
||||||
@@ -81,6 +86,7 @@ api.patch('/agents/:id', (req, res) => {
|
|||||||
obs,
|
obs,
|
||||||
autoConnectObs:
|
autoConnectObs:
|
||||||
typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.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,
|
notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import type { LogEntry, LogLevel, ObsSettings, Platform } from '@stream-control/shared';
|
import type {
|
||||||
import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared';
|
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';
|
import { config } from './config.ts';
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
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);
|
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 {
|
export interface AgentRow {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -51,6 +75,7 @@ export interface AgentRow {
|
|||||||
obs_port: number;
|
obs_port: number;
|
||||||
obs_password: string;
|
obs_password: string;
|
||||||
auto_connect: number;
|
auto_connect: number;
|
||||||
|
watch_json: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
last_seen_at: number | null;
|
last_seen_at: number | null;
|
||||||
@@ -65,6 +90,7 @@ export interface AgentRecord {
|
|||||||
tokenHash: string;
|
tokenHash: string;
|
||||||
obs: ObsSettings;
|
obs: ObsSettings;
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
|
watch: WatchSettings;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastSeenAt: number | null;
|
lastSeenAt: number | null;
|
||||||
@@ -84,6 +110,9 @@ function toRecord(row: AgentRow): AgentRecord {
|
|||||||
password: row.obs_password ?? '',
|
password: row.obs_password ?? '',
|
||||||
},
|
},
|
||||||
autoConnectObs: Number(row.auto_connect) === 1,
|
autoConnectObs: Number(row.auto_connect) === 1,
|
||||||
|
watch: normalizeWatchSettings(
|
||||||
|
row.watch_json ? safeJsonParse<WatchSettings>(row.watch_json) : DEFAULT_WATCH_SETTINGS,
|
||||||
|
),
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_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 = ?'),
|
getAgentByTokenHash: db.prepare('SELECT * FROM agents WHERE token_hash = ?'),
|
||||||
insertAgent: db.prepare(`
|
insertAgent: db.prepare(`
|
||||||
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
|
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
|
||||||
obs_host, obs_port, obs_password, auto_connect, notes, created_at)
|
obs_host, obs_port, obs_password, auto_connect, watch_json,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
notes, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`),
|
`),
|
||||||
updateIdentity: db.prepare(`
|
updateIdentity: db.prepare(`
|
||||||
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
|
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
|
||||||
@@ -105,7 +135,7 @@ const stmts = {
|
|||||||
`),
|
`),
|
||||||
updateSettings: db.prepare(`
|
updateSettings: db.prepare(`
|
||||||
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
|
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
|
||||||
auto_connect = ?, notes = ?
|
auto_connect = ?, watch_json = ?, notes = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`),
|
`),
|
||||||
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
|
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
|
||||||
@@ -147,6 +177,7 @@ export const agentsRepo = {
|
|||||||
agentVersion?: string | null;
|
agentVersion?: string | null;
|
||||||
obs?: Partial<ObsSettings>;
|
obs?: Partial<ObsSettings>;
|
||||||
autoConnectObs?: boolean;
|
autoConnectObs?: boolean;
|
||||||
|
watch?: WatchSettings;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
}): AgentRecord {
|
}): AgentRecord {
|
||||||
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
|
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
|
||||||
@@ -161,6 +192,7 @@ export const agentsRepo = {
|
|||||||
obs.port,
|
obs.port,
|
||||||
obs.password,
|
obs.password,
|
||||||
input.autoConnectObs === false ? 0 : 1,
|
input.autoConnectObs === false ? 0 : 1,
|
||||||
|
JSON.stringify(normalizeWatchSettings(input.watch ?? DEFAULT_WATCH_SETTINGS)),
|
||||||
input.notes ?? null,
|
input.notes ?? null,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -188,6 +220,7 @@ export const agentsRepo = {
|
|||||||
name: string;
|
name: string;
|
||||||
obs: ObsSettings;
|
obs: ObsSettings;
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
|
watch: WatchSettings;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
@@ -197,6 +230,7 @@ export const agentsRepo = {
|
|||||||
settings.obs.port,
|
settings.obs.port,
|
||||||
settings.obs.password,
|
settings.obs.password,
|
||||||
settings.autoConnectObs ? 1 : 0,
|
settings.autoConnectObs ? 1 : 0,
|
||||||
|
JSON.stringify(normalizeWatchSettings(settings.watch)),
|
||||||
settings.notes,
|
settings.notes,
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ class Hub {
|
|||||||
type: 'config',
|
type: 'config',
|
||||||
obs: record.obs,
|
obs: record.obs,
|
||||||
autoConnectObs: record.autoConnectObs,
|
autoConnectObs: record.autoConnectObs,
|
||||||
|
watch: record.watch,
|
||||||
};
|
};
|
||||||
connection.socket.send(JSON.stringify(message));
|
connection.socket.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
@@ -171,6 +172,7 @@ class Hub {
|
|||||||
// Le mot de passe OBS n'est jamais renvoyé au navigateur.
|
// Le mot de passe OBS n'est jamais renvoyé au navigateur.
|
||||||
obs: { ...record.obs, password: record.obs.password ? '********' : '' },
|
obs: { ...record.obs, password: record.obs.password ? '********' : '' },
|
||||||
autoConnectObs: record.autoConnectObs,
|
autoConnectObs: record.autoConnectObs,
|
||||||
|
watch: record.watch,
|
||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
status: this.statusOf(record.id),
|
status: this.statusOf(record.id),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export const AGENT_ACTIONS = [
|
|||||||
'profile.set',
|
'profile.set',
|
||||||
'collection.set',
|
'collection.set',
|
||||||
'recordDirectory.set',
|
'recordDirectory.set',
|
||||||
|
'watch.check',
|
||||||
|
'hotkey.fullscreen',
|
||||||
'agent.ping',
|
'agent.ping',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -60,6 +62,97 @@ export interface AgentActionParams {
|
|||||||
'recordDirectory.set': { directory: string };
|
'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
|
// Statut remonté par un agent
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -102,6 +195,9 @@ export interface AgentStatus {
|
|||||||
diskFreeBytes?: number;
|
diskFreeBytes?: number;
|
||||||
diskTotalBytes?: number;
|
diskTotalBytes?: number;
|
||||||
|
|
||||||
|
/** Surveillance du stream source, absente si elle n'est pas configurée. */
|
||||||
|
watch?: WatchState;
|
||||||
|
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +277,7 @@ export interface WelcomeMessage {
|
|||||||
statusIntervalMs: number;
|
statusIntervalMs: number;
|
||||||
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
|
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
|
watch: WatchSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandMessage {
|
export interface CommandMessage {
|
||||||
@@ -194,6 +291,7 @@ export interface ConfigMessage {
|
|||||||
type: 'config';
|
type: 'config';
|
||||||
obs: ObsSettings;
|
obs: ObsSettings;
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
|
watch: WatchSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PingMessage {
|
export interface PingMessage {
|
||||||
@@ -218,6 +316,7 @@ export interface AgentView {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
obs: ObsSettings;
|
obs: ObsSettings;
|
||||||
autoConnectObs: boolean;
|
autoConnectObs: boolean;
|
||||||
|
watch: WatchSettings;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
}
|
}
|
||||||
@@ -255,3 +354,59 @@ export function safeJsonParse<T>(raw: string): T | null {
|
|||||||
return 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<WatchSettings>;
|
||||||
|
const base = DEFAULT_WATCH_SETTINGS;
|
||||||
|
const fullscreen = (input.fullscreen ?? {}) as Partial<FullscreenSettings>;
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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';
|
import { formatBytes, formatPercent, formatRelative, formatTimecode } from '../format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,13 +26,16 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
|
|||||||
const busy = pending !== null;
|
const busy = pending !== null;
|
||||||
const obsReady = agent.online && status.obsConnected;
|
const obsReady = agent.online && status.obsConnected;
|
||||||
|
|
||||||
|
const watch = status.watch;
|
||||||
|
const pausedLabel = watch?.autoPaused ? 'Pause · show privé' : 'En pause';
|
||||||
|
|
||||||
const state = !agent.online
|
const state = !agent.online
|
||||||
? { label: 'Hors-ligne', tone: 'offline' as const }
|
? { label: 'Hors-ligne', tone: 'offline' as const }
|
||||||
: !status.obsConnected
|
: !status.obsConnected
|
||||||
? { label: 'OBS déconnecté', tone: 'warn' as const }
|
? { label: 'OBS déconnecté', tone: 'warn' as const }
|
||||||
: status.recording
|
: status.recording
|
||||||
? {
|
? {
|
||||||
label: status.recordPaused ? 'En pause' : 'Enregistre',
|
label: status.recordPaused ? pausedLabel : 'Enregistre',
|
||||||
tone: status.recordPaused ? ('warn' as const) : ('rec' as const),
|
tone: status.recordPaused ? ('warn' as const) : ('rec' as const),
|
||||||
}
|
}
|
||||||
: { label: 'Prêt', tone: 'ok' as const };
|
: { label: 'Prêt', tone: 'ok' as const };
|
||||||
@@ -65,6 +68,15 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
|
|||||||
<p className="error small">{status.obsError}</p>
|
<p className="error small">{status.obsError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{watch?.enabled && (
|
||||||
|
<WatchStrip
|
||||||
|
watch={watch}
|
||||||
|
busy={busy}
|
||||||
|
onCheck={() => void run('watch.check')}
|
||||||
|
onFullscreen={() => void run('hotkey.fullscreen')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="metrics">
|
<div className="metrics">
|
||||||
<Metric label="Durée" value={formatTimecode(status.recordTimecode)} mono />
|
<Metric label="Durée" value={formatTimecode(status.recordTimecode)} mono />
|
||||||
<Metric label="Fichier" value={formatBytes(status.recordBytes)} />
|
<Metric label="Fichier" value={formatBytes(status.recordBytes)} />
|
||||||
@@ -150,6 +162,56 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WATCH_LABELS: Record<WatchState['state'], { text: string; tone: string }> = {
|
||||||
|
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 (
|
||||||
|
<div className="watch">
|
||||||
|
<div className="watch-head">
|
||||||
|
<span className={`badge ${info.tone}`}>{info.text}</span>
|
||||||
|
<span className="watch-name" title={`${watch.provider} · ${watch.username}`}>
|
||||||
|
{watch.username || '(aucun pseudo)'}
|
||||||
|
</span>
|
||||||
|
<div className="spacer" />
|
||||||
|
<button className="icon" disabled={busy} onClick={onCheck} title="Sonder maintenant">
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
<button className="icon" disabled={busy} onClick={onFullscreen} title="Remettre en plein écran">
|
||||||
|
⛶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{watch.lastError ? (
|
||||||
|
<span className="small error">Sonde en échec : {watch.lastError}</span>
|
||||||
|
) : (
|
||||||
|
<span className="small muted">
|
||||||
|
{watch.rawStatus ? `statut brut « ${watch.rawStatus} »` : 'pas encore sondé'}
|
||||||
|
{watch.autoPaused && ' · pause automatique active'}
|
||||||
|
{stale && ' · dernière sonde ancienne'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Metric({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
function Metric({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="metric">
|
<div className="metric">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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';
|
import { api } from '../api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +19,15 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
|
|||||||
const [directory, setDirectory] = useState(agent.status.recordDirectory ?? '');
|
const [directory, setDirectory] = useState(agent.status.recordDirectory ?? '');
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [watch, setWatch] = useState<WatchSettings>(agent.watch);
|
||||||
|
|
||||||
|
function patchWatch(patch: Partial<WatchSettings>) {
|
||||||
|
setWatch((current) => ({ ...current, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFullscreen(patch: Partial<WatchSettings['fullscreen']>) {
|
||||||
|
setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...patch } }));
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -28,6 +37,7 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
|
|||||||
notes,
|
notes,
|
||||||
autoConnectObs: autoConnect,
|
autoConnectObs: autoConnect,
|
||||||
obs: { host, port: Number(port), password },
|
obs: { host, port: Number(port), password },
|
||||||
|
watch,
|
||||||
} as Partial<AgentView>);
|
} as Partial<AgentView>);
|
||||||
notify('Configuration enregistrée');
|
notify('Configuration enregistrée');
|
||||||
onClose();
|
onClose();
|
||||||
@@ -129,6 +139,127 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="group">
|
||||||
|
<legend>Surveillance du stream</legend>
|
||||||
|
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={watch.enabled}
|
||||||
|
onChange={(event) => patchWatch({ enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Mettre l'enregistrement en pause pendant les shows privés</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label className="field small-field">
|
||||||
|
<span>Plateforme</span>
|
||||||
|
<select
|
||||||
|
value={watch.provider}
|
||||||
|
onChange={(event) =>
|
||||||
|
patchWatch({ provider: event.target.value as WatchSettings['provider'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="stripchat">Stripchat</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field grow">
|
||||||
|
<span>Pseudo du streamer</span>
|
||||||
|
<input
|
||||||
|
value={watch.username}
|
||||||
|
onChange={(event) => patchWatch({ username: event.target.value })}
|
||||||
|
placeholder="tel qu'il apparaît dans l'URL"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label className="field grow">
|
||||||
|
<span>Intervalle de sonde (s)</span>
|
||||||
|
<input
|
||||||
|
value={String(Math.round(watch.pollIntervalMs / 1000))}
|
||||||
|
onChange={(event) =>
|
||||||
|
patchWatch({ pollIntervalMs: (Number(event.target.value) || 10) * 1000 })
|
||||||
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field grow">
|
||||||
|
<span>Lectures avant pause</span>
|
||||||
|
<input
|
||||||
|
value={String(watch.confirmations)}
|
||||||
|
onChange={(event) => patchWatch({ confirmations: Number(event.target.value) || 1 })}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Statuts considérés comme « privé »</span>
|
||||||
|
<input
|
||||||
|
value={watch.privateStatuses.join(', ')}
|
||||||
|
onChange={(event) =>
|
||||||
|
patchWatch({
|
||||||
|
privateStatuses: event.target.value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="muted small">
|
||||||
|
Statuts renvoyés par Stripchat : public, private, p2p, groupShow, idle. Retire
|
||||||
|
groupShow si tu veux continuer à enregistrer les shows de groupe.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={watch.fullscreen.enabled}
|
||||||
|
onChange={(event) => patchFullscreen({ enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Rappeler le plein écran à la fin du show privé</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label className="field small-field">
|
||||||
|
<span>Touche</span>
|
||||||
|
<input
|
||||||
|
value={watch.fullscreen.key}
|
||||||
|
onChange={(event) => patchFullscreen({ key: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field grow">
|
||||||
|
<span>Titre de la fenêtre du lecteur</span>
|
||||||
|
<input
|
||||||
|
value={watch.fullscreen.windowMatch}
|
||||||
|
onChange={(event) => patchFullscreen({ windowMatch: event.target.value })}
|
||||||
|
placeholder="fragment du titre, ex. Stripchat"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field small-field">
|
||||||
|
<span>Délai (s)</span>
|
||||||
|
<input
|
||||||
|
value={String(Math.round(watch.fullscreen.delayMs / 1000))}
|
||||||
|
onChange={(event) =>
|
||||||
|
patchFullscreen({ delayMs: (Number(event.target.value) || 0) * 1000 })
|
||||||
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<button className="ghost" onClick={() => void onCommand(agent.id, 'watch.check')}>
|
||||||
|
Sonder maintenant
|
||||||
|
</button>
|
||||||
|
<button className="ghost" onClick={() => void onCommand(agent.id, 'hotkey.fullscreen')}>
|
||||||
|
Tester le plein écran
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div className="danger-zone">
|
<div className="danger-zone">
|
||||||
<div>
|
<div>
|
||||||
<strong>Jeton d'agent</strong>
|
<strong>Jeton d'agent</strong>
|
||||||
|
|||||||
@@ -349,6 +349,45 @@ textarea:focus {
|
|||||||
gap: 8px;
|
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 {
|
.card-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
Reference in New Issue
Block a user