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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user