- 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>
197 lines
6.2 KiB
TypeScript
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();
|
|
}
|
|
}
|