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:
2026-10-18 22:02:25 +00:00
parent 9d5a046b40
commit 54444a77f0
45 changed files with 8168 additions and 643 deletions

View File

@@ -1,7 +1,8 @@
/**
* tosu (https://github.com/tosuapp/tosu) reads the osu! tourney client's
* memory and serves it over a websocket. We need each spectated player's
* live score, and how far through the map they are.
* live score, how far through the map they are, and the team names of the
* room being spectated (to confirm the right room was selected).
*/
export interface TosuClientScore {
@@ -20,6 +21,8 @@ export interface TosuFrame {
beatmapId: number | null;
/** 0–1 through the map. */
progress: number | null;
/** Team names osu!tourney parsed from the spectated room's name; empty when none. */
teams: { left: string; right: string };
clients: TosuClientScore[];
}
@@ -28,9 +31,11 @@ export function parseTosu(message: unknown): TosuFrame | null {
const root = message as {
tourney?: {
ipcState?: number;
team?: { left?: string; right?: string };
clients?: {
user?: { id?: number; name?: string };
play?: {
playerName?: string;
score?: number;
accuracy?: number;
failed?: boolean;
@@ -53,11 +58,18 @@ export function parseTosu(message: unknown): TosuFrame | null {
span > 0 && time?.live != null
? Math.max(0, Math.min(1, (time.live - time.firstObject!) / span))
: null,
teams: {
left: String(root.tourney.team?.left ?? '').trim(),
right: String(root.tourney.team?.right ?? '').trim()
},
clients: root.tourney.clients
.filter((client) => (client.user?.id ?? 0) > 0)
.filter(
(client) =>
(client.user?.id ?? 0) > 0 || client.user?.name?.trim() || client.play?.playerName?.trim()
)
.map((client) => ({
osuId: client.user!.id!,
name: client.user?.name ?? '',
osuId: client.user?.id ?? 0,
name: client.user?.name?.trim() || client.play?.playerName?.trim() || '',
score: client.play?.score ?? 0,
accuracy: client.play?.accuracy ?? 0,
combo: client.play?.combo?.current ?? 0,
@@ -65,3 +77,74 @@ export function parseTosu(message: unknown): TosuFrame | null {
}))
};
}
/** Keeps a websocket to tosu open and remembers its latest tourney frame. */
export class TosuFeed {
latest: TosuFrame | null = null;
latestAt = 0;
connected = false;
private socket: WebSocket | null = null;
private attempts = 0;
private stopped = false;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private url: string,
private onFrame: (frame: TosuFrame) => void,
private log: (...parts: unknown[]) => void = () => {}
) {}
start() {
this.stopped = false;
this.connect();
}
private connect() {
if (this.stopped) return;
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onopen = () => {
this.attempts = 0;
this.connected = true;
this.log('tosu connected');
};
socket.onmessage = (event) => {
let frame: TosuFrame | null;
try {
frame = parseTosu(JSON.parse(String(event.data)));
} catch {
return;
}
if (!frame) return;
this.latest = frame;
this.latestAt = Date.now();
this.onFrame(frame);
};
socket.onclose = () => {
if (this.socket !== socket) return;
if (this.connected) this.log('tosu disconnected');
this.connected = false;
this.socket = null;
if (this.stopped) return;
// tosu often starts after us; keep trying quietly.
const wait = Math.min(15_000, 1000 * 2 ** this.attempts++);
if (this.attempts === 4)
this.log(`tosu is not answering at ${this.url}; live scores start when it does.`);
this.timer = setTimeout(() => this.connect(), wait);
};
}
/** The latest frame if it is recent enough to trust. */
fresh(maxAgeMs = 5000) {
return this.connected && Date.now() - this.latestAt <= maxAgeMs ? this.latest : null;
}
stop() {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
const socket = this.socket;
this.socket = null;
this.connected = false;
socket?.close();
}
}