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:
2026-10-18 22:02:25 +00:00
parent 9d5a046b40
commit 54444a77f0
45 changed files with 8168 additions and 643 deletions

239
streamer/windows/config.ts Normal file
View File

@@ -0,0 +1,239 @@
/**
* Where an instance lives and what it is configured with.
*
* %LOCALAPPDATA%\Vash\Broadcast\ (VASH_BROADCAST_HOME overrides)
* bin\vash-broadcast.exe the CLI, copied here by setup
* bin\vash-broadcast-worker.exe the same program without a console window
* bin\vash-desktop.ps1 desktop helper (windows, capture, OCR, input)
* downloads\ verified archives, reused by every instance
* tourney.lock which instance runs osu!tourney + tosu now
* instances\<name>\
* config.json server, channel, token, ports, options
* obs\ osu\ tosu\ ffmpeg\ this instance's own copies of each program
* hls\ logs\ snapshots\ recording scratch, logs, diagnostics
* state.json runtime.json what the worker is doing; how to reach it
*
* Every program an instance runs lives under its own folder, so stopping one
* instance can find and stop exactly its processes and nothing else.
*/
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
export const VERSION = '0.1.0';
export function rootDir() {
if (process.env.VASH_BROADCAST_HOME) return process.env.VASH_BROADCAST_HOME;
if (process.platform === 'win32')
return join(
process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'),
'Vash',
'Broadcast'
);
return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'vash-broadcast');
}
export const INSTANCE_NAME = /^[a-z][a-z0-9-]{0,19}$/;
export function instanceDir(name: string) {
if (!INSTANCE_NAME.test(name))
throw new Error(
`Instance names are 1–20 lowercase letters, digits or dashes, starting with a letter (got "${name}")`
);
return join(rootDir(), 'instances', name);
}
/** Paths inside an instance folder. */
export function layout(name: string) {
const dir = instanceDir(name);
const root = rootDir();
return {
root,
dir,
config: join(dir, 'config.json'),
state: join(dir, 'state.json'),
runtime: join(dir, 'runtime.json'),
request: join(dir, 'request.json'),
login: join(dir, 'login.json'),
doctor: join(dir, 'doctor.json'),
logs: join(dir, 'logs'),
log: join(dir, 'logs', 'worker.log'),
snapshots: join(dir, 'snapshots'),
hls: join(dir, 'hls'),
obs: join(dir, 'obs'),
obsExe: join(dir, 'obs', 'bin', '64bit', 'obs64.exe'),
obsConfig: join(dir, 'obs', 'config', 'obs-studio'),
osu: join(dir, 'osu'),
osuExe: join(dir, 'osu', 'osu!.exe'),
tosu: join(dir, 'tosu'),
tosuExe: join(dir, 'tosu', 'tosu.exe'),
ffmpeg: join(dir, 'ffmpeg', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
bin: join(root, 'bin'),
cliExe: join(root, 'bin', 'vash-broadcast.exe'),
workerExe: join(root, 'bin', 'vash-broadcast-worker.exe'),
helperScript: join(root, 'bin', 'vash-desktop.ps1'),
downloads: join(root, 'downloads'),
lock: join(root, 'tourney.lock')
};
}
export type Layout = ReturnType<typeof layout>;
export interface Ports {
/** The worker's local control API (status, stop, snapshots). */
control: number;
/** obs-websocket of this instance's OBS. */
obs: number;
/** tosu's HTTP/websocket server. */
tosu: number;
/** RTMP ingest OBS streams into; the relay records and restreams it. */
ingest: number;
/** The one-time sign-in screen viewer. */
viewer: number;
/** Local overlay proxy for sites behind Cloudflare Access (OBS cannot send its headers). */
proxy: number;
}
export interface InstanceConfig {
version: 1;
name: string;
/** The Vash site, e.g. https://esports.vash.software */
server: string;
channel: string;
/** The channel's agent token (Broadcast page → Agent token). */
token: string;
/** Cloudflare Access service token for sites behind Access (dev, staging). */
access: { clientId: string; clientSecret: string } | null;
ports: Ports;
obsPassword: string;
ingestKey: string;
/** Where osu!.exe was copied from for this instance's separate tourney installation. */
osuSource: string | null;
/** osu!tourney's streamable height (tournament.cfg Height); the window is ~200 px taller. */
tourneyHeight: number;
/** 'amd' asks OBS for AMD AMF hardware encoding; 'x264' chooses software encoding on purpose. */
encoder: 'amd' | 'x264';
/** Start the worker when this Windows user signs in. */
autostart: boolean;
}
const PORT_BASE = 47100;
const PORT_STEP = 10;
export function portsForSlot(slot: number): Ports {
const base = PORT_BASE + slot * PORT_STEP;
return {
control: base,
obs: base + 1,
tosu: base + 2,
ingest: base + 3,
viewer: base + 4,
proxy: base + 5
};
}
/** The first port block no other instance uses. */
export function allocatePorts(taken: Ports[]): Ports {
const used = new Set(taken.map((ports) => ports.control));
for (let slot = 0; slot < 50; slot++) {
const ports = portsForSlot(slot);
if (!used.has(ports.control)) return ports;
}
throw new Error('No free port block for another instance');
}
export const secret = (bytes = 18) => randomBytes(bytes).toString('base64url');
export function validateServer(server: string) {
let url: URL;
try {
url = new URL(server);
} catch {
throw new Error(`--server must be a URL like https://esports.vash.software (got "${server}")`);
}
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost'].includes(url.hostname))
throw new Error('--server must use https:// (plain http only for localhost)');
return url.origin;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export function validateConfig(value: unknown): InstanceConfig {
const config = value as InstanceConfig;
const problems: string[] = [];
if (!config || typeof config !== 'object') throw new Error('config.json is not an object');
if (config.version !== 1) problems.push('unknown config version');
if (!INSTANCE_NAME.test(config.name ?? '')) problems.push('bad instance name');
try {
validateServer(config.server);
} catch (err) {
problems.push((err as Error).message);
}
if (!UUID.test(config.channel ?? '')) problems.push('channel must be the channel id (a UUID)');
if (typeof config.token !== 'string' || !config.token) problems.push('missing agent token');
for (const key of ['control', 'obs', 'tosu', 'ingest', 'viewer', 'proxy'] as const)
if (!Number.isInteger(config.ports?.[key])) problems.push(`missing ${key} port`);
if (!config.obsPassword || !config.ingestKey) problems.push('missing generated secrets');
if (!(config.tourneyHeight >= 568 && config.tourneyHeight <= 2160))
problems.push('tourneyHeight must be 568–2160');
if (config.encoder !== 'amd' && config.encoder !== 'x264')
problems.push('encoder must be amd or x264');
if (problems.length) throw new Error(`Invalid instance config: ${problems.join('; ')}`);
return config;
}
export async function loadConfig(name: string): Promise<InstanceConfig> {
const path = layout(name).config;
if (!existsSync(path))
throw new Error(
`Instance "${name}" is not set up. Run: vash-broadcast setup --instance ${name} …`
);
return validateConfig(JSON.parse(await readFile(path, 'utf8')));
}
export async function listInstances(): Promise<InstanceConfig[]> {
const dir = join(rootDir(), 'instances');
const names = await readdir(dir).catch(() => [] as string[]);
const configs: InstanceConfig[] = [];
for (const name of names) {
if (!INSTANCE_NAME.test(name)) continue;
try {
configs.push(await loadConfig(name));
} catch {
/* half-set-up instance; ignore */
}
}
return configs;
}
/** Writes JSON through a temporary file so readers never see half a file. */
export async function writeJson(path: string, value: unknown) {
await mkdir(join(path, '..'), { recursive: true });
const temp = `${path}.${process.pid}.tmp`;
await writeFile(temp, JSON.stringify(value, null, '\t'));
await rename(temp, path);
}
export async function readJson<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, 'utf8')) as T;
} catch {
return null;
}
}
export async function saveConfig(config: InstanceConfig) {
await writeJson(layout(config.name).config, validateConfig(config));
}
/** Headers for a site behind Cloudflare Access. */
export function accessHeaders(config: InstanceConfig): Record<string, string> {
return config.access
? {
'CF-Access-Client-Id': config.access.clientId,
'CF-Access-Client-Secret': config.access.clientSecret
}
: {};
}