- 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>
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
import { afterEach, describe, expect, test } from 'bun:test';
|
||
import { mkdtemp, readFile, rm, writeFile, mkdir, readdir } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import type { Server } from 'bun';
|
||
import { Obs, obsAuthentication } from '../obs';
|
||
import { ObsScene, windowSetting, type SceneTarget } from './obs-control';
|
||
import { encoderFromLog, parseIni, setIni, stringifyIni, writeObsSettings } from './obs-files';
|
||
|
||
interface Item {
|
||
sceneItemId: number;
|
||
sourceName: string;
|
||
sceneItemIndex: number;
|
||
sceneItemEnabled: boolean;
|
||
transform: Record<string, unknown>;
|
||
}
|
||
|
||
/** Just enough obs-websocket 5 for the requests ObsScene makes, with an in-memory scene. */
|
||
function fakeObs(password: string, options: { ignore?: string[] } = {}) {
|
||
const model = {
|
||
scenes: ['Scene'],
|
||
program: 'Scene',
|
||
inputs: new Map<string, { kind: string; settings: Record<string, unknown> }>([
|
||
['Desktop Audio', { kind: 'wasapi_output_capture', settings: {} }]
|
||
]),
|
||
items: new Map<string, Item[]>([['Scene', []]]),
|
||
muted: new Set<string>(),
|
||
service: {
|
||
streamServiceType: 'rtmp_common',
|
||
streamServiceSettings: {} as Record<string, unknown>
|
||
},
|
||
sources: new Map<string, { width: number; height: number }>(),
|
||
requests: [] as string[],
|
||
nextId: 1
|
||
};
|
||
const salt = 'salt';
|
||
const challenge = 'challenge';
|
||
const handle = (type: string, data: Record<string, unknown>): Record<string, unknown> | null => {
|
||
const sceneItems = (scene: string) => model.items.get(scene) ?? [];
|
||
switch (type) {
|
||
case 'GetSceneList':
|
||
return {
|
||
scenes: model.scenes.map((sceneName) => ({ sceneName })),
|
||
currentProgramSceneName: model.program
|
||
};
|
||
case 'CreateScene':
|
||
model.scenes.push(String(data.sceneName));
|
||
model.items.set(String(data.sceneName), []);
|
||
return {};
|
||
case 'SetCurrentProgramScene':
|
||
model.program = String(data.sceneName);
|
||
return {};
|
||
case 'GetInputList':
|
||
return { inputs: [...model.inputs.keys()].map((inputName) => ({ inputName })) };
|
||
case 'CreateInput': {
|
||
const name = String(data.inputName);
|
||
model.inputs.set(name, {
|
||
kind: String(data.inputKind),
|
||
settings: { ...(data.inputSettings as object) }
|
||
});
|
||
const items = sceneItems(String(data.sceneName));
|
||
items.push({
|
||
sceneItemId: model.nextId++,
|
||
sourceName: name,
|
||
sceneItemIndex: items.length,
|
||
sceneItemEnabled: data.sceneItemEnabled !== false,
|
||
transform: {}
|
||
});
|
||
return {};
|
||
}
|
||
case 'GetInputSettings': {
|
||
const input = model.inputs.get(String(data.inputName))!;
|
||
return { inputKind: input.kind, inputSettings: input.settings };
|
||
}
|
||
case 'SetInputSettings':
|
||
Object.assign(model.inputs.get(String(data.inputName))!.settings, data.inputSettings);
|
||
return {};
|
||
case 'GetSceneItemList':
|
||
return {
|
||
sceneItems: sceneItems(String(data.sceneName)).map((item) => ({
|
||
sceneItemId: item.sceneItemId,
|
||
sourceName: item.sourceName,
|
||
sceneItemIndex: item.sceneItemIndex,
|
||
sceneItemEnabled: item.sceneItemEnabled
|
||
}))
|
||
};
|
||
case 'CreateSceneItem': {
|
||
const items = sceneItems(String(data.sceneName));
|
||
items.push({
|
||
sceneItemId: model.nextId++,
|
||
sourceName: String(data.sourceName),
|
||
sceneItemIndex: items.length,
|
||
sceneItemEnabled: true,
|
||
transform: {}
|
||
});
|
||
return {};
|
||
}
|
||
case 'SetSceneItemIndex': {
|
||
const items = sceneItems(String(data.sceneName));
|
||
const item = items.find((each) => each.sceneItemId === data.sceneItemId)!;
|
||
items.splice(items.indexOf(item), 1);
|
||
items.splice(Number(data.sceneItemIndex), 0, item);
|
||
items.forEach((each, index) => (each.sceneItemIndex = index));
|
||
return {};
|
||
}
|
||
case 'SetSceneItemEnabled':
|
||
sceneItems(String(data.sceneName)).find(
|
||
(each) => each.sceneItemId === data.sceneItemId
|
||
)!.sceneItemEnabled = data.sceneItemEnabled === true;
|
||
return {};
|
||
case 'GetSceneItemTransform': {
|
||
const item = sceneItems(String(data.sceneName)).find(
|
||
(each) => each.sceneItemId === data.sceneItemId
|
||
)!;
|
||
const size = model.sources.get(item.sourceName) ?? { width: 0, height: 0 };
|
||
return {
|
||
sceneItemTransform: {
|
||
sourceWidth: size.width,
|
||
sourceHeight: size.height,
|
||
cropBottom: 0,
|
||
boundsType: 'OBS_BOUNDS_NONE',
|
||
boundsWidth: 0,
|
||
boundsHeight: 0,
|
||
...item.transform
|
||
}
|
||
};
|
||
}
|
||
case 'SetSceneItemTransform':
|
||
Object.assign(
|
||
sceneItems(String(data.sceneName)).find((each) => each.sceneItemId === data.sceneItemId)!
|
||
.transform,
|
||
data.sceneItemTransform
|
||
);
|
||
return {};
|
||
case 'GetSpecialInputs':
|
||
return { desktop1: 'Desktop Audio', desktop2: null, mic1: null };
|
||
case 'SetInputMute':
|
||
model.muted.add(String(data.inputName));
|
||
return {};
|
||
case 'GetStreamServiceSettings':
|
||
return model.service;
|
||
case 'SetStreamServiceSettings':
|
||
model.service = data as typeof model.service;
|
||
return {};
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
const server: Server<{ identified: boolean }> = Bun.serve<{ identified: boolean }>({
|
||
port: 0,
|
||
fetch(request, server) {
|
||
return server.upgrade(request, { data: { identified: false } })
|
||
? undefined
|
||
: new Response('no');
|
||
},
|
||
websocket: {
|
||
open(ws) {
|
||
ws.send(
|
||
JSON.stringify({ op: 0, d: { rpcVersion: 1, authentication: { salt, challenge } } })
|
||
);
|
||
},
|
||
message(ws, raw) {
|
||
const { op, d } = JSON.parse(String(raw));
|
||
if (op === 1) {
|
||
if (d.authentication !== obsAuthentication(password, salt, challenge)) {
|
||
ws.close(4009, 'auth');
|
||
return;
|
||
}
|
||
ws.data.identified = true;
|
||
ws.send(JSON.stringify({ op: 2, d: { negotiatedRpcVersion: 1 } }));
|
||
return;
|
||
}
|
||
if (op !== 6 || !ws.data.identified) return;
|
||
model.requests.push(d.requestType);
|
||
if (options.ignore?.includes(d.requestType)) return;
|
||
const result = handle(d.requestType, d.requestData ?? {});
|
||
ws.send(
|
||
JSON.stringify({
|
||
op: 7,
|
||
d: {
|
||
requestType: d.requestType,
|
||
requestId: d.requestId,
|
||
requestStatus: result
|
||
? { result: true, code: 100 }
|
||
: { result: false, code: 204, comment: 'unknown' },
|
||
responseData: result ?? undefined
|
||
}
|
||
})
|
||
);
|
||
}
|
||
}
|
||
});
|
||
return { server, model, url: `ws://127.0.0.1:${server.port}` };
|
||
}
|
||
|
||
const servers: Server<unknown>[] = [];
|
||
afterEach(() => {
|
||
for (const server of servers.splice(0)) server.stop(true);
|
||
});
|
||
|
||
const target = (overrides: Partial<SceneTarget> = {}): SceneTarget => ({
|
||
canvas: { width: 1920, height: 1080 },
|
||
fps: 60,
|
||
overlayUrl: 'https://esports.vash.software/broadcast/abc/overlay',
|
||
capture: { title: 'Vash osu!tourney (prod)', className: 'WindowsForms10', exe: 'osu!.exe' },
|
||
showGameplay: false,
|
||
ingest: { server: 'rtmp://127.0.0.1:47103/live', key: 'k' },
|
||
...overrides
|
||
});
|
||
|
||
describe('Obs client', () => {
|
||
test('reports a wrong websocket password clearly', async () => {
|
||
const fake = fakeObs('right');
|
||
servers.push(fake.server);
|
||
const obs = new Obs(fake.url, 'wrong', 2000);
|
||
await expect(obs.connect()).rejects.toThrow('OBS rejected the websocket password');
|
||
});
|
||
|
||
test('connected means authenticated, and a hung request times out', async () => {
|
||
const fake = fakeObs('pw', { ignore: ['GetStreamStatus'] });
|
||
servers.push(fake.server);
|
||
const obs = new Obs(fake.url, 'pw', 300);
|
||
expect(obs.connected).toBe(false);
|
||
await obs.connect();
|
||
expect(obs.connected).toBe(true);
|
||
await expect(obs.request('GetStreamStatus')).rejects.toThrow('OBS GetStreamStatus timed out');
|
||
await Bun.sleep(400);
|
||
obs.close();
|
||
expect(obs.connected).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('ObsScene', () => {
|
||
test('builds the scene once, keeps the overlay on top, and changes nothing on a second pass', async () => {
|
||
const fake = fakeObs('pw');
|
||
servers.push(fake.server);
|
||
const obs = new Obs(fake.url, 'pw');
|
||
await obs.connect();
|
||
const scene = new ObsScene(obs, () => {});
|
||
await scene.reconcile(target());
|
||
const items = fake.model.items.get('Vash')!;
|
||
expect(fake.model.program).toBe('Vash');
|
||
expect(items.map((item) => item.sourceName)).toEqual(['Gameplay', 'Overlay']);
|
||
expect(items[0].sceneItemEnabled).toBe(false);
|
||
expect(fake.model.inputs.get('Gameplay')!.settings).toMatchObject({
|
||
window: 'Vash osu!tourney (prod):WindowsForms10:osu!.exe',
|
||
priority: 1,
|
||
capture_audio: true
|
||
});
|
||
expect(fake.model.inputs.get('Overlay')!.settings).toMatchObject({
|
||
width: 1920,
|
||
height: 1080,
|
||
fps: 60
|
||
});
|
||
expect(fake.model.muted.has('Desktop Audio')).toBe(true);
|
||
expect(fake.model.service.streamServiceSettings).toMatchObject({
|
||
server: 'rtmp://127.0.0.1:47103/live',
|
||
key: 'k'
|
||
});
|
||
|
||
fake.model.requests.length = 0;
|
||
await scene.reconcile(target());
|
||
expect(fake.model.requests.filter((type) => /^(Create|Set|Remove)/.test(type))).toEqual([]);
|
||
|
||
// Someone reorders the scene: a changed target re-checks everything.
|
||
scene.reset();
|
||
const overlay = items.find((item) => item.sourceName === 'Overlay')!;
|
||
items.splice(items.indexOf(overlay), 1);
|
||
items.unshift(overlay);
|
||
items.forEach((item, index) => (item.sceneItemIndex = index));
|
||
await scene.reconcile(target());
|
||
expect(fake.model.items.get('Vash')!.at(-1)!.sourceName).toBe('Overlay');
|
||
obs.close();
|
||
});
|
||
|
||
test('crops the control panel off the capture and shows it only while spectating', async () => {
|
||
const fake = fakeObs('pw');
|
||
servers.push(fake.server);
|
||
const obs = new Obs(fake.url, 'pw');
|
||
await obs.connect();
|
||
const scene = new ObsScene(obs, () => {});
|
||
await scene.reconcile(target());
|
||
// At 125 % display scaling the 1280×920 window captures as 1600×1150.
|
||
fake.model.sources.set('Gameplay', { width: 1600, height: 1150 });
|
||
await scene.reconcile(target({ showGameplay: true }));
|
||
const gameplay = fake.model.items.get('Vash')!.find((item) => item.sourceName === 'Gameplay')!;
|
||
expect(gameplay.sceneItemEnabled).toBe(true);
|
||
expect(gameplay.transform).toMatchObject({
|
||
cropBottom: 250,
|
||
boundsType: 'OBS_BOUNDS_SCALE_INNER',
|
||
boundsWidth: 1920,
|
||
boundsHeight: 1080
|
||
});
|
||
await scene.reconcile(target({ showGameplay: false }));
|
||
expect(gameplay.sceneItemEnabled).toBe(false);
|
||
obs.close();
|
||
});
|
||
});
|
||
|
||
describe('OBS files', () => {
|
||
let dir: string;
|
||
afterEach(async () => {
|
||
if (dir) await rm(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
test('escapes window settings like libobs', () => {
|
||
expect(windowSetting({ title: 'a:b#c', className: 'X', exe: 'osu!.exe' })).toBe(
|
||
'a#3Ab#22c:X:osu!.exe'
|
||
);
|
||
});
|
||
|
||
test('ini round trip keeps unknown keys', () => {
|
||
const ini = parseIni('\uFEFF[General]\r\nName=Old\r\nKeep=1\r\n[Video]\r\nBaseCX=1280\r\n');
|
||
setIni(ini, { General: { Name: 'Vash' }, Audio: { SampleRate: 48000 } });
|
||
expect(stringifyIni(ini)).toBe(
|
||
'[General]\r\nName=Vash\r\nKeep=1\r\n\r\n[Video]\r\nBaseCX=1280\r\n\r\n[Audio]\r\nSampleRate=48000\r\n'
|
||
);
|
||
});
|
||
|
||
test('writes profile, websocket and first-run settings, and clears the crash sentinel', async () => {
|
||
dir = await mkdtemp(join(tmpdir(), 'vash-obs-'));
|
||
await mkdir(join(dir, '.sentinel'), { recursive: true });
|
||
await writeFile(join(dir, '.sentinel', 'run_123'), '');
|
||
await mkdir(join(dir, 'basic', 'profiles', 'Vash'), { recursive: true });
|
||
await writeFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), '[Hotkeys]\r\nX=1\r\n');
|
||
await writeObsSettings(dir, {
|
||
websocketPort: 47101,
|
||
websocketPassword: 'pw',
|
||
width: 1920,
|
||
height: 1080,
|
||
fps: 60,
|
||
bitrateKbps: 6000,
|
||
encoder: 'amd',
|
||
ingestUrl: 'rtmp://127.0.0.1:47103/live',
|
||
ingestKey: 'key'
|
||
});
|
||
const profile = parseIni(
|
||
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), 'utf8')
|
||
);
|
||
expect(profile.get('SimpleOutput')?.get('StreamEncoder')).toBe('amd');
|
||
expect(profile.get('Video')?.get('FPSInt')).toBe('60');
|
||
expect(profile.get('Hotkeys')?.get('X')).toBe('1');
|
||
const user = parseIni(await readFile(join(dir, 'user.ini'), 'utf8'));
|
||
expect(user.get('General')?.get('FirstRun')).toBe('true');
|
||
expect(user.get('Basic')?.get('Profile')).toBe('Vash');
|
||
const websocket = JSON.parse(
|
||
await readFile(join(dir, 'plugin_config', 'obs-websocket', 'config.json'), 'utf8')
|
||
);
|
||
expect(websocket).toMatchObject({
|
||
server_enabled: true,
|
||
server_port: 47101,
|
||
auth_required: true,
|
||
server_password: 'pw',
|
||
first_load: false
|
||
});
|
||
const service = JSON.parse(
|
||
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'service.json'), 'utf8')
|
||
);
|
||
expect(service).toEqual({
|
||
type: 'rtmp_custom',
|
||
settings: {
|
||
server: 'rtmp://127.0.0.1:47103/live',
|
||
key: 'key',
|
||
use_auth: false,
|
||
bwtest: false
|
||
}
|
||
});
|
||
expect(await readdir(join(dir, '.sentinel'))).toEqual([]);
|
||
});
|
||
|
||
test('reads which encoder OBS started last from its log', () => {
|
||
expect(
|
||
encoderFromLog("12:00:00.000: [x264 encoder: 'simple_video_stream'] preset: veryfast")
|
||
).toBe('x264');
|
||
expect(
|
||
encoderFromLog(
|
||
"[x264 encoder: 'simple_video_stream'] preset: veryfast\n[texture-amf-h264: 'simple_video_stream'] settings:"
|
||
)
|
||
).toBe('amd');
|
||
expect(encoderFromLog('no encoders here')).toBeNull();
|
||
});
|
||
});
|