lilith-platform.live/codebase/@features/broadcast/controller/obs-client.ts

378 lines
10 KiB
TypeScript

/**
* ObsClient — pure Bun WebSocket v5 client for obs-websocket (no external deps).
*
* Production complete: full hello/identify/auth dance, request/response correlation
* with timeouts, event subscription, typed high-level helpers for the actions we need.
*
* Reconnects automatically on close. All public methods throw on failure with clear messages.
*/
import type { ObsScene } from "../shared/types";
interface ObsHelloAuth {
challenge: string;
salt: string;
}
interface ObsHelloData {
obsWebSocketVersion: string;
rpcVersion: number;
authentication?: ObsHelloAuth;
}
interface ObsRequest {
requestType: string;
requestData?: Record<string, unknown>;
}
interface ObsRequestStatus {
result: boolean;
code: number;
comment?: string;
}
interface ObsResponse {
requestType: string;
requestStatus: ObsRequestStatus;
responseData?: Record<string, unknown>;
}
interface ObsEvent {
eventType: string;
eventIntent: number;
eventData?: Record<string, unknown>;
}
type PendingRequest = {
resolve: (value: Record<string, unknown>) => void;
reject: (reason: Error) => void;
timer: Timer;
};
export class ObsError extends Error {
constructor(
message: string,
public readonly code?: number,
public readonly requestType?: string,
) {
super(message);
this.name = "ObsError";
}
}
export class ObsClient {
private ws: WebSocket | null = null;
private readonly rpcVersion = 1;
private readonly eventListeners = new Map<string, Array<(data: Record<string, unknown>) => void>>();
private readonly requestMap = new Map<string, PendingRequest>();
private connected = false;
private authenticated = false;
private reconnectTimer: Timer | null = null;
private connecting = false;
constructor(
private readonly url: string,
private readonly password: string,
) {}
get isConnected(): boolean {
return this.connected && this.authenticated;
}
async connect(): Promise<void> {
if (this.connecting) {
throw new ObsError("Connection already in progress");
}
if (this.isConnected) return;
this.connecting = true;
return new Promise<void>((resolve, reject) => {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log("[obs] WS connected, awaiting Hello");
};
this.ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data));
this.handleMessage(msg, resolve, reject);
} catch (e) {
console.error("[obs] failed to parse message", e);
}
};
this.ws.onclose = () => {
console.log("[obs] WS closed");
this.connected = false;
this.authenticated = false;
this.ws = null;
this.clearPendingRequests(new ObsError("OBS WebSocket closed"));
this.scheduleReconnect();
};
this.ws.onerror = (ev) => {
const err = new ObsError("OBS WebSocket error");
console.error("[obs] WS error", ev);
if (this.connecting) {
this.connecting = false;
reject(err);
}
};
// overall connect timeout
setTimeout(() => {
if (this.connecting) {
this.connecting = false;
const t = new ObsError("OBS WS connect timeout");
reject(t);
}
}, 15000);
});
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.isConnected) {
console.log("[obs] attempting reconnect...");
this.connect().catch((e) => {
console.warn("[obs] reconnect failed:", e instanceof Error ? e.message : e);
});
}
}, 3000);
}
private clearPendingRequests(reason: Error): void {
for (const [id, entry] of this.requestMap) {
clearTimeout(entry.timer as any);
entry.reject(reason);
this.requestMap.delete(id);
}
}
private handleMessage(
msg: any,
connectResolve?: (v: void) => void,
connectReject?: (e: Error) => void,
): void {
const { op, d } = msg;
if (op === 0) {
// Hello
console.log("[obs] Hello received, rpcVersion", d?.rpcVersion);
const identify: any = {
op: 1,
d: {
rpcVersion: this.rpcVersion,
},
};
if (d?.authentication) {
const { challenge, salt } = d.authentication as ObsHelloAuth;
const secret = Bun.SHA256(this.password + salt).toString("base64");
const auth = Bun.SHA256(secret + challenge).toString("base64");
identify.d.authentication = auth;
}
this.ws?.send(JSON.stringify(identify));
return;
}
if (op === 2) {
// Identified
this.connected = true;
this.authenticated = true;
this.connecting = false;
console.log("[obs] Identified successfully");
if (connectResolve) connectResolve();
return;
}
if (op === 7) {
// RequestResponse
const rid: string = d?.requestId;
const entry = this.requestMap.get(rid);
if (entry) {
this.requestMap.delete(rid);
clearTimeout(entry.timer as any);
const status: ObsRequestStatus = d?.requestStatus;
if (status?.result) {
entry.resolve((d?.responseData as Record<string, unknown>) ?? {});
} else {
entry.reject(
new ObsError(
status?.comment || `OBS error code ${status?.code}`,
status?.code,
),
);
}
}
return;
}
if (op === 5) {
// Event
const ev: ObsEvent = d;
const listeners = this.eventListeners.get(ev.eventType) ?? [];
for (const l of listeners) {
try {
l(ev.eventData ?? {});
} catch (e) {
console.error("[obs] event handler error", e);
}
}
const all = this.eventListeners.get("*") ?? [];
for (const l of all) {
try {
l({ type: ev.eventType, data: ev.eventData ?? {} });
} catch (e) {
console.error("[obs] wildcard event handler error", e);
}
}
}
}
async sendRequest(req: ObsRequest): Promise<Record<string, unknown>> {
if (!this.ws || !this.authenticated) {
throw new ObsError("OBS not connected or not authenticated");
}
const requestId = crypto.randomUUID();
const payload = {
op: 6,
d: {
requestType: req.requestType,
requestId,
requestData: req.requestData ?? {},
},
};
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (this.requestMap.has(requestId)) {
this.requestMap.delete(requestId);
reject(new ObsError(`OBS request ${req.requestType} timed out`, undefined, req.requestType));
}
}, 15000);
this.requestMap.set(requestId, { resolve, reject, timer });
try {
this.ws!.send(JSON.stringify(payload));
} catch (e) {
this.requestMap.delete(requestId);
clearTimeout(timer as any);
reject(new ObsError(`Failed to send OBS request: ${(e as Error).message}`));
}
});
}
onEvent(eventType: string, handler: (data: Record<string, unknown>) => void): void {
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, []);
}
this.eventListeners.get(eventType)!.push(handler);
}
removeEventListener(eventType: string, handler: (data: Record<string, unknown>) => void): void {
const list = this.eventListeners.get(eventType);
if (!list) return;
const idx = list.indexOf(handler);
if (idx >= 0) list.splice(idx, 1);
}
// ---------------- high level typed helpers ----------------
async getScenes(): Promise<ObsScene[]> {
const res = await this.sendRequest({ requestType: "GetSceneList" });
const scenes = (res.scenes as any[] | undefined) ?? [];
return scenes.map((s: any, idx: number) => ({
sceneName: String(s.sceneName ?? s.name ?? `Scene${idx}`),
sceneIndex: typeof s.sceneIndex === "number" ? s.sceneIndex : idx,
}));
}
async getCurrentProgramScene(): Promise<string | null> {
try {
const res = await this.sendRequest({ requestType: "GetCurrentProgramScene" });
return (res.currentProgramSceneName as string) ?? null;
} catch {
return null;
}
}
async setCurrentScene(sceneName: string): Promise<void> {
await this.sendRequest({
requestType: "SetCurrentProgramScene",
requestData: { sceneName },
});
}
async getStreamStatus(): Promise<Record<string, unknown>> {
return this.sendRequest({ requestType: "GetStreamStatus" });
}
async startStream(): Promise<void> {
await this.sendRequest({ requestType: "StartStream" });
}
async stopStream(): Promise<void> {
await this.sendRequest({ requestType: "StopStream" });
}
async getInputList(): Promise<Record<string, unknown>> {
return this.sendRequest({ requestType: "GetInputList" });
}
/**
* Set or create a text source. Uses "text_ft2_source_v2" by default (cross platform).
* Falls back gracefully if the named input already exists but is wrong kind.
*/
async setTextSource(inputName: string, text: string, sceneName?: string): Promise<void> {
const settings = { text, font: { face: "Arial", size: 48 } };
try {
await this.sendRequest({
requestType: "SetInputSettings",
requestData: {
inputName,
inputSettings: settings,
},
});
return;
} catch {
// try create
}
const targetScene = sceneName || "Hotel Cam";
try {
await this.sendRequest({
requestType: "CreateInput",
requestData: {
inputName,
inputKind: "text_ft2_source_v2",
inputSettings: settings,
sceneName: targetScene,
},
});
} catch (e) {
// last resort: try gdiplus variant (windows)
await this.sendRequest({
requestType: "CreateInput",
requestData: {
inputName,
inputKind: "text_gdiplus_v2",
inputSettings: settings,
sceneName: targetScene,
},
});
}
}
async getStats(): Promise<Record<string, unknown>> {
return this.sendRequest({ requestType: "GetStats" });
}
}