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:
239
streamer/windows/config.ts
Normal file
239
streamer/windows/config.ts
Normal 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
|
||||
}
|
||||
: {};
|
||||
}
|
||||
Reference in New Issue
Block a user