- 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>
151 lines
4.2 KiB
TypeScript
151 lines
4.2 KiB
TypeScript
/**
|
||
* 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, 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 {
|
||
osuId: number;
|
||
name: string;
|
||
score: number;
|
||
/** Percent. */
|
||
accuracy: number;
|
||
combo: number;
|
||
failed: boolean;
|
||
}
|
||
|
||
export interface TosuFrame {
|
||
/** 1 idle, 2 waiting for clients, 3 playing, 4 results (tosu's ipcState). */
|
||
ipcState: number | null;
|
||
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[];
|
||
}
|
||
|
||
/** Picks what Vash needs out of one tosu v2 message. Returns null outside tourney mode. */
|
||
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;
|
||
combo?: { current?: number };
|
||
};
|
||
}[];
|
||
};
|
||
beatmap?: { id?: number; time?: { live?: number; firstObject?: number; lastObject?: number } };
|
||
};
|
||
if (!root?.tourney || !Array.isArray(root.tourney.clients)) return null;
|
||
const time = root.beatmap?.time;
|
||
const span =
|
||
time && time.lastObject != null && time.firstObject != null
|
||
? time.lastObject - time.firstObject
|
||
: 0;
|
||
return {
|
||
ipcState: root.tourney.ipcState ?? null,
|
||
beatmapId: root.beatmap?.id ?? null,
|
||
progress:
|
||
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 || client.user?.name?.trim() || client.play?.playerName?.trim()
|
||
)
|
||
.map((client) => ({
|
||
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,
|
||
failed: client.play?.failed ?? false
|
||
}))
|
||
};
|
||
}
|
||
|
||
/** 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();
|
||
}
|
||
}
|