import { EventEmitter } from 'node:events'; import OBSWebSocket from 'obs-websocket-js'; import type { AgentAction, AgentEvent, AgentStatus, ObsSettings, PresetApplyResult, RecordingPreset, RecordingSettings, } from '@stream-control/shared'; import { RECORDING_ENCODERS, findRecordingPreset } from '@stream-control/shared'; /** Traduit l'état d'une sortie OBS en évènement d'historique et en libellé. */ const RECORD_STATES: Record = { STARTED: { event: 'record.started', label: 'Enregistrement démarré' }, STOPPED: { event: 'record.stopped', label: 'Enregistrement arrêté' }, PAUSED: { event: 'record.paused', label: 'Enregistrement mis en pause' }, RESUMED: { event: 'record.resumed', label: 'Enregistrement repris' }, }; const STREAM_STATES: Record = { STARTED: { event: 'stream.started', label: 'Diffusion démarrée' }, STOPPED: { event: 'stream.stopped', label: 'Diffusion arrêtée' }, }; /** * Actions relevant d'OBS. `watch.*`, `hotkey.*`, `preset.apply` et * `agent.update` sont traitées en amont par l'agent : elles ne se réduisent pas * à un appel obs-websocket. */ export type ObsAction = Exclude< AgentAction, | 'watch.check' | 'hotkey.fullscreen' | 'agent.update' | 'browser.open' | 'browser.close' | 'capture.start' | 'capture.stop' | 'preset.apply' >; type ObsSnapshot = Pick< AgentStatus, | 'obsConnected' | 'obsVersion' | 'obsError' | 'recording' | 'recordPaused' | 'recordTimecode' | 'recordBytes' | 'recordDirectory' | 'streaming' | 'streamTimecode' | 'currentScene' | 'scenes' | 'currentProfile' | 'profiles' | 'currentCollection' | 'collections' | 'cpuUsage' | 'fps' | 'droppedFrames' | 'renderSkippedFrames' >; const RECONNECT_DELAY_MS = 5000; const CONNECT_TIMEOUT_MS = 10_000; /** Borne une promesse qui pourrait ne jamais se résoudre. */ async function withTimeout(promise: Promise, ms: number, message: string): Promise { let timer: NodeJS.Timeout | undefined; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }), ]); } finally { if (timer) clearTimeout(timer); } } /** * Traduit une erreur de connexion en message actionnable. Le brut * (« ECONNREFUSED ») dit ce qui a échoué, jamais quoi faire ensuite. */ function describeConnectionError(err: unknown): string { const message = err instanceof Error ? err.message : String(err); const sysCode = (err as NodeJS.ErrnoException).code; const obsCode = (err as { code?: unknown }).code; if (sysCode === 'ECONNREFUSED' || message.includes('ECONNREFUSED')) { return `${message} — rien n'écoute sur ce port. Lance OBS, puis Outils → Paramètres du serveur WebSocket → coche « Activer le serveur WebSocket ».`; } if (obsCode === 4009 || /authent/i.test(message)) { return `${message} — mot de passe obs-websocket incorrect ou manquant. Dans OBS : Outils → Paramètres du serveur WebSocket → Afficher les informations de connexion.`; } if (sysCode === 'ENOTFOUND' || sysCode === 'EHOSTUNREACH' || sysCode === 'ETIMEDOUT') { return `${message} — hôte injoignable. Vérifie l'adresse configurée pour cet agent.`; } return message; } /** * Enveloppe obs-websocket : maintient la session, expose les actions du * protocole et produit un instantané d'état à chaque cycle de statut. */ export class ObsController extends EventEmitter { private readonly obs = new OBSWebSocket(); private settings: ObsSettings; private connected = false; private connecting: Promise | null = null; private autoReconnect = false; private reconnectTimer: NodeJS.Timeout | null = null; private lastError: string | undefined; private lastRecordingPath: string | undefined; private cachedVersion: string | undefined; constructor(settings: ObsSettings) { super(); this.settings = settings; this.obs.on('ConnectionClosed', (err: unknown) => { const wasConnected = this.connected; this.connected = false; this.cachedVersion = undefined; if (wasConnected) { this.lastError = err instanceof Error ? err.message : undefined; this.emit( 'log', 'warn', `Session OBS fermée${this.lastError ? ` : ${this.lastError}` : ''}`, 'obs.disconnected', ); } this.scheduleReconnect(); }); this.obs.on('RecordStateChanged', (event) => { if (typeof event.outputPath === 'string' && event.outputPath) { this.lastRecordingPath = event.outputPath; } // La source fait autorité : elle capte aussi les pauses déclenchées depuis // l'interface d'OBS, que le dashboard ne verrait pas autrement. const raw = String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', ''); const known = RECORD_STATES[raw]; this.emit('log', 'info', known?.label ?? `Enregistrement : ${raw}`, known?.event); }); this.obs.on('StreamStateChanged', (event) => { const raw = String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', ''); const known = STREAM_STATES[raw]; this.emit('log', 'info', known?.label ?? `Diffusion : ${raw}`, known?.event); }); } get isConnected(): boolean { return this.connected; } applySettings(settings: ObsSettings): void { const changed = settings.host !== this.settings.host || settings.port !== this.settings.port || settings.password !== this.settings.password; this.settings = settings; if (changed && this.connected) { this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion', 'config.changed'); void this.reconnect(); } } async connect(): Promise { if (this.connected) return; if (this.connecting) return this.connecting; this.autoReconnect = true; const url = `ws://${this.settings.host}:${this.settings.port}`; this.connecting = (async () => { try { // Un port ouvert par autre chose qu'obs-websocket ne renvoie jamais de // poignée de main : sans ce délai, la promesse resterait éternellement // en attente et, comme elle est réutilisée ci-dessus, toute tentative // ultérieure serait bloquée jusqu'au redémarrage de l'agent. const info = await withTimeout( this.obs.connect(url, this.settings.password || undefined, { rpcVersion: 1 }), CONNECT_TIMEOUT_MS, `aucune réponse après ${CONNECT_TIMEOUT_MS / 1000} s — le port répond mais ne parle pas obs-websocket ?`, ); this.connected = true; this.lastError = undefined; this.cachedVersion = info.obsWebSocketVersion; this.emit( 'log', 'info', `Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`, 'obs.connected', ); } catch (err) { this.connected = false; this.lastError = describeConnectionError(err); // Après un délai dépassé, le socket peut rester à demi ouvert. await this.obs.disconnect().catch(() => undefined); throw new Error(`Connexion à OBS impossible (${url}) : ${this.lastError}`); } finally { this.connecting = null; } })(); return this.connecting; } async disconnect(): Promise { this.autoReconnect = false; this.clearReconnect(); if (this.connected) await this.obs.disconnect(); this.connected = false; this.emit('log', 'info', 'Déconnecté d\'OBS', 'obs.disconnected'); } private async reconnect(): Promise { try { await this.obs.disconnect(); } catch { /* ignore */ } this.connected = false; await this.connect().catch((err: Error) => this.emit('log', 'warn', err.message)); } private scheduleReconnect(): void { if (!this.autoReconnect || this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; if (!this.autoReconnect || this.connected) return; this.connect().catch(() => { /* la prochaine fermeture reprogrammera un essai */ }); }, RECONNECT_DELAY_MS); this.reconnectTimer.unref?.(); } private clearReconnect(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } /** État d'enregistrement à la demande, sans passer par l'instantané complet. */ async recordState(): Promise<{ active: boolean; paused: boolean }> { if (!this.connected) return { active: false, paused: false }; const status = await this.obs.call('GetRecordStatus'); return { active: status.outputActive, paused: status.outputPaused }; } // --- Exécution des actions du protocole --------------------------------- async execute(action: ObsAction, params: Record = {}): Promise { switch (action) { case 'obs.connect': await this.connect(); return { connected: true }; case 'obs.disconnect': await this.disconnect(); return { connected: false }; case 'obs.refresh': this.requireConnection(); return this.snapshot(); case 'record.start': { this.requireConnection(); await this.obs.call('StartRecord'); return { recording: true }; } case 'record.stop': { this.requireConnection(); const result = await this.obs.call('StopRecord'); this.lastRecordingPath = result.outputPath; return { recording: false, outputPath: result.outputPath }; } case 'record.pause': this.requireConnection(); await this.obs.call('PauseRecord'); return { paused: true }; case 'record.resume': this.requireConnection(); await this.obs.call('ResumeRecord'); return { paused: false }; case 'record.split': this.requireConnection(); await this.obs.call('SplitRecordFile'); return { split: true }; case 'stream.start': this.requireConnection(); await this.obs.call('StartStream'); return { streaming: true }; case 'stream.stop': this.requireConnection(); await this.obs.call('StopStream'); return { streaming: false }; case 'scene.set': { this.requireConnection(); const sceneName = requireString(params.scene, 'scene'); await this.obs.call('SetCurrentProgramScene', { sceneName }); return { scene: sceneName }; } case 'profile.set': { this.requireConnection(); const profileName = requireString(params.profile, 'profile'); await this.obs.call('SetCurrentProfile', { profileName }); return { profile: profileName }; } case 'collection.set': { this.requireConnection(); const sceneCollectionName = requireString(params.collection, 'collection'); await this.obs.call('SetCurrentSceneCollection', { sceneCollectionName }); return { collection: sceneCollectionName }; } case 'recordDirectory.set': { this.requireConnection(); const recordDirectory = requireString(params.directory, 'directory'); await this.obs.call('SetRecordDirectory', { recordDirectory }); return { recordDirectory }; } case 'agent.ping': return { pong: Date.now() }; default: { const exhaustive: never = action; throw new Error(`Action non gérée : ${String(exhaustive)}`); } } } // --- Presets d'enregistrement -------------------------------------------- /** * Traduit un preset en réglages OBS : paramètres de profil pour l'encodeur et * la qualité, `SetVideoSettings` pour la définition et la fluidité. * * Chaque paramètre est écrit isolément et un refus n'interrompt pas la série : * les clés varient d'une version d'OBS à l'autre, et il vaut mieux appliquer * quatre réglages sur cinq en le disant que tout abandonner sur un détail. Le * compte rendu liste ce qui est passé et ce qui a été refusé. */ async applyRecordingPreset(settings: RecordingSettings): Promise { this.requireConnection(); const preset = findRecordingPreset(settings.presetId); if (!preset) throw new Error(`Preset d'enregistrement inconnu : ${settings.presetId}`); const encoder = RECORDING_ENCODERS[settings.encoder]; if (!encoder) throw new Error(`Encodeur inconnu : ${settings.encoder}`); // Changer d'encodeur ou de définition pendant une capture la corromprait. const state = await this.obs.call('GetRecordStatus'); if (state.outputActive) { throw new Error("Enregistrement en cours : arrête-le avant d'appliquer un preset."); } const applied: string[] = []; const skipped: string[] = []; const setParam = async (category: string, name: string, value: string): Promise => { try { await this.obs.call('SetProfileParameter', { parameterCategory: category, parameterName: name, parameterValue: value, }); applied.push(`${name}=${value}`); } catch (err) { skipped.push(`${name} (${err instanceof Error ? err.message : String(err)})`); } }; // L'encodeur réellement en service est celui chargé au lancement d'OBS. const loadedEncoder = await this.readParam('SimpleOutput', 'RecEncoder'); // Les clés ci-dessous n'ont d'effet qu'en mode de sortie simple. await setParam('Output', 'Mode', 'Simple'); await setParam('SimpleOutput', 'RecEncoder', encoder.obsValue); await setParam('SimpleOutput', 'RecQuality', preset.quality); await setParam('SimpleOutput', 'RecFormat2', preset.format); // `RecFormat` est la clé des OBS antérieurs à la 30 ; l'écrire ne coûte rien. await setParam('SimpleOutput', 'RecFormat', preset.format === 'mkv' ? 'mkv' : 'mp4'); await setParam('SimpleOutput', 'ABitrate', String(preset.audioBitrateKbps)); if (encoder.speedKey && encoder.speedFamily) { await setParam('SimpleOutput', encoder.speedKey, preset.speed[encoder.speedFamily]); } let video: string; try { video = await this.applyVideoSettings(preset); } catch (err) { const message = err instanceof Error ? err.message : String(err); skipped.push(`vidéo (${message})`); video = `non appliqué : ${message}`; } return { presetId: preset.id, presetLabel: preset.label, encoder: settings.encoder, applied, skipped, video, restartRequired: loadedEncoder !== null && loadedEncoder !== encoder.obsValue, }; } /** * Aligne la sortie vidéo sur le preset, sans jamais agrandir : au-delà de la * définition de la scène il n'y a rien à gagner, seulement des pixels inventés. */ private async applyVideoSettings(preset: RecordingPreset): Promise { const current = await this.obs.call('GetVideoSettings'); const { baseWidth, baseHeight } = current; let outputWidth = baseWidth; let outputHeight = baseHeight; if (preset.height !== null && preset.height < baseHeight) { outputHeight = even(preset.height); outputWidth = even((baseWidth * preset.height) / baseHeight); } await this.obs.call('SetVideoSettings', { baseWidth, baseHeight, outputWidth, outputHeight, fpsNumerator: preset.fps, fpsDenominator: 1, }); return `${outputWidth}×${outputHeight} @ ${preset.fps} fps`; } /** Valeur courante d'un paramètre de profil, sa valeur par défaut à défaut. */ private async readParam(category: string, name: string): Promise { try { const result = await this.obs.call('GetProfileParameter', { parameterCategory: category, parameterName: name, }); return result.parameterValue || result.defaultParameterValue || null; } catch { return null; } } /** Interroge OBS pour construire l'état courant ; ne lève jamais. */ async snapshot(): Promise { const base: ObsSnapshot = { obsConnected: this.connected, obsVersion: this.cachedVersion, obsError: this.connected ? undefined : this.lastError, recording: false, recordPaused: false, streaming: false, scenes: [], profiles: [], collections: [], }; if (!this.connected) return base; try { const [record, stream, sceneList, profileList, collectionList, stats, directory] = await Promise.all([ this.obs.call('GetRecordStatus'), this.obs.call('GetStreamStatus'), this.obs.call('GetSceneList'), this.obs.call('GetProfileList'), this.obs.call('GetSceneCollectionList'), this.obs.call('GetStats'), this.obs.call('GetRecordDirectory').catch(() => null), ]); return { ...base, recording: record.outputActive, recordPaused: record.outputPaused, recordTimecode: record.outputTimecode, recordBytes: record.outputBytes, recordDirectory: directory?.recordDirectory, streaming: stream.outputActive, streamTimecode: stream.outputTimecode, currentScene: sceneList.currentProgramSceneName, scenes: (sceneList.scenes as Array<{ sceneName?: string }>) .map((scene) => scene.sceneName) .filter((name): name is string => typeof name === 'string'), currentProfile: profileList.currentProfileName, profiles: profileList.profiles, currentCollection: collectionList.currentSceneCollectionName, collections: collectionList.sceneCollections, cpuUsage: stats.cpuUsage, fps: stats.activeFps, droppedFrames: stats.outputSkippedFrames, renderSkippedFrames: stats.renderSkippedFrames, }; } catch (err) { this.lastError = err instanceof Error ? err.message : String(err); return { ...base, obsError: this.lastError }; } } get recordingPath(): string | undefined { return this.lastRecordingPath; } private requireConnection(): void { if (!this.connected) throw new Error('OBS n\'est pas connecté sur cet agent'); } } /** H.264 exige des dimensions paires ; on arrondit au pair le plus proche. */ function even(value: number): number { return Math.max(2, Math.round(value / 2) * 2); } function requireString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { throw new Error(`Paramètre « ${field} » manquant`); } return value; }