/** * 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', 'browser.importSession', 'capture.start', 'capture.stop', 'preset.apply', '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 }; /** Sans paramètre, l'agent applique le preset configuré côté serveur. */ 'preset.apply': { presetId?: string; encoder?: RecordingEncoder }; } /** Compte rendu de l'import de session (mode `bidi` uniquement). */ export interface SessionImportResult { /** Profil Firefox personnel dont les cookies ont été copiés. */ sourceProfile: string; /** Fichiers copiés : `cookies.sqlite`, son journal WAL, le stockage local… */ copied: string[]; } // --------------------------------------------------------------------------- // Presets d'enregistrement // --------------------------------------------------------------------------- /** * Un preset décrit un compromis qualité / charge CPU. Il se traduit en écritures * dans le profil OBS courant (`SetProfileParameter`, section `SimpleOutput`) et * en un réglage vidéo (`SetVideoSettings`). * * Deux limites d'OBS qu'aucun contournement propre ne lève : * - les paramètres de profil sont relus à chaque démarrage d'enregistrement, * donc un changement de qualité prend effet au prochain enregistrement ; * - l'objet encodeur, lui, n'est instancié qu'au lancement d'OBS : changer * `RecEncoder` exige un redémarrage d'OBS pour être réellement pris en compte. */ export interface RecordingEncoderInfo { /** Valeur écrite dans `SimpleOutput/RecEncoder`. */ obsValue: string; label: string; /** Clé de profil pilotant le compromis vitesse/qualité ; null si l'encodeur n'en expose pas. */ speedKey: string | null; /** Jeu de valeurs accepté par cette clé. */ speedFamily: SpeedFamily | null; hint: string; } export type SpeedFamily = 'x264' | 'nvenc' | 'qsv' | 'amd'; /** * Pas d'auto-détection : obs-websocket ne publie pas la liste des encodeurs * disponibles, et le matériel diffère d'une VM à l'autre. C'est l'opérateur qui * choisit, VM par VM. */ export const RECORDING_ENCODERS = { x264: { obsValue: 'x264', label: 'x264 — logiciel (CPU)', speedKey: 'Preset', speedFamily: 'x264', hint: 'Toujours disponible. Le seul choix sur un VPS sans GPU.', }, x264_lowcpu: { obsValue: 'x264_lowcpu', label: 'x264 économe — logiciel (CPU)', speedKey: null, speedFamily: null, hint: "Force le réglage le plus rapide de x264 : CPU au plancher, fichiers plus gros. Ignore la vitesse du preset.", }, nvenc: { obsValue: 'nvenc', label: 'NVENC H.264 — NVIDIA', speedKey: 'NVENCPreset2', speedFamily: 'nvenc', hint: 'Encodage déporté sur le GPU : CPU quasi nul. Exige une carte NVIDIA.', }, nvenc_hevc: { obsValue: 'nvenc_hevc', label: 'NVENC HEVC — NVIDIA', speedKey: 'NVENCPreset2', speedFamily: 'nvenc', hint: 'Environ 30 % de poids en moins que H.264, moins universellement lisible.', }, qsv: { obsValue: 'qsv', label: 'Quick Sync H.264 — Intel', speedKey: 'QSVPreset', speedFamily: 'qsv', hint: 'iGPU Intel. Disponible sur beaucoup de VM bureautiques.', }, amd: { obsValue: 'amd', label: 'AMF H.264 — AMD', speedKey: 'AMDPreset', speedFamily: 'amd', hint: 'GPU AMD.', }, apple_h264: { obsValue: 'apple_h264', label: 'VideoToolbox H.264 — macOS', speedKey: null, speedFamily: null, hint: 'macOS uniquement.', }, } as const satisfies Record; export type RecordingEncoder = keyof typeof RECORDING_ENCODERS; export const DEFAULT_RECORDING_ENCODER: RecordingEncoder = 'x264'; export function isRecordingEncoder(value: unknown): value is RecordingEncoder { return typeof value === 'string' && Object.hasOwn(RECORDING_ENCODERS, value); } export interface RecordingPreset { id: string; label: string; summary: string; /** Charge CPU relative, 1 = la plus légère. Sert à ordonner l'interface. */ cpuCost: 1 | 2 | 3 | 4; /** Hauteur de sortie ; `null` conserve la résolution de la scène. Jamais d'agrandissement. */ height: number | null; /** * Images par seconde, toujours explicite. Un « ne pas toucher » rendrait le * résultat dépendant du preset appliqué juste avant : passer d'Économe à * Qualité maximale garderait les 30 fps du premier, sans que rien ne le dise. */ fps: number; /** * `SimpleOutput/RecQuality` : OBS en déduit un CRF (x264) ou un CQP (matériel). * On évite volontairement le mode à débit fixe, qui gâche des bits sur les * plans statiques et sature sur les plans animés. */ quality: 'Small' | 'HQ' | 'Lossless'; /** `mkv` survit à un plantage ; `hybrid_mp4` est lisible partout. */ format: 'mkv' | 'hybrid_mp4'; audioBitrateKbps: number; /** Valeur de vitesse à écrire, selon la famille d'encodeur retenue. */ speed: Record; } /** * Catalogue ordonné du plus léger au plus lourd. * * Le levier de qualité est le CRF (`quality`), celui de charge CPU est la * vitesse d'encodage (`speed`) puis la définition. Un preset lourd sur une VM * sous-dimensionnée fait chuter les images par seconde : la qualité perçue * baisse alors, malgré un CRF meilleur. Surveille « Frames perdues » sur la * fiche de l'agent après un changement. */ export const RECORDING_PRESETS: RecordingPreset[] = [ { id: 'light', label: 'Économe', summary: '720p 30 fps, encodage rapide. Pour les VM à petit CPU ou plusieurs captures en parallèle.', cpuCost: 1, height: 720, fps: 30, quality: 'Small', format: 'hybrid_mp4', audioBitrateKbps: 128, speed: { x264: 'superfast', nvenc: 'p3', qsv: 'speed', amd: 'speed' }, }, { id: 'balanced', label: 'Équilibré', summary: "1080p 30 fps, qualité élevée. Le compromis par défaut : bonne image sans saturer un CPU modeste.", cpuCost: 2, height: 1080, fps: 30, quality: 'HQ', format: 'hybrid_mp4', audioBitrateKbps: 160, speed: { x264: 'veryfast', nvenc: 'p5', qsv: 'balanced', amd: 'balanced' }, }, { id: 'archive', label: 'Qualité maximale', summary: "Définition de la scène, 60 fps, encodage lent. Exige un CPU confortable ou un encodeur matériel.", cpuCost: 3, height: null, fps: 60, quality: 'HQ', format: 'mkv', audioBitrateKbps: 192, speed: { x264: 'medium', nvenc: 'p6', qsv: 'quality', amd: 'quality' }, }, { id: 'lossless', label: 'Sans perte', summary: 'Aucune compression destructrice, 60 fps. Des dizaines de Go par heure : à réserver aux captures courtes.', cpuCost: 4, height: null, fps: 60, quality: 'Lossless', format: 'mkv', audioBitrateKbps: 320, // OBS impose ultrafast en sans perte ; les autres valeurs sont là pour la forme. speed: { x264: 'ultrafast', nvenc: 'p1', qsv: 'speed', amd: 'speed' }, }, ]; export const DEFAULT_RECORDING_PRESET_ID = 'balanced'; export function findRecordingPreset(id: string): RecordingPreset | null { return RECORDING_PRESETS.find((preset) => preset.id === id) ?? null; } /** Preset choisi pour un agent. Réglable indépendamment sur chaque VM. */ export interface RecordingSettings { /** Tant que c'est faux, l'agent ne touche jamais aux réglages d'OBS. */ enabled: boolean; presetId: string; encoder: RecordingEncoder; } export const DEFAULT_RECORDING_SETTINGS: RecordingSettings = { enabled: false, presetId: DEFAULT_RECORDING_PRESET_ID, encoder: DEFAULT_RECORDING_ENCODER, }; export function normalizeRecordingSettings(raw: unknown): RecordingSettings { const input = (raw ?? {}) as Partial; const base = DEFAULT_RECORDING_SETTINGS; return { enabled: input.enabled === true, presetId: typeof input.presetId === 'string' && findRecordingPreset(input.presetId) ? input.presetId : base.presetId, encoder: isRecordingEncoder(input.encoder) ? input.encoder : base.encoder, }; } /** Compte rendu détaillé d'une application de preset, remonté au dashboard. */ export interface PresetApplyResult { presetId: string; presetLabel: string; encoder: RecordingEncoder; /** Paramètres écrits, sous la forme `clé=valeur`. */ applied: string[]; /** Paramètres refusés par OBS, avec le motif. */ skipped: string[]; /** Résumé lisible du réglage vidéo obtenu (définition et fluidité de sortie). */ video: string; /** * Vrai si l'encodeur configuré diffère de celui qu'OBS a chargé au démarrage : * la nouvelle valeur est écrite, mais OBS continuera d'utiliser l'ancienne * jusqu'à son redémarrage. */ restartRequired: boolean; } // --------------------------------------------------------------------------- // 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; } /** * Sélection automatique de la meilleure qualité offerte par le lecteur Stripchat. * * Stripchat ne démarre pas toujours sur sa meilleure qualité disponible — la * valeur active par défaut varie, et rien ne garantit qu'elle corresponde au * maximum réellement proposé pour ce streamer. Chaque nouvelle page (donc * chaque nouveau stream ouvert) repart de ce choix du site, pas du précédent. * * Couplé à la structure actuelle du lecteur Stripchat (`.player-resolution`, * `.player-resolution-tooltip__button--resolution`) : n'a d'effet qu'en mode * de pilotage WebDriver BiDi, et se limite à ne rien faire si le site change * son balisage. */ export interface StreamQualitySettings { enabled: boolean; /** Délai après le chargement de la page, le temps que le lecteur s'initialise. */ 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, mais le * streamer est toujours là ». Ils mettent l'enregistrement en pause sans le * clore : le flux est censé revenir. * * Énumération relevée sur l'API Stripchat : `public`, `private`, `p2p`, * `groupShow`, `virtualPrivate`, `ticketShow`, `idle`, `off`. */ 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; /** * Clore l'enregistrement quand le flux passe hors-ligne (statut brut « off », * « idle »…), une fois le délai ci-dessous écoulé. * * Le passage hors-ligne met de toute façon l'enregistrement en pause : l'écran * d'attente n'a rien à faire dans le fichier. Ce réglage ne décide que de la * clôture définitive. */ stopOnOffline: boolean; /** * Temps hors-ligne toléré avant de clore. Une coupure brève ne doit pas * découper le fichier en deux ; une vraie fin de diffusion, si. Le compte à * rebours est annulé dès que le flux revient, public ou privé. */ offlineStopDelayMs: number; fullscreen: FullscreenSettings; streamQuality: StreamQualitySettings; } /** * 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; /** * Comment l'agent obtient la page. * * `launch` relance l'exécutable à chaque fois et lui confie l'URL : simple, * mais l'agent ne sait rien de ce qui se passe ensuite, et le plein écran * dépend alors de xdotool — donc d'une session X11. * * `bidi` ouvre Firefox une fois et garde un canal WebDriver BiDi. L'agent * navigue, déclenche le plein écran et le *vérifie* dans la page, sans passer * par le serveur d'affichage : c'est le seul mode qui fonctionne en session * Wayland native. */ mode: BrowserControlMode; /** Exécutable du navigateur. */ command: string; /** * Arguments placés avant l'URL (mode `launch` seulement). Vide par défaut, et * ce n'est pas un oubli : `--new-window` créerait une fenêtre neuve à chaque * capture, avec un nouvel identifiant X11 — la source « capture de fenêtre » * d'OBS perdrait sa cible et il faudrait la repointer à la main. Sans * argument, Firefox confie l'URL à la fenêtre déjà ouverte, qu'OBS capture. */ args: string[]; /** Délai avant l'envoi du plein écran, le temps que le lecteur démarre. */ readyDelayMs: number; /** * Sort de la fenêtre à l'arrêt de l'enregistrement. * * `blank` charge une page vide : la fenêtre survit, donc la source OBS aussi, * et le lecteur cesse de décoder la vidéo. `close` ferme la fenêtre — c'est * précisément ce qui casse la capture OBS. `keep` ne touche à rien et laisse * le flux tourner. */ onStop: BrowserStopAction; /** * Port local du « remote agent » de Firefox (mode `bidi`). * * Il n'écoute que sur la boucle locale et n'est ouvert que par l'instance que * l'agent lance lui-même. Le rendre configurable permet de faire cohabiter * plusieurs profils sur une même machine. */ remotePort: number; /** * Profil Firefox dédié (mode `bidi`). Vide : l'agent en gère un sous son * répertoire de données. * * Un profil séparé est nécessaire, pas cosmétique : le remote agent ne * s'active qu'au démarrage du processus, et deux instances ne peuvent pas * partager un profil — pointer sur celui de l'opérateur donnerait * « Firefox est déjà ouvert ». */ profileDir: string; } /** * Ce que l'agent sait du navigateur qu'il pilote. * * Le mode `launch` ne permet rien de tel : on lance un processus et on espère. * Ce retour est l'autre bénéfice de BiDi, à côté du plein écran — savoir que la * page est bien celle attendue, sans aller regarder l'écran de la VM. */ export interface BrowserState { /** Canal BiDi établi avec une instance vivante. */ connected: boolean; /** URL de l'onglet piloté. */ url?: string; /** Un élément de la page est en plein écran. */ fullscreen?: boolean; /** Dernier échec de pilotage, effacé à la reconnexion. */ lastError?: string; } export const BROWSER_CONTROL_MODES = ['launch', 'bidi'] as const; export type BrowserControlMode = (typeof BROWSER_CONTROL_MODES)[number]; export const BROWSER_STOP_ACTIONS = ['blank', 'keep', 'close'] as const; export type BrowserStopAction = (typeof BROWSER_STOP_ACTIONS)[number]; export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = { enabled: false, // `launch` reste le défaut : basculer un agent existant en `bidi` ouvre une // nouvelle fenêtre Firefox, que la source OBS doit être repointée sur une // fois. C'est un choix d'opérateur, pas un effet de bord de mise à jour. mode: 'launch', command: 'firefox', args: [], readyDelayMs: 8000, onStop: 'blank', remotePort: 9222, profileDir: '', }; export function normalizeBrowserSettings(raw: unknown): BrowserSettings { const input = (raw ?? {}) as Partial; const base = DEFAULT_BROWSER_SETTINGS; const delay = Number(input.readyDelayMs); const port = Number(input.remotePort); return { enabled: input.enabled === true, mode: (BROWSER_CONTROL_MODES as readonly string[]).includes(input.mode as string) ? (input.mode as BrowserControlMode) : base.mode, 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, onStop: readStopAction(raw), // Bornes hautes des ports non privilégiés : sous 1024, l'agent tourne sans // droit de liaison et Firefox échouerait au démarrage. remotePort: Number.isFinite(port) ? Math.min(Math.max(Math.round(port), 1024), 65_535) : base.remotePort, profileDir: typeof input.profileDir === 'string' ? input.profileDir.trim() : base.profileDir, }; } /** Lit le sort de la fenêtre, en acceptant l'ancien booléen `closeOnStop`. */ function readStopAction(raw: unknown): BrowserStopAction { const input = (raw ?? {}) as Partial & { closeOnStop?: boolean }; if ((BROWSER_STOP_ACTIONS as readonly string[]).includes(input.onStop as string)) { return input.onStop as BrowserStopAction; } // Configuration écrite avant l'introduction du choix à trois valeurs. if (typeof input.closeOnStop === 'boolean') return input.closeOnStop ? 'close' : 'keep'; return DEFAULT_BROWSER_SETTINGS.onStop; } export const DEFAULT_WATCH_SETTINGS: WatchSettings = { enabled: false, provider: 'stripchat', username: '', pollIntervalMs: 10_000, // `idle` est le « revient bientôt » : le modèle est connecté (isOnline) mais ne // diffuse pas (isLive faux). Il relève de la pause, pas de la clôture — à la // différence de `off`, qui est une déconnexion franche. privateStatuses: ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow', 'idle'], confirmations: 2, pauseOnPrivate: true, resumeOnPublic: true, stopOnOffline: true, offlineStopDelayMs: 3_600_000, fullscreen: { enabled: true, key: 'f', windowMatch: 'Stripchat', delayMs: 4000, }, streamQuality: { enabled: true, delayMs: 3000, }, }; /** É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; /** Depuis quand le flux est hors-ligne, absent s'il ne l'est pas. */ offlineSince?: number; /** Échéance de la clôture automatique, absente si aucun compte à rebours ne court. */ stopScheduledAt?: number; /** Lectures « privé » accumulées, en attente du seuil de confirmation. */ pendingConfirmations: number; } 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; /** * Deux goulots distincts, à ne pas confondre — c'est eux qui désignent la * cause d'une chute de performance. * * `renderSkipped` : la composition de la scène n'a pas tenu la cadence. En * cause : la méthode de capture, la définition, ou un bureau sans * accélération 3D. Changer d'encodeur n'y ferait rien. * * `droppedFrames` (encodage) : la scène était prête mais l'encodeur n'a pas * suivi. Là, le preset et l'encodeur sont les bons leviers. */ droppedFrames?: number; outputTotalFrames?: number; renderSkippedFrames?: number; renderTotalFrames?: number; /** Durée moyenne de composition d'une image, en millisecondes. */ frameRenderTimeMs?: 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; /** Navigateur piloté, absent hors du mode `bidi`. */ browser?: BrowserState; /** * 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'; /** * Nature d'un évènement journalisé. * * Le message reste du texte libre, destiné à être lu ; ce champ le classe, pour * que l'historique d'une VM puisse être filtré et illustré sans avoir à faire * de l'analyse de chaîne. Il est facultatif : un agent d'une version antérieure * n'en envoie pas, et son entrée s'affiche simplement sans pictogramme. * * Le préfixe porte la catégorie — l'interface s'en sert pour ses filtres, donc * un nouvel évènement doit réutiliser un préfixe existant quand c'est possible. */ export const AGENT_EVENTS = [ 'agent.connected', 'agent.disconnected', 'agent.enrolled', 'agent.updated', 'obs.connected', 'obs.disconnected', 'record.started', 'record.stopped', 'record.paused', 'record.resumed', 'record.split', 'stream.started', 'stream.stopped', 'capture.started', 'capture.stopped', 'watch.started', 'watch.private', 'watch.public', 'watch.offline', 'watch.failed', 'fullscreen.restored', 'fullscreen.failed', 'quality.selected', 'quality.failed', 'browser.opened', 'browser.closed', 'browser.imported', 'browser.importFailed', 'preset.applied', 'preset.failed', 'config.changed', 'command.failed', ] as const; export type AgentEvent = (typeof AGENT_EVENTS)[number]; export function isAgentEvent(value: unknown): value is AgentEvent { return typeof value === 'string' && (AGENT_EVENTS as readonly string[]).includes(value); } 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; event?: AgentEvent; } 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; recording: RecordingSettings; } export interface CommandMessage { type: 'command'; requestId: string; action: AgentAction; params?: Record; } export interface ConfigMessage { type: 'config'; obs: ObsSettings; autoConnectObs: boolean; watch: WatchSettings; browser: BrowserSettings; recording: RecordingSettings; } 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; recording: RecordingSettings; notes: string | null; status: AgentStatus; } export interface LogEntry { id: number; agentId: string | null; agentName: string | null; level: LogLevel; message: string; ts: number; /** Absent sur les entrées anciennes et sur celles des agents non mis à jour. */ event: AgentEvent | null; } /** * Étiquette librement créée par l'opérateur, posable sur autant de profils * qu'on veut. * * Une entité à part entière plutôt qu'un simple texte répété sur chaque profil : * c'est ce qui permet de la renommer d'un geste, et de la garder disponible * même quand plus aucun profil ne la porte. */ export interface Tag { id: string; name: string; } /** Limite de saisie : au-delà, l'étiquette ne tient plus dans une vignette. */ export const TAG_MAX_LENGTH = 24; /** * Nettoie un nom d'étiquette saisi, ou renvoie `null` s'il ne reste rien. * * Les espaces intérieurs sont réduits et les extrémités coupées : « en soirée » * et « en soirée » désignent la même étiquette, et les laisser cohabiter ferait * deux entrées indiscernables à l'œil dans la liste des filtres. */ export function normalizeTagName(raw: unknown): string | null { if (typeof raw !== 'string') return null; const name = raw.replace(/\s+/g, ' ').trim().slice(0, TAG_MAX_LENGTH); return name.length > 0 ? name : null; } /** * 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; /** Épinglé en tête de liste. Purement un confort de tri, sans effet ailleurs. */ favorite: boolean; /** * Lancer l'enregistrement sans intervention dès que ce profil est en direct * depuis assez longtemps. Exige un agent assigné. * * Plusieurs profils peuvent l'activer sur une même VM : elle n'en capture * qu'un à la fois, et c'est {@link WatchTarget.priority} qui arbitre. */ autoRecord: boolean; /** * Rang dans la file d'attente d'une VM, le plus élevé l'emportant. * * L'échelle est globale pour rester comparable partout, mais elle ne tranche * qu'entre profils assignés au même agent : deux VM distinctes ne se * disputent rien. */ priority: number; /** * Autorise ce profil à couper une capture de priorité inférieure sur sa VM. * * Séparé de la priorité, et non déduit d'elle : interrompre un * enregistrement en cours perd la fin d'un fichier. Cela ne doit pas * découler d'un simple changement de rang, mais d'un choix explicite. */ preempt: 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; /** * Étiquettes libres posées par l'opérateur, pour trier une liste qui grandit. * * Embarquées dans le profil plutôt que référencées par identifiant : la liste * part déjà en entier à chaque mise à jour temps réel, et une poignée de noms * ne pèse rien à côté. Le dashboard n'a de ce fait aucune seconde liste à * tenir à jour pour afficher une vignette. */ tags: Tag[]; /** Photo de profil. */ avatarUrl: string | null; /** * Vignette du flux — voir {@link StripchatStatus.previewUrl}. Absente hors * diffusion connue ; la carte ne montre l'aperçu que sur un profil en direct. */ previewUrl: string | null; /** Horodatage de la vignette, à ajouter en paramètre d'URL pour éviter le cache. */ snapshotAt: number | 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; } // --------------------------------------------------------------------------- // Historique de diffusion // --------------------------------------------------------------------------- /** * Une diffusion publique observée, du passage en direct à sa fin. * * Distinct de `WatchTarget.lastLive*`, qui ne garde que la dernière : ici on * conserve l'historique, c'est ce qui rend une frise possible. */ export interface StreamSession { id: number; targetId: string; startedAt: number; /** Nul tant que la diffusion est en cours. */ endedAt: number | null; /** * Une vignette de cette diffusion a été copiée sur le serveur, et reste donc * consultable des mois plus tard. * * Nécessaire parce que `WatchTarget.previewUrl` ne vaut que sur l'instant : * elle pointe vers le CDN de la plateforme, dont l'image expire en une * trentaine de minutes. Rien de ce qui est affiché en direct ne survit à la * diffusion elle-même — d'où cette copie, prise une seule fois par diffusion. */ hasPreview: boolean; } /** * Un intervalle réellement capturé par une VM. * * Séparé des diffusions à dessein : un enregistrement démarre en général après * le début du stream, peut s'arrêter avant sa fin, et une même diffusion peut * en compter plusieurs. Les superposer sur la frise est justement ce qui montre * ce qui a été gardé et ce qui a été manqué. */ export interface RecordingSpan { id: number; /** Nul si l'enregistrement n'a pas pu être rattaché à un profil suivi. */ targetId: string | null; agentId: string | null; startedAt: number; /** Nul tant que la capture est en cours. */ endedAt: number | null; } export interface TimelineData { sessions: StreamSession[]; spans: RecordingSpan[]; } /** * Le pseudo que cette VM est en train de capturer, ou `null`. * * `startTargetRecording()` cale la surveillance de l'agent sur le profil qu'il * enregistre : ce pseudo est donc le seul lien fiable entre une capture en * cours et le profil concerné. Sans lui, une VM occupée ferait passer *tous* * les profils qui lui sont assignés pour « en enregistrement », alors qu'elle * n'en capture qu'un seul à la fois. */ export function recordingUsername(agent: Pick): string | null { if (!agent.status.recording) return null; return agent.watch.username || null; } /** * Rangs proposés par l'interface. La colonne reste un entier libre : affiner * l'échelle un jour ne demandera pas de migration. */ export const PRIORITY_LEVELS = [ { value: 0, label: 'Basse' }, { value: 1, label: 'Normale' }, { value: 2, label: 'Haute' }, { value: 3, label: 'Critique' }, ] as const; export const DEFAULT_PRIORITY = 1; export function priorityLabel(value: number): string { return PRIORITY_LEVELS.find((level) => level.value === value)?.label ?? `Rang ${value}`; } export interface PreemptionVerdict { allowed: boolean; /** Pourquoi l'interruption est refusée — destiné à être affiché tel quel. */ reason?: string; } /** * `candidate` a-t-il le droit de couper la capture en cours au profit de la sienne ? * * Trois refus, dans cet ordre, du plus prudent au plus attendu : * * 1. **Capture non identifiée.** Si l'on ne sait pas quel profil la VM * enregistre, on ne la coupe pas : le risque est de détruire une capture * dont on ignore la valeur, et l'on n'a de toute façon aucune priorité à * lui comparer. * 2. **Interruption non autorisée** sur le profil candidat. * 3. **Priorité insuffisante** — strictement supérieure exigée, ce qui rend * les égalités inoffensives : à rang égal, la capture en place l'emporte. */ export function preemptionVerdict( candidate: Pick, current: Pick | null, ): PreemptionVerdict { if (!current) { return { allowed: false, reason: "la capture en cours n'a pas pu être rattachée à un profil suivi", }; } // Rien à interrompre : c'est déjà ce profil qui est capturé. Refusé plutôt // qu'autorisé — l'appelant s'apprêtait à lancer une seconde capture. if (current.id === candidate.id) { return { allowed: false, reason: 'sa capture est déjà en cours sur cette VM' }; } const name = current.label ?? current.username; if (!candidate.preempt) { return { allowed: false, reason: `« ${name} » est en cours de capture et l'interruption n'est pas autorisée sur ce profil`, }; } if (candidate.priority <= current.priority) { return { allowed: false, reason: `« ${name} » est en cours de capture avec une priorité au moins égale ` + `(${priorityLabel(current.priority)})`, }; } return { allowed: true }; } /** Cette VM enregistre-t-elle bien *ce* profil, et non un autre qui lui est assigné ? */ export function isRecordingTarget( agent: Pick | null | undefined, target: Pick, ): boolean { if (!agent) return false; return recordingUsername(agent) === target.username; } 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 streamQuality = (input.streamQuality ?? {}) 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, stopOnOffline: input.stopOnOffline !== false, // Plafond à 24 h : au-delà, l'enregistrement mobiliserait une VM pour rien. offlineStopDelayMs: clamp(input.offlineStopDelayMs, base.offlineStopDelayMs, 0, 86_400_000), fullscreen: { enabled: fullscreen.enabled !== false, key: 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), }, streamQuality: { enabled: streamQuality.enabled !== false, delayMs: clamp(streamQuality.delayMs, base.streamQuality.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`, `virtualPrivate`, `idle`, `off`. * * Deux champs voisins prêtent à confusion et ne doivent pas servir ici : * - `offlineStatus` est le message d'absence libre du modèle. Il reste * renseigné pendant qu'il diffuse : il ne dit rien de l'état courant. * - `isLive` / `isOnline` distinguent `idle` (connecté, ne diffuse pas) de * `off` (déconnecté), ce que `status` exprime déjà. * * 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; /** * Image du flux — pas le flux lui-même : l'agent seul pilote un navigateur, * le serveur n'a que cette API REST. * * Reconstruite depuis `img.doppiocdn.net/thumbs/{snapshotTimestamp}/{id}` et * non lue telle quelle sur `previewUrlThumbBig` / `previewUrlThumbSmall` : * ces deux champs, malgré leur nom, pointent vers la photo de couverture du * profil — fixe, choisie par le modèle — et non vers une image de la * diffusion en cours. `img.doppiocdn.net` est la vraie vignette caméra, * confirmée en comparant les deux : contenu différent, cache CDN de 30 min * au lieu de 30 jours, `Last-Modified` à quelques secondes de la requête. * * Absente si le modèle n'a jamais diffusé (pas d'identifiant numérique ou * d'horodatage exploitable côté plateforme). */ previewUrl: string | null; /** * Horodatage de cette vignette côté plateforme — déjà encodé dans * `previewUrl`, donc sans utilité pour le cache : une nouvelle vignette porte * de toute façon une URL différente. Exposé pour l'affichage seulement * (« vignette vieille de X »). */ snapshotAt: 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, previewUrl: null, snapshotAt: null, }; } if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`); const payload = (await response.json()) as { user?: { user?: { id?: number; status?: string; avatarUrl?: string; statusChangedAt?: string; snapshotTimestamp?: number; }; }; }; 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; // Secondes côté API, comme tous les timestamps Unix bruts de cette plateforme. const snapshotAt = Number(profile?.snapshotTimestamp) * 1000; const modelId = Number(profile?.id); return { raw, state: mapStreamStatus(raw, privateStatuses), avatarUrl: typeof profile?.avatarUrl === 'string' && profile.avatarUrl ? profile.avatarUrl : null, statusChangedAt: Number.isFinite(changedAt) ? changedAt : null, previewUrl: Number.isFinite(modelId) && modelId > 0 && Number.isFinite(snapshotAt) && snapshotAt > 0 ? `https://img.doppiocdn.net/thumbs/${Math.round(snapshotAt / 1000)}/${modelId}` : null, snapshotAt: Number.isFinite(snapshotAt) && snapshotAt > 0 ? snapshotAt : 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'; // off / offline / deleted / notFound : le modèle n'est plus là du tout. return 'offline'; } // --------------------------------------------------------------------------- // Notifications Pushover // --------------------------------------------------------------------------- /** * Identifiants Pushover, réglage serveur unique (pas par agent) : les * notifications concernent l'opérateur, pas une VM en particulier. * * Complète — sans le remplacer — le mécanisme de notification déjà en place * (notification navigateur, `WatchTarget.notify`) : ce canal-ci atteint * l'opérateur même dashboard fermé, ce que le navigateur ne peut pas faire. */ export interface PushoverSettings { enabled: boolean; /** Clé utilisateur ou de groupe Pushover (`Your User Key` sur pushover.net). */ userKey: string; /** Jeton de l'application créée sur pushover.net/apps/build. */ appToken: string; } export const DEFAULT_PUSHOVER_SETTINGS: PushoverSettings = { enabled: false, userKey: '', appToken: '', }; export function normalizePushoverSettings(raw: unknown): PushoverSettings { const input = (raw ?? {}) as Partial; return { enabled: input.enabled === true, userKey: typeof input.userKey === 'string' ? input.userKey.trim() : '', appToken: typeof input.appToken === 'string' ? input.appToken.trim() : '', }; }