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:
@@ -15,8 +15,9 @@
|
||||
* Build single binaries with `bun run streamer:build`.
|
||||
*/
|
||||
import { parseArgs } from 'node:util';
|
||||
import { VashLink, type Follow } from './link';
|
||||
import { Obs } from './obs';
|
||||
import { parseTosu, type TosuFrame } from './tosu';
|
||||
import { TosuFeed } from './tosu';
|
||||
import { Tourney } from './tourney';
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
@@ -116,8 +117,6 @@ async function connectObs() {
|
||||
|
||||
// ── Vash ────────────────────────────────────────────────────────────────
|
||||
|
||||
let vash: WebSocket | null = null;
|
||||
let vashAttempts = 0;
|
||||
let current: { scene: string; matchId: string | null; lobbyId: number | null } = {
|
||||
scene: 'idle',
|
||||
matchId: null,
|
||||
@@ -135,38 +134,16 @@ let teamSize = 1;
|
||||
let selectedLobby: number | null = null;
|
||||
let lastJoinNotice = 0;
|
||||
|
||||
function connectVash() {
|
||||
const url = `${server.replace(/^http/, 'ws')}/ws/agent`;
|
||||
const socket = new WebSocket(url);
|
||||
vash = socket;
|
||||
socket.onopen = () =>
|
||||
socket.send(JSON.stringify({ type: 'hello', channelId: channel, token: args.token }));
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.type === 'welcome') {
|
||||
vashAttempts = 0;
|
||||
say(`Connected to ${server} as channel "${message.channel}". Overlay: ${overlayUrl}`);
|
||||
} else if (message.type === 'follow') void follow(message);
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (event.code === 4001) {
|
||||
say('Vash refused the channel or token. Check them on the Broadcast page.');
|
||||
process.exit(1);
|
||||
}
|
||||
const wait = Math.min(30_000, 1000 * 2 ** vashAttempts++);
|
||||
say(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`);
|
||||
setTimeout(connectVash, wait);
|
||||
};
|
||||
}
|
||||
const link = new VashLink({
|
||||
server,
|
||||
channel,
|
||||
token: args.token!,
|
||||
log: say,
|
||||
onFollow: (next) => void follow(next),
|
||||
onRejected: () => process.exit(1)
|
||||
});
|
||||
|
||||
async function follow(next: {
|
||||
scene: string;
|
||||
matchId: string | null;
|
||||
lobbyId: number | null;
|
||||
title: string | null;
|
||||
roomName?: string | null;
|
||||
teamSize?: number | null;
|
||||
}) {
|
||||
async function follow(next: Follow) {
|
||||
const changedMatch = next.matchId !== current.matchId;
|
||||
const changedLobby = next.lobbyId != null && next.lobbyId !== current.lobbyId;
|
||||
if (changedMatch)
|
||||
@@ -217,48 +194,8 @@ if (tourney && args['auto-join']) {
|
||||
|
||||
// ── tosu ────────────────────────────────────────────────────────────────
|
||||
|
||||
let tosuAttempts = 0;
|
||||
let lastSent = 0;
|
||||
let lastKey = '';
|
||||
let pending: TosuFrame | null = null;
|
||||
const tosu = new TosuFeed(args.tosu!, (frame) => link.sendScores(frame), say);
|
||||
|
||||
function connectTosu() {
|
||||
const socket = new WebSocket(args.tosu!);
|
||||
socket.onopen = () => {
|
||||
tosuAttempts = 0;
|
||||
say('tosu connected');
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
const frame = parseTosu(JSON.parse(String(event.data)));
|
||||
if (!frame || !frame.clients.length) return;
|
||||
pending = frame;
|
||||
flush();
|
||||
};
|
||||
socket.onclose = () => {
|
||||
// tosu is often started after the agent; keep trying quietly.
|
||||
const wait = Math.min(15_000, 1000 * 2 ** tosuAttempts++);
|
||||
if (tosuAttempts === 3)
|
||||
say(`tosu is not running at ${args.tosu}; live scores will start when it is.`);
|
||||
setTimeout(connectTosu, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/** At most four updates a second, and only when something changed. */
|
||||
function flush() {
|
||||
if (!pending || vash?.readyState !== WebSocket.OPEN) return;
|
||||
const wait = 250 - (Date.now() - lastSent);
|
||||
if (wait > 0) {
|
||||
setTimeout(flush, wait);
|
||||
return;
|
||||
}
|
||||
const frame = pending;
|
||||
pending = null;
|
||||
const key = frame.clients.map((client) => `${client.osuId}:${client.score}`).join('|');
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
lastSent = Date.now();
|
||||
vash.send(JSON.stringify({ type: 'tosu', progress: frame.progress, clients: frame.clients }));
|
||||
}
|
||||
|
||||
connectVash();
|
||||
connectTosu();
|
||||
say(`Overlay: ${overlayUrl}`);
|
||||
link.start();
|
||||
tosu.start();
|
||||
|
||||
Reference in New Issue
Block a user