/** * Protocole partagé entre le serveur de contrôle, les agents et le dashboard. * * Transport : WebSocket, messages JSON, un champ `type` discriminant. * - agent <-> serveur : /ws/agent (l'agent initie la connexion sortante) * - browser <- serveur : /ws/dashboard (flux de statut temps réel) */ export const PROTOCOL_VERSION = 1; export type Platform = 'windows' | 'linux' | 'darwin' | 'unknown'; /** Paramètres de connexion à obs-websocket (plugin intégré à OBS >= 28). */ export interface ObsSettings { host: string; port: number; /** Mot de passe obs-websocket ; chaîne vide si l'authentification est désactivée. */ password: string; } export const DEFAULT_OBS_SETTINGS: ObsSettings = { host: '127.0.0.1', port: 4455, password: '', }; // --------------------------------------------------------------------------- // Actions pilotables sur un agent // --------------------------------------------------------------------------- export const AGENT_ACTIONS = [ 'obs.connect', 'obs.disconnect', 'obs.refresh', 'record.start', 'record.stop', 'record.pause', 'record.resume', 'record.split', 'stream.start', 'stream.stop', 'scene.set', 'profile.set', 'collection.set', 'recordDirectory.set', 'watch.check', 'hotkey.fullscreen', 'agent.ping', ] as const; export type AgentAction = (typeof AGENT_ACTIONS)[number]; export function isAgentAction(value: unknown): value is AgentAction { return typeof value === 'string' && (AGENT_ACTIONS as readonly string[]).includes(value); } /** Paramètres attendus par action (les autres actions n'en prennent aucun). */ export interface AgentActionParams { 'scene.set': { scene: string }; 'profile.set': { profile: string }; 'collection.set': { collection: 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 // --------------------------------------------------------------------------- export interface AgentStatus { /** L'agent a-t-il une session obs-websocket établie ? */ obsConnected: boolean; obsVersion?: string; obsError?: string; recording: boolean; recordPaused: boolean; /** Durée d'enregistrement au format HH:MM:SS.mmm renvoyé par OBS. */ recordTimecode?: string; recordBytes?: number; /** Chemin du dernier fichier écrit (renseigné à l'arrêt de l'enregistrement). */ lastRecordingPath?: string; recordDirectory?: string; streaming: boolean; streamTimecode?: string; currentScene?: string; scenes: string[]; currentProfile?: string; profiles: string[]; currentCollection?: string; collections: string[]; /** Statistiques OBS. */ cpuUsage?: number; fps?: number; droppedFrames?: number; renderSkippedFrames?: number; /** Statistiques machine (collectées par l'agent, pas par OBS). */ systemCpu?: number; systemMemoryUsed?: number; systemMemoryTotal?: number; diskFreeBytes?: number; diskTotalBytes?: number; /** Surveillance du stream source, absente si elle n'est pas configurée. */ watch?: WatchState; updatedAt: number; } export function emptyStatus(): AgentStatus { return { obsConnected: false, recording: false, recordPaused: false, streaming: false, scenes: [], profiles: [], collections: [], updatedAt: 0, }; } // --------------------------------------------------------------------------- // Messages agent -> serveur // --------------------------------------------------------------------------- export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export interface HelloMessage { type: 'hello'; protocol: number; /** Absent lors du tout premier enrôlement : le serveur en attribue un. */ agentId?: string; name: string; hostname: string; platform: Platform; agentVersion: string; } export interface StatusMessage { type: 'status'; status: AgentStatus; } export interface ResultMessage { type: 'result'; requestId: string; ok: boolean; data?: unknown; error?: string; } export interface LogMessage { type: 'log'; level: LogLevel; message: string; ts: number; } export interface PongMessage { type: 'pong'; ts: number; } export type AgentToServer = | HelloMessage | StatusMessage | ResultMessage | LogMessage | PongMessage; // --------------------------------------------------------------------------- // Messages serveur -> agent // --------------------------------------------------------------------------- export interface WelcomeMessage { type: 'welcome'; agentId: string; /** Fourni uniquement lors de l'enrôlement : l'agent doit le persister. */ token?: string; obs: ObsSettings; /** Fréquence de remontée de statut demandée. */ statusIntervalMs: number; /** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */ autoConnectObs: boolean; watch: WatchSettings; } export interface CommandMessage { type: 'command'; requestId: string; action: AgentAction; params?: Record; } export interface ConfigMessage { type: 'config'; obs: ObsSettings; autoConnectObs: boolean; watch: WatchSettings; } export interface PingMessage { type: 'ping'; ts: number; } export type ServerToAgent = WelcomeMessage | CommandMessage | ConfigMessage | PingMessage; // --------------------------------------------------------------------------- // Vue agrégée exposée au dashboard // --------------------------------------------------------------------------- export interface AgentView { id: string; name: string; hostname: string | null; platform: Platform; agentVersion: string | null; online: boolean; lastSeenAt: number | null; createdAt: number; obs: ObsSettings; autoConnectObs: boolean; watch: WatchSettings; notes: string | null; status: AgentStatus; } export interface LogEntry { id: number; agentId: string | null; agentName: string | null; level: LogLevel; message: string; ts: number; } export type ServerToDashboard = | { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[] } | { type: 'agent'; agent: AgentView } | { type: 'agent.removed'; agentId: string } | { type: 'log'; entry: LogEntry }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- export function detectPlatform(raw: string): Platform { if (raw === 'win32') return 'windows'; if (raw === 'linux') return 'linux'; if (raw === 'darwin') return 'darwin'; return 'unknown'; } export function safeJsonParse(raw: string): T | null { try { return JSON.parse(raw) as T; } catch { 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'; }