Files
esports/streamer/agent.ts
Stan Runge 54444a77f0 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>
2026-10-18 22:02:25 +00:00

202 lines
7.7 KiB
TypeScript

#!/usr/bin/env bun
/**
* vash-streamer: runs next to OBS and the osu! tourney client on the
* streaming PC, and connects them to a Vash broadcast channel.
*
* tosu ──live scores──▶ agent ──▶ Vash (/ws/agent) ──▶ overlays, match page
* Vash ──scene, lobby──▶ agent ──▶ OBS scenes, tourney client (#mp_…)
*
* Every part is optional except Vash itself: without tosu there are no live
* scores from real lobbies, without OBS nothing switches scenes.
*
* vash-streamer --server https://esports.vash.software --channel <id> --token <token>
* vash-streamer ... --obs-password <password> --setup-obs create a ready OBS scene
*
* Build single binaries with `bun run streamer:build`.
*/
import { parseArgs } from 'node:util';
import { VashLink, type Follow } from './link';
import { Obs } from './obs';
import { TosuFeed } from './tosu';
import { Tourney } from './tourney';
const { values: args } = parseArgs({
options: {
server: { type: 'string', default: process.env.VASH_SERVER ?? 'https://esports.vash.software' },
channel: { type: 'string', default: process.env.VASH_CHANNEL },
token: { type: 'string', default: process.env.VASH_TOKEN },
tosu: { type: 'string', default: process.env.TOSU_URL ?? 'ws://127.0.0.1:24050/websocket/v2' },
obs: { type: 'string', default: process.env.OBS_URL ?? 'ws://127.0.0.1:4455' },
'obs-password': { type: 'string', default: process.env.OBS_PASSWORD ?? '' },
/** JSON: Vash scene → OBS scene name, e.g. {"gameplay":"Gameplay","idle":"Starting Soon"} */
'obs-scenes': { type: 'string', default: process.env.OBS_SCENES ?? '' },
'setup-obs': { type: 'boolean', default: false },
'auto-join': { type: 'boolean', default: process.env.AUTO_JOIN === '1' },
'osu-exe': { type: 'string', default: process.env.OSU_EXE ?? '' },
help: { type: 'boolean', short: 'h', default: false }
}
});
if (args.help || !args.channel || !args.token) {
console.log(`vash-streamer: connects tosu and OBS to a Vash broadcast channel.
--server URL Vash site (default https://esports.vash.software)
--channel ID broadcast channel id (or VASH_CHANNEL)
--token TOKEN the channel's agent token (or VASH_TOKEN)
--tosu URL tosu websocket (default ws://127.0.0.1:24050/websocket/v2)
--obs URL obs-websocket (default ws://127.0.0.1:4455)
--obs-password PW obs-websocket password
--obs-scenes JSON switch OBS scenes with the match, e.g. '{"gameplay":"Gameplay","results":"Results"}'
--setup-obs create a "Vash" scene with the overlay and a tourney client capture, then exit
--auto-join select the match in osu!tourney's control panel (Wine/X11)
--osu-exe PATH launch osu!tourney from this executable under Wine`);
process.exit(args.help ? 0 : 1);
}
const server = args.server!.replace(/\/$/, '');
const channel = args.channel!;
const overlayUrl = `${server}/broadcast/${channel}/overlay`;
const say = (...parts: unknown[]) => console.log(new Date().toISOString().slice(11, 19), ...parts);
// ── OBS ─────────────────────────────────────────────────────────────────
const obs = new Obs(args.obs!, args['obs-password']!);
const sceneMap: Record<string, string> = args['obs-scenes'] ? JSON.parse(args['obs-scenes']) : {};
if (args['setup-obs']) {
await obs.connect();
const { scenes } = await obs.request<{ scenes: { sceneName: string }[] }>('GetSceneList');
if (!scenes.some((scene) => scene.sceneName === 'Vash'))
await obs.request('CreateScene', { sceneName: 'Vash' });
// Inputs added later sit on top: the capture first, then the see-through overlay over it.
const kind =
process.platform === 'win32'
? 'window_capture'
: process.platform === 'darwin'
? 'screen_capture'
: 'xcomposite_input';
await obs
.request('CreateInput', {
sceneName: 'Vash',
inputName: 'osu! tourney client',
inputKind: kind,
inputSettings: {}
})
.catch((err) => say('capture:', err.message));
await obs
.request('CreateInput', {
sceneName: 'Vash',
inputName: 'Vash overlay',
inputKind: 'browser_source',
inputSettings: {
url: overlayUrl,
width: 1920,
height: 1080,
fps_custom: true,
fps: 60,
reroute_audio: true
}
})
.catch((err) => say('overlay:', err.message));
await obs.request('SetCurrentProgramScene', { sceneName: 'Vash' });
say('OBS is set up: scene "Vash" with the overlay on top of "osu! tourney client".');
say('Open that capture source and pick the tourney client window.');
obs.close();
process.exit(0);
}
async function connectObs() {
if (!Object.keys(sceneMap).length || obs.connected) return;
try {
await obs.connect();
say('OBS connected');
} catch (err) {
say((err as Error).message);
}
}
// ── Vash ────────────────────────────────────────────────────────────────
let current: { scene: string; matchId: string | null; lobbyId: number | null } = {
scene: 'idle',
matchId: null,
lobbyId: null
};
const tourney = args['osu-exe']
? new Tourney(args['osu-exe'], {
display: process.env.GAMEPLAY_DISPLAY ?? ':98',
width: Number(process.env.GAMEPLAY_WIDTH ?? 1280),
height: Number(process.env.GAMEPLAY_HEIGHT ?? 720)
})
: null;
let roomName: string | null = null;
let teamSize = 1;
let selectedLobby: number | null = null;
let lastJoinNotice = 0;
const link = new VashLink({
server,
channel,
token: args.token!,
log: say,
onFollow: (next) => void follow(next),
onRejected: () => process.exit(1)
});
async function follow(next: Follow) {
const changedMatch = next.matchId !== current.matchId;
const changedLobby = next.lobbyId != null && next.lobbyId !== current.lobbyId;
if (changedMatch)
say(
next.title
? `Now showing ${next.title}${next.lobbyId ? ` (mp/${next.lobbyId})` : ''}`
: 'Nothing to show; idle'
);
if (next.scene !== current.scene) say(`Scene: ${next.scene}`);
const sceneName = sceneMap[next.scene];
if (sceneName && next.scene !== current.scene) {
await connectObs();
await obs.request('SetCurrentProgramScene', { sceneName }).catch((err) => say(err.message));
}
if (changedLobby) selectedLobby = null;
roomName = next.roomName ?? null;
teamSize = next.teamSize ?? 1;
if (changedLobby && args['auto-join'] && !tourney)
say('Automatic room selection needs --osu-exe on a Wine/X11 host.');
current = { scene: next.scene, matchId: next.matchId, lobbyId: next.lobbyId };
}
if (tourney && args['auto-join']) {
let selecting = false;
setInterval(async () => {
if (selecting || !current.lobbyId || !roomName) return;
selecting = true;
try {
if (await tourney.ensure(teamSize)) selectedLobby = null;
if (selectedLobby === current.lobbyId) return;
if (await tourney.select(roomName)) {
selectedLobby = current.lobbyId;
say(`osu!tourney is spectating ${roomName} (mp/${selectedLobby})`);
} else if (Date.now() - lastJoinNotice > 30_000) {
lastJoinNotice = Date.now();
say(`Waiting for ${roomName} in osu!tourney's room list`);
}
} catch (error) {
if (Date.now() - lastJoinNotice > 30_000) {
lastJoinNotice = Date.now();
say(`osu!tourney: ${(error as Error).message}`);
}
} finally {
selecting = false;
}
}, 5000);
}
// ── tosu ────────────────────────────────────────────────────────────────
const tosu = new TosuFeed(args.tosu!, (frame) => link.sendScores(frame), say);
say(`Overlay: ${overlayUrl}`);
link.start();
tosu.start();