Files
esports/streamer/link.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

197 lines
6.2 KiB
TypeScript

/**
* The agent connection to a Vash broadcast channel (/ws/agent): Vash says
* which match, lobby and scene to follow; we send live tosu scores and,
* from an unattended worker, what the broadcast PC is doing.
*/
import type { TosuFrame } from './tosu';
export interface Follow {
scene: string;
matchId: string | null;
lobbyId: number | null;
roomName: string | null;
teamSize: number | null;
title: string | null;
}
/** What an unattended worker reports; shown on the channel's pages. */
export interface WorkerReport {
state: string;
detail: string | null;
onAir: boolean;
encoder: string | null;
instance: string | null;
version: string | null;
}
export interface LinkOptions {
server: string;
channel: string;
token: string;
/** Extra headers, e.g. Cloudflare Access service-token headers. */
headers?: Record<string, string>;
log?: (...parts: unknown[]) => void;
onFollow?: (follow: Follow) => void;
/** The channel or token was refused; retrying will not help until it changes. */
onRejected?: () => void;
}
export class VashLink {
connected = false;
rejected = false;
channelName: string | null = null;
follow: Follow | null = null;
private socket: WebSocket | null = null;
private attempts = 0;
private stopped = false;
private timer: ReturnType<typeof setTimeout> | null = null;
private lastSent = 0;
private lastKey = '';
private pending: TosuFrame | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private lastReport = '';
private lastReportAt = 0;
/** Server acknowledgement: a connected socket alone does not prove scores reached the match. */
scores = { sentAt: 0, acceptedAt: 0, players: 0 };
private log: (...parts: unknown[]) => void;
constructor(private options: LinkOptions) {
this.log = options.log ?? (() => {});
}
get url() {
return `${this.options.server.replace(/\/$/, '').replace(/^http/, 'ws')}/ws/agent`;
}
start() {
this.stopped = false;
this.rejected = false;
this.connect();
}
private connect() {
if (this.stopped) return;
const headers = this.options.headers ?? {};
// Bun's WebSocket accepts headers; they carry Cloudflare Access service tokens.
const socket = Object.keys(headers).length
? new WebSocket(this.url, { headers } as unknown as string[])
: new WebSocket(this.url);
this.socket = socket;
socket.onopen = () =>
socket.send(
JSON.stringify({
type: 'hello',
channelId: this.options.channel,
token: this.options.token
})
);
socket.onmessage = (event) => {
let message: { type?: string; channel?: string; accepted?: number } & Partial<Follow>;
try {
message = JSON.parse(String(event.data));
} catch {
return;
}
if (message.type === 'welcome') {
this.attempts = 0;
this.connected = true;
this.channelName = message.channel ?? null;
this.lastReport = '';
this.lastKey = '';
this.log(`Connected to ${this.options.server} as channel "${message.channel}"`);
} else if (message.type === 'follow') {
const follow: Follow = {
scene: String(message.scene ?? 'idle'),
matchId: message.matchId ?? null,
lobbyId: typeof message.lobbyId === 'number' ? message.lobbyId : null,
roomName: message.roomName ?? null,
teamSize: typeof message.teamSize === 'number' ? message.teamSize : null,
title: message.title ?? null
};
if (follow.matchId !== this.follow?.matchId || follow.lobbyId !== this.follow?.lobbyId) {
this.pending = null;
this.lastKey = '';
this.scores = { sentAt: 0, acceptedAt: 0, players: 0 };
}
this.follow = follow;
this.options.onFollow?.(follow);
} else if (message.type === 'scores' && message.matchId === this.follow?.matchId) {
this.scores.players = Number(message.accepted) || 0;
if (this.scores.players > 0) this.scores.acceptedAt = Date.now();
}
};
socket.onclose = (event) => {
if (this.socket !== socket) return;
this.connected = false;
this.socket = null;
this.pending = null;
this.follow = null;
if (this.stopped) return;
if (event.code === 4001) {
this.rejected = true;
this.log('Vash refused the channel or token. Check them on the Broadcast page.');
this.options.onRejected?.();
// A rotated token is fixed by re-running setup; check again now and then anyway.
this.timer = setTimeout(() => this.connect(), 5 * 60_000);
return;
}
const wait = Math.min(30_000, 1000 * 2 ** this.attempts++);
this.log(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`);
this.timer = setTimeout(() => this.connect(), wait);
};
}
/** At most four updates a second; unchanged fresh frames heartbeat once a second. */
sendScores(frame: TosuFrame) {
if (!frame.clients.length || !this.connected || !this.follow?.matchId) return;
this.pending = frame;
this.flush();
}
private flush() {
if (!this.pending || !this.connected || this.socket?.readyState !== WebSocket.OPEN) return;
const wait = 250 - (Date.now() - this.lastSent);
if (wait > 0) {
this.flushTimer ??= setTimeout(() => {
this.flushTimer = null;
this.flush();
}, wait);
return;
}
const frame = this.pending;
this.pending = null;
const message = JSON.stringify({
type: 'tosu',
matchId: this.follow?.matchId,
beatmapId: frame.beatmapId && frame.beatmapId > 0 ? frame.beatmapId : null,
progress: frame.progress,
clients: frame.clients
});
if (message === this.lastKey && Date.now() - this.lastSent < 1000) return;
this.lastKey = message;
this.lastSent = Date.now();
this.scores.sentAt = this.lastSent;
this.socket.send(message);
}
/** Sends the worker's state when it changes, and every 30 seconds as a heartbeat. */
report(status: WorkerReport) {
if (!this.connected || this.socket?.readyState !== WebSocket.OPEN) return;
const key = JSON.stringify(status);
if (key === this.lastReport && Date.now() - this.lastReportAt < 30_000) return;
this.lastReport = key;
this.lastReportAt = Date.now();
this.socket.send(JSON.stringify({ type: 'status', ...status }));
}
stop() {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
if (this.flushTimer) clearTimeout(this.flushTimer);
const socket = this.socket;
this.socket = null;
this.connected = false;
socket?.close();
}
}