// obs-websocket v5. Eigene Umsetzung statt npm-Paket - das Protokoll ist klein // und das Projekt kommt ohne Abhaengigkeiten aus (ADR-0001, Nachtrag 2). // // Ablauf: Server schickt Hello (op 0), Client antwortet mit Identify (op 1), // Server bestaetigt mit Identified (op 2). Danach Requests (op 6) / Responses // (op 7) und Events (op 5). import crypto from 'node:crypto'; import { logger } from '#core/logger.ts'; import { forever } from '#core/retry.ts'; const log = logger('obs'); /** * Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs, * dazu Inputs fuer die Stummschalter auf dem Pult. */ export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, inputs: 8, outputs: 64 } as const; const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes | SUBSCRIPTIONS.inputs | SUBSCRIPTIONS.outputs; /** * Antwort auf die Authentifizierungsaufforderung. * base64(sha256( base64(sha256(passwort + salt)) + challenge )) */ export function authString(password: string, salt: string, challenge: string): string { const secret = crypto.createHash('sha256').update(password + salt).digest('base64'); return crypto.createHash('sha256').update(secret + challenge).digest('base64'); } type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }; export type ObsEventHandlers = { onReplaySaved?: (path: string) => void; onSceneChanged?: (scene: string) => void; onIdentified?: () => void; onClosed?: () => void; /** Jedes Ereignis, auch die oben schon einzeln gemeldeten. */ onEvent?: (type: string, data: Record) => void; }; export type ObsClient = { run: (signal: AbortSignal) => Promise; request: (type: string, data?: unknown) => Promise; isConnected: () => boolean; }; export function createObs( url: string, password: () => string | undefined, handlers: ObsEventHandlers ): ObsClient { let ws: WebSocket | null = null; let identified = false; let seq = 0; const pending = new Map(); function send(op: number, d: unknown): void { if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error('OBS ist nicht verbunden'); ws.send(JSON.stringify({ op, d })); } function request(type: string, data?: unknown): Promise { if (!identified) return Promise.reject(new Error('OBS ist nicht verbunden')); const requestId = `r${++seq}`; return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`OBS antwortet nicht auf ${type}`)); }, 10_000); pending.set(requestId, { resolve: resolve as (v: unknown) => void, reject, timer }); try { send(6, { requestType: type, requestId, ...(data ? { requestData: data } : {}) }); } catch (err) { clearTimeout(timer); pending.delete(requestId); reject(err as Error); } }); } function handleMessage(raw: string, fail: (why: string) => void): void { let msg: { op: number; d: Record }; try { msg = JSON.parse(raw); } catch { return; } switch (msg.op) { case 0: { // Hello const auth = msg.d['authentication'] as { challenge: string; salt: string } | undefined; const identify: Record = { rpcVersion: 1, eventSubscriptions: DEFAULT_SUBS, }; if (auth) { const pw = password(); if (!pw) return fail('OBS verlangt ein Passwort, es ist keines hinterlegt (/setup)'); identify['authentication'] = authString(pw, auth.salt, auth.challenge); } send(1, identify); break; } case 2: { // Identified identified = true; log.info(`Mit OBS verbunden (RPC ${String(msg.d['negotiatedRpcVersion'])})`); handlers.onIdentified?.(); break; } case 5: { // Event const type = msg.d['eventType'] as string; const data = (msg.d['eventData'] ?? {}) as Record; if (type === 'ReplayBufferSaved') { handlers.onReplaySaved?.(String(data['savedReplayPath'] ?? '')); } else if (type === 'CurrentProgramSceneChanged') { handlers.onSceneChanged?.(String(data['sceneName'] ?? '')); } handlers.onEvent?.(type, data); break; } case 7: { // RequestResponse const id = msg.d['requestId'] as string; const entry = pending.get(id); if (!entry) break; pending.delete(id); clearTimeout(entry.timer); const status = msg.d['requestStatus'] as { result: boolean; code: number; comment?: string }; if (status?.result) entry.resolve(msg.d['responseData'] ?? {}); else entry.reject(new Error( `OBS lehnt ab (${status?.code}): ${status?.comment ?? 'ohne Begruendung'}`)); break; } default: break; } } function connectOnce(signal: AbortSignal): Promise { return new Promise((_resolve, reject) => { const socket = new WebSocket(url); ws = socket; identified = false; const fail = (why: string) => { identified = false; for (const [, p] of pending) { clearTimeout(p.timer); p.reject(new Error(why)); } pending.clear(); try { socket.close(); } catch { /* schon zu */ } handlers.onClosed?.(); reject(new Error(why)); }; signal.addEventListener('abort', () => fail('abgebrochen'), { once: true }); socket.addEventListener('message', (ev) => handleMessage(String(ev.data), fail)); socket.addEventListener('error', () => fail('WebSocket-Fehler')); socket.addEventListener('close', (ev) => fail(`Verbindung geschlossen (${ev.code})`)); }); } return { run: (signal) => forever(connectOnce, { signal, baseMs: 2000, maxMs: 60_000, onWait: (ms, n) => { // Erst ab dem dritten Versuch laut werden: OBS laeuft oft einfach // noch nicht, wenn das Toolkit startet. const line = `OBS nicht erreichbar, neuer Versuch in ${Math.round(ms / 1000)} s`; if (n >= 3) log.warn(line); else log.debug(line); }, }), request, isConnected: () => identified, }; }