feat: redesign match play and add broadcast tooling
This commit is contained in:
264
streamer/agent.ts
Normal file
264
streamer/agent.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* vash-streamer: runs next to OBS and the osu! tourney client on the
|
||||
* streaming PC, and connects them to a Vash broadcast channel.
|
||||
*
|
||||
* tosu ──live scores──▶ agent ──▶ Vash (/ws/agent) ──▶ overlays, match page
|
||||
* Vash ──scene, lobby──▶ agent ──▶ OBS scenes, tourney client (#mp_…)
|
||||
*
|
||||
* Every part is optional except Vash itself: without tosu there are no live
|
||||
* scores from real lobbies, without OBS nothing switches scenes.
|
||||
*
|
||||
* vash-streamer --server https://esports.vash.software --channel <id> --token <token>
|
||||
* vash-streamer ... --obs-password <password> --setup-obs create a ready OBS scene
|
||||
*
|
||||
* Build single binaries with `bun run streamer:build`.
|
||||
*/
|
||||
import { parseArgs } from 'node:util';
|
||||
import { Obs } from './obs';
|
||||
import { parseTosu, type TosuFrame } from './tosu';
|
||||
import { Tourney } from './tourney';
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
server: { type: 'string', default: process.env.VASH_SERVER ?? 'https://esports.vash.software' },
|
||||
channel: { type: 'string', default: process.env.VASH_CHANNEL },
|
||||
token: { type: 'string', default: process.env.VASH_TOKEN },
|
||||
tosu: { type: 'string', default: process.env.TOSU_URL ?? 'ws://127.0.0.1:24050/websocket/v2' },
|
||||
obs: { type: 'string', default: process.env.OBS_URL ?? 'ws://127.0.0.1:4455' },
|
||||
'obs-password': { type: 'string', default: process.env.OBS_PASSWORD ?? '' },
|
||||
/** JSON: Vash scene → OBS scene name, e.g. {"gameplay":"Gameplay","idle":"Starting Soon"} */
|
||||
'obs-scenes': { type: 'string', default: process.env.OBS_SCENES ?? '' },
|
||||
'setup-obs': { type: 'boolean', default: false },
|
||||
'auto-join': { type: 'boolean', default: process.env.AUTO_JOIN === '1' },
|
||||
'osu-exe': { type: 'string', default: process.env.OSU_EXE ?? '' },
|
||||
help: { type: 'boolean', short: 'h', default: false }
|
||||
}
|
||||
});
|
||||
|
||||
if (args.help || !args.channel || !args.token) {
|
||||
console.log(`vash-streamer: connects tosu and OBS to a Vash broadcast channel.
|
||||
|
||||
--server URL Vash site (default https://esports.vash.software)
|
||||
--channel ID broadcast channel id (or VASH_CHANNEL)
|
||||
--token TOKEN the channel's agent token (or VASH_TOKEN)
|
||||
--tosu URL tosu websocket (default ws://127.0.0.1:24050/websocket/v2)
|
||||
--obs URL obs-websocket (default ws://127.0.0.1:4455)
|
||||
--obs-password PW obs-websocket password
|
||||
--obs-scenes JSON switch OBS scenes with the match, e.g. '{"gameplay":"Gameplay","results":"Results"}'
|
||||
--setup-obs create a "Vash" scene with the overlay and a tourney client capture, then exit
|
||||
--auto-join select the match in osu!tourney's control panel (Wine/X11)
|
||||
--osu-exe PATH launch osu!tourney from this executable under Wine`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const server = args.server!.replace(/\/$/, '');
|
||||
const channel = args.channel!;
|
||||
const overlayUrl = `${server}/broadcast/${channel}/overlay`;
|
||||
const say = (...parts: unknown[]) => console.log(new Date().toISOString().slice(11, 19), ...parts);
|
||||
|
||||
// ── OBS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const obs = new Obs(args.obs!, args['obs-password']!);
|
||||
const sceneMap: Record<string, string> = args['obs-scenes'] ? JSON.parse(args['obs-scenes']) : {};
|
||||
|
||||
if (args['setup-obs']) {
|
||||
await obs.connect();
|
||||
const { scenes } = await obs.request<{ scenes: { sceneName: string }[] }>('GetSceneList');
|
||||
if (!scenes.some((scene) => scene.sceneName === 'Vash'))
|
||||
await obs.request('CreateScene', { sceneName: 'Vash' });
|
||||
// Inputs added later sit on top: the capture first, then the see-through overlay over it.
|
||||
const kind =
|
||||
process.platform === 'win32'
|
||||
? 'window_capture'
|
||||
: process.platform === 'darwin'
|
||||
? 'screen_capture'
|
||||
: 'xcomposite_input';
|
||||
await obs
|
||||
.request('CreateInput', {
|
||||
sceneName: 'Vash',
|
||||
inputName: 'osu! tourney client',
|
||||
inputKind: kind,
|
||||
inputSettings: {}
|
||||
})
|
||||
.catch((err) => say('capture:', err.message));
|
||||
await obs
|
||||
.request('CreateInput', {
|
||||
sceneName: 'Vash',
|
||||
inputName: 'Vash overlay',
|
||||
inputKind: 'browser_source',
|
||||
inputSettings: {
|
||||
url: overlayUrl,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps_custom: true,
|
||||
fps: 60,
|
||||
reroute_audio: true
|
||||
}
|
||||
})
|
||||
.catch((err) => say('overlay:', err.message));
|
||||
await obs.request('SetCurrentProgramScene', { sceneName: 'Vash' });
|
||||
say('OBS is set up: scene "Vash" with the overlay on top of "osu! tourney client".');
|
||||
say('Open that capture source and pick the tourney client window.');
|
||||
obs.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function connectObs() {
|
||||
if (!Object.keys(sceneMap).length || obs.connected) return;
|
||||
try {
|
||||
await obs.connect();
|
||||
say('OBS connected');
|
||||
} catch (err) {
|
||||
say((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vash ────────────────────────────────────────────────────────────────
|
||||
|
||||
let vash: WebSocket | null = null;
|
||||
let vashAttempts = 0;
|
||||
let current: { scene: string; matchId: string | null; lobbyId: number | null } = {
|
||||
scene: 'idle',
|
||||
matchId: null,
|
||||
lobbyId: null
|
||||
};
|
||||
const tourney = args['osu-exe']
|
||||
? new Tourney(args['osu-exe'], {
|
||||
display: process.env.GAMEPLAY_DISPLAY ?? ':98',
|
||||
width: Number(process.env.GAMEPLAY_WIDTH ?? 1280),
|
||||
height: Number(process.env.GAMEPLAY_HEIGHT ?? 720)
|
||||
})
|
||||
: null;
|
||||
let roomName: string | null = null;
|
||||
let teamSize = 1;
|
||||
let selectedLobby: number | null = null;
|
||||
let lastJoinNotice = 0;
|
||||
|
||||
function connectVash() {
|
||||
const url = `${server.replace(/^http/, 'ws')}/ws/agent`;
|
||||
const socket = new WebSocket(url);
|
||||
vash = socket;
|
||||
socket.onopen = () =>
|
||||
socket.send(JSON.stringify({ type: 'hello', channelId: channel, token: args.token }));
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.type === 'welcome') {
|
||||
vashAttempts = 0;
|
||||
say(`Connected to ${server} as channel "${message.channel}". Overlay: ${overlayUrl}`);
|
||||
} else if (message.type === 'follow') void follow(message);
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (event.code === 4001) {
|
||||
say('Vash refused the channel or token. Check them on the Broadcast page.');
|
||||
process.exit(1);
|
||||
}
|
||||
const wait = Math.min(30_000, 1000 * 2 ** vashAttempts++);
|
||||
say(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`);
|
||||
setTimeout(connectVash, wait);
|
||||
};
|
||||
}
|
||||
|
||||
async function follow(next: {
|
||||
scene: string;
|
||||
matchId: string | null;
|
||||
lobbyId: number | null;
|
||||
title: string | null;
|
||||
roomName?: string | null;
|
||||
teamSize?: number | null;
|
||||
}) {
|
||||
const changedMatch = next.matchId !== current.matchId;
|
||||
const changedLobby = next.lobbyId != null && next.lobbyId !== current.lobbyId;
|
||||
if (changedMatch)
|
||||
say(
|
||||
next.title
|
||||
? `Now showing ${next.title}${next.lobbyId ? ` (mp/${next.lobbyId})` : ''}`
|
||||
: 'Nothing to show; idle'
|
||||
);
|
||||
if (next.scene !== current.scene) say(`Scene: ${next.scene}`);
|
||||
const sceneName = sceneMap[next.scene];
|
||||
if (sceneName && next.scene !== current.scene) {
|
||||
await connectObs();
|
||||
await obs.request('SetCurrentProgramScene', { sceneName }).catch((err) => say(err.message));
|
||||
}
|
||||
if (changedLobby) selectedLobby = null;
|
||||
roomName = next.roomName ?? null;
|
||||
teamSize = next.teamSize ?? 1;
|
||||
if (changedLobby && args['auto-join'] && !tourney)
|
||||
say('Automatic room selection needs --osu-exe on a Wine/X11 host.');
|
||||
current = { scene: next.scene, matchId: next.matchId, lobbyId: next.lobbyId };
|
||||
}
|
||||
|
||||
if (tourney && args['auto-join']) {
|
||||
let selecting = false;
|
||||
setInterval(async () => {
|
||||
if (selecting || !current.lobbyId || !roomName) return;
|
||||
selecting = true;
|
||||
try {
|
||||
if (await tourney.ensure(teamSize)) selectedLobby = null;
|
||||
if (selectedLobby === current.lobbyId) return;
|
||||
if (await tourney.select(roomName)) {
|
||||
selectedLobby = current.lobbyId;
|
||||
say(`osu!tourney is spectating ${roomName} (mp/${selectedLobby})`);
|
||||
} else if (Date.now() - lastJoinNotice > 30_000) {
|
||||
lastJoinNotice = Date.now();
|
||||
say(`Waiting for ${roomName} in osu!tourney's room list`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (Date.now() - lastJoinNotice > 30_000) {
|
||||
lastJoinNotice = Date.now();
|
||||
say(`osu!tourney: ${(error as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
selecting = false;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ── tosu ────────────────────────────────────────────────────────────────
|
||||
|
||||
let tosuAttempts = 0;
|
||||
let lastSent = 0;
|
||||
let lastKey = '';
|
||||
let pending: TosuFrame | null = null;
|
||||
|
||||
function connectTosu() {
|
||||
const socket = new WebSocket(args.tosu!);
|
||||
socket.onopen = () => {
|
||||
tosuAttempts = 0;
|
||||
say('tosu connected');
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
const frame = parseTosu(JSON.parse(String(event.data)));
|
||||
if (!frame || !frame.clients.length) return;
|
||||
pending = frame;
|
||||
flush();
|
||||
};
|
||||
socket.onclose = () => {
|
||||
// tosu is often started after the agent; keep trying quietly.
|
||||
const wait = Math.min(15_000, 1000 * 2 ** tosuAttempts++);
|
||||
if (tosuAttempts === 3)
|
||||
say(`tosu is not running at ${args.tosu}; live scores will start when it is.`);
|
||||
setTimeout(connectTosu, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/** At most four updates a second, and only when something changed. */
|
||||
function flush() {
|
||||
if (!pending || vash?.readyState !== WebSocket.OPEN) return;
|
||||
const wait = 250 - (Date.now() - lastSent);
|
||||
if (wait > 0) {
|
||||
setTimeout(flush, wait);
|
||||
return;
|
||||
}
|
||||
const frame = pending;
|
||||
pending = null;
|
||||
const key = frame.clients.map((client) => `${client.osuId}:${client.score}`).join('|');
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
lastSent = Date.now();
|
||||
vash.send(JSON.stringify({ type: 'tosu', progress: frame.progress, clients: frame.clients }));
|
||||
}
|
||||
|
||||
connectVash();
|
||||
connectTosu();
|
||||
Reference in New Issue
Block a user