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

240 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
}
: {};
}