/** * 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', 'browser.open', 'browser.close', 'capture.start', 'capture.stop', 'agent.update', '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; } /** * Pilotage du navigateur de la VM. * * On lance le navigateur déjà installé, avec son profil et sa session : c'est ce * qui donne accès au flux comme si l'opérateur l'ouvrait lui-même. Aucune * instance dédiée, aucun profil de test. */ export interface BrowserSettings { enabled: boolean; /** Exécutable du navigateur. */ command: string; /** Arguments placés avant l'URL. */ args: string[]; /** Délai avant l'envoi du plein écran, le temps que le lecteur démarre. */ readyDelayMs: number; /** Fermer la fenêtre quand l'enregistrement s'arrête. */ closeOnStop: boolean; } export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = { enabled: false, command: 'firefox', args: ['--new-window'], readyDelayMs: 8000, closeOnStop: true, }; export function normalizeBrowserSettings(raw: unknown): BrowserSettings { const input = (raw ?? {}) as Partial; const base = DEFAULT_BROWSER_SETTINGS; const delay = Number(input.readyDelayMs); return { enabled: input.enabled === true, command: typeof input.command === 'string' && input.command.trim() ? input.command.trim() : base.command, args: Array.isArray(input.args) ? input.args.filter((arg): arg is string => typeof arg === 'string' && arg.trim() !== '') : base.args, readyDelayMs: Number.isFinite(delay) ? Math.min(Math.max(Math.round(delay), 0), 120_000) : base.readyDelayMs, closeOnStop: input.closeOnStop !== false, }; } 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; /** * Empreinte courte du binaire en cours d'exécution. Deux agents partageant * cette valeur tournent sur le même build — c'est ce qui rend visible un * agent resté en arrière après une mise à jour. */ buildId?: string; /** Vrai si l'agent sait se mettre à jour seul (URL de paquet connue). */ canSelfUpdate?: boolean; 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; browser: BrowserSettings; } export interface CommandMessage { type: 'command'; requestId: string; action: AgentAction; params?: Record; } export interface ConfigMessage { type: 'config'; obs: ObsSettings; autoConnectObs: boolean; watch: WatchSettings; browser: BrowserSettings; } 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; browser: BrowserSettings; notes: string | null; status: AgentStatus; } export interface LogEntry { id: number; agentId: string | null; agentName: string | null; level: LogLevel; message: string; ts: number; } /** * Profil surveillé par le serveur : on colle l'URL d'un streamer, le serveur * sonde son statut et signale les passages en direct. */ export interface WatchTarget { id: string; provider: WatchProvider; username: string; /** Nom lisible, à défaut le pseudo. */ label: string | null; url: string; /** Agent qui enregistrera ce streamer, s'il est assigné. */ agentId: string | null; notify: boolean; state: StreamState; rawStatus: string | null; /** Depuis quand l'état est stable, selon nos propres observations. */ stateSince: number; lastCheckedAt: number | null; lastError: string | null; createdAt: number; /** Photo de profil. */ avatarUrl: string | null; /** * Début du statut courant d'après la plateforme. Quand `state` vaut `public`, * c'est l'heure de début du stream en cours. */ statusChangedAt: number | null; /** Dernier stream terminé : début et fin observés. */ lastLiveStartedAt: number | null; lastLiveEndedAt: number | null; } export type ServerToDashboard = | { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] } | { type: 'agent'; agent: AgentView } | { type: 'agent.removed'; agentId: string } | { type: 'log'; entry: LogEntry } | { type: 'target'; target: WatchTarget } | { type: 'target.removed'; targetId: string } /** Transition vers le direct : c'est ce qui déclenche la notification. */ | { type: 'target.live'; target: WatchTarget }; // --------------------------------------------------------------------------- // 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), }, }; } /** * Extrait le pseudo d'un profil Stripchat à partir d'une URL complète, d'une URL * sans schéma, ou d'un pseudo saisi seul. */ export function parseStripchatUsername(input: string): string | null { const trimmed = input.trim(); if (!trimmed) return null; const looksLikeUrl = trimmed.includes('/') || trimmed.includes('.'); if (looksLikeUrl) { try { const url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); if (!/(^|\.)stripchat\.com$/i.test(url.hostname)) return null; const first = url.pathname.split('/').filter(Boolean)[0]; return first && USERNAME_PATTERN.test(first) ? first : null; } catch { return null; } } return USERNAME_PATTERN.test(trimmed) ? trimmed : null; } const USERNAME_PATTERN = /^[A-Za-z0-9_.-]{2,64}$/; export function stripchatProfileUrl(username: string): string { return `https://fr.stripchat.com/${username}`; } /** * Interroge le statut d'un modèle 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`. * * Mutualisé entre l'agent (pause automatique) et le serveur (veille) : une seule * définition de l'endpoint et du chemin du champ à maintenir. */ export interface StripchatStatus { raw: string; state: StreamState; /** Photo de profil, absente si le modèle n'en a pas. */ avatarUrl: string | null; /** * Début du statut courant. Quand le modèle est public, c'est l'heure de début * du stream en cours — bien plus précis que notre propre première observation. */ statusChangedAt: number | null; } export async function fetchStripchatStatus( username: string, privateStatuses: string[], timeoutMs = 8000, ): Promise { const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent( username, )}/cam`; const response = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36', accept: 'application/json', }, signal: AbortSignal.timeout(timeoutMs), }); if (response.status === 404) { return { raw: 'notFound', state: 'offline', avatarUrl: null, statusChangedAt: null }; } if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`); const payload = (await response.json()) as { user?: { user?: { status?: string; avatarUrl?: string; statusChangedAt?: string } }; }; const profile = payload?.user?.user; const raw = profile?.status; if (typeof raw !== 'string') { throw new Error('Réponse Stripchat inattendue : user.user.status absent'); } const changedAt = profile?.statusChangedAt ? Date.parse(profile.statusChangedAt) : Number.NaN; return { raw, state: mapStreamStatus(raw, privateStatuses), avatarUrl: typeof profile?.avatarUrl === 'string' && profile.avatarUrl ? profile.avatarUrl : null, statusChangedAt: Number.isFinite(changedAt) ? changedAt : null, }; } /** 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'; }