feat: add native Windows broadcaster and live score modules
- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor) with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room selection and OBS scene reconciliation; built and tested on Linux only - streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging - worker status validation and live score helpers for the site - STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
139
streamer/obs.ts
139
streamer/obs.ts
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Just enough of obs-websocket 5 (built into OBS 28+) to switch scenes and
|
||||
* set up sources: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
||||
* Just enough of obs-websocket 5 (built into OBS 28+) to set up and drive a
|
||||
* broadcast: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
||||
*
|
||||
* `connected` means identified (authenticated), not merely an open socket.
|
||||
* Every connect and request has a timeout, so a hung OBS cannot stall a worker.
|
||||
*/
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
@@ -11,35 +14,94 @@ export function obsAuthentication(password: string, salt: string, challenge: str
|
||||
return sha256(sha256(password + salt) + challenge);
|
||||
}
|
||||
|
||||
/** obs-websocket close codes worth explaining. */
|
||||
const CLOSE_REASONS: Record<number, string> = {
|
||||
4009: 'OBS rejected the websocket password',
|
||||
4010: 'OBS does not support obs-websocket RPC version 1'
|
||||
};
|
||||
|
||||
export class ObsRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly requestType: string,
|
||||
readonly code: number | null
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface Waiter {
|
||||
resolve(data: unknown): void;
|
||||
reject(err: Error): void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class Obs {
|
||||
private socket: WebSocket | null = null;
|
||||
private pending = new Map<string, { resolve(data: unknown): void; reject(err: Error): void }>();
|
||||
private pending = new Map<string, Waiter>();
|
||||
private identified = false;
|
||||
/** Called once when an identified connection closes. */
|
||||
onClose: ((reason: string) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private password: string
|
||||
private password: string,
|
||||
private timeoutMs = 15_000
|
||||
) {}
|
||||
|
||||
get connected() {
|
||||
return this.socket?.readyState === WebSocket.OPEN;
|
||||
return this.identified && this.socket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
this.close();
|
||||
// Each connection owns its requests, so a late close of an old socket cannot fail new ones.
|
||||
const pending = new Map<string, Waiter>();
|
||||
this.pending = pending;
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const fail = (message: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
reject(new Error(message));
|
||||
};
|
||||
const timer = setTimeout(
|
||||
() => fail(`OBS at ${this.url} did not finish the websocket handshake in time`),
|
||||
this.timeoutMs
|
||||
);
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
socket.onerror = () =>
|
||||
reject(
|
||||
new Error(
|
||||
`Could not reach OBS at ${this.url}. Is Tools → WebSocket Server Settings enabled?`
|
||||
)
|
||||
);
|
||||
socket.onclose = () => {
|
||||
for (const waiter of this.pending.values()) waiter.reject(new Error('OBS disconnected'));
|
||||
this.pending.clear();
|
||||
fail(`Could not reach OBS at ${this.url}. Is its WebSocket server enabled?`);
|
||||
socket.onclose = (event) => {
|
||||
const wasIdentified = this.identified && this.socket === socket;
|
||||
if (this.socket === socket) {
|
||||
this.identified = false;
|
||||
this.socket = null;
|
||||
}
|
||||
const reason = CLOSE_REASONS[event.code] ?? `OBS closed the websocket (${event.code})`;
|
||||
for (const waiter of pending.values()) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error(reason));
|
||||
}
|
||||
pending.clear();
|
||||
fail(reason);
|
||||
if (wasIdentified) this.onClose?.(reason);
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
const { op, d } = JSON.parse(String(event.data));
|
||||
let message: { op: number; d: Record<string, unknown> };
|
||||
try {
|
||||
message = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { op, d } = message;
|
||||
if (op === 0) {
|
||||
const auth = d.authentication as { salt: string; challenge: string } | undefined;
|
||||
socket.send(
|
||||
@@ -54,15 +116,27 @@ export class Obs {
|
||||
}
|
||||
})
|
||||
);
|
||||
} else if (op === 2) resolve();
|
||||
else if (op === 7) {
|
||||
const waiter = this.pending.get(d.requestId);
|
||||
this.pending.delete(d.requestId);
|
||||
if (d.requestStatus?.result) waiter?.resolve(d.responseData);
|
||||
} else if (op === 2) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
this.identified = true;
|
||||
resolve();
|
||||
} else if (op === 7) {
|
||||
const requestId = d.requestId as string;
|
||||
const waiter = pending.get(requestId);
|
||||
if (!waiter) return;
|
||||
pending.delete(requestId);
|
||||
clearTimeout(waiter.timer);
|
||||
const status = d.requestStatus as
|
||||
{ result?: boolean; code?: number; comment?: string } | undefined;
|
||||
if (status?.result) waiter.resolve(d.responseData ?? {});
|
||||
else
|
||||
waiter?.reject(
|
||||
new Error(
|
||||
`OBS ${d.requestType}: ${d.requestStatus?.comment ?? d.requestStatus?.code}`
|
||||
waiter.reject(
|
||||
new ObsRequestError(
|
||||
`OBS ${d.requestType}: ${status?.comment ?? status?.code ?? 'failed'}`,
|
||||
String(d.requestType),
|
||||
status?.code ?? null
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -70,18 +144,29 @@ export class Obs {
|
||||
});
|
||||
}
|
||||
|
||||
request<T = unknown>(requestType: string, requestData: Record<string, unknown> = {}): Promise<T> {
|
||||
request<T = unknown>(
|
||||
requestType: string,
|
||||
requestData: Record<string, unknown> = {},
|
||||
timeoutMs = this.timeoutMs
|
||||
): Promise<T> {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN)
|
||||
return Promise.reject(new Error('OBS is not connected'));
|
||||
if (!socket || !this.connected) return Promise.reject(new Error('OBS is not connected'));
|
||||
const requestId = randomUUID();
|
||||
const pending = this.pending;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(requestId, { resolve: resolve as (data: unknown) => void, reject });
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(requestId);
|
||||
reject(new Error(`OBS ${requestType} timed out`));
|
||||
}, timeoutMs);
|
||||
pending.set(requestId, { resolve: resolve as (data: unknown) => void, reject, timer });
|
||||
socket.send(JSON.stringify({ op: 6, d: { requestType, requestId, requestData } }));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket?.close();
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.identified = false;
|
||||
socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user