#!/usr/bin/env bun /** * vash-broadcast: sets up and runs an unattended Vash broadcast on a Windows * PC, one instance per Vash site (dev, staging, prod). Everything is driven * from an SSH session; the worker itself runs in the signed-in desktop. * * Build with `bun run broadcast:build` (dist/vash-broadcast.exe). */ import { existsSync } from 'node:fs'; import { copyFile, readdir, readFile, rm, stat } from 'node:fs/promises'; import { hostname } from 'node:os'; import { join } from 'node:path'; import { parseArgs } from 'node:util'; import { VashApi, VashApiError } from '../vash-api'; import { accessHeaders, allocatePorts, layout, listInstances, loadConfig, readJson, saveConfig, secret, validateServer, VERSION, writeJson, type InstanceConfig } from './config'; import { callWorker, type Runtime } from './control'; import { WindowsDesktop } from './desktop'; import { findOsu, installFfmpeg, installObs, installSelf, installTosu, PINS, prepareOsu, writeTosuEnv } from './install'; import { lockOwner } from './lock'; import { writeObsSettings } from './obs-files'; import { consoleUser, endTask, gpuNames, registerTask, runTask, taskInfo, unregisterTask } from './tasks'; import { readSavedLogin } from './tourney'; import { runTask as runWatchdog, runWorker, stopInstanceProcesses, type SignInState, type WorkerState } from './worker'; const HELP = `vash-broadcast ${VERSION}: an unattended Vash broadcast on this Windows PC. One-time setup, per instance (dev, staging, prod…): vash-broadcast setup --instance prod --server https://esports.vash.software --channel --token Installs OBS, tosu and FFmpeg for this instance, a separate osu!tourney folder, and a scheduled task that runs the worker in your signed-in desktop. --osu-exe PATH your osu! stable osu!.exe to copy (found automatically otherwise) --token-stdin read the agent token from standard input instead of --token --access-id ID --access-secret SECRET Cloudflare Access service token, for dev/staging sites behind Access --tourney-height N osu!tourney streamable height (default 720; the window is ~200 px taller) --encoder amd|x264 amd (default) uses the Radeon's hardware encoder --no-autostart do not start the worker when you sign in to Windows vash-broadcast login --instance prod One-time sign-in of the spectator osu! account, shown to you through an SSH tunnel. vash-broadcast login --instance dev --copy-from prod Reuse another instance's remembered login instead (same Windows user). Every day, nothing: the worker follows the site. Go live and End stream on the channel's Stream page start and stop the broadcast. To manage it: vash-broadcast start|stop|restart --instance prod (stop/restart also take --all) vash-broadcast status --instance prod [--json] vash-broadcast logs --instance prod [-n 100] [-f] vash-broadcast snapshot --instance prod save what OBS and osu!tourney show vash-broadcast doctor --instance prod check the setup and the desktop vash-broadcast list vash-broadcast remove --instance prod --yes unregister (keeps files unless --delete-files) `; const { values: args, positionals } = parseArgs({ allowPositionals: true, options: { instance: { type: 'string', short: 'i' }, server: { type: 'string' }, channel: { type: 'string' }, token: { type: 'string' }, 'token-stdin': { type: 'boolean', default: false }, 'access-id': { type: 'string' }, 'access-secret': { type: 'string' }, 'osu-exe': { type: 'string' }, 'tourney-height': { type: 'string' }, encoder: { type: 'string' }, 'no-autostart': { type: 'boolean', default: false }, autostart: { type: 'boolean', default: false }, all: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, follow: { type: 'boolean', short: 'f', default: false }, lines: { type: 'string', short: 'n' }, yes: { type: 'boolean', default: false }, 'delete-files': { type: 'boolean', default: false }, 'copy-from': { type: 'string' }, help: { type: 'boolean', short: 'h', default: false }, version: { type: 'boolean', short: 'V', default: false } } }); const command = positionals[0]; const say = (line = '') => console.log(line); class UsageError extends Error {} function instanceName() { if (!args.instance) throw new UsageError('Say which instance: --instance dev|staging|prod (or your own name)'); return args.instance; } async function running(name: string) { return callWorker(layout(name).runtime, '/status'); } function requireWindows(what: string) { if (process.platform !== 'win32') throw new Error(`${what} works only on Windows`); } // ── setup ─────────────────────────────────────────────────────────────── async function setup() { const name = instanceName(); const paths = layout(name); const existing = await loadConfig(name).catch(() => null); const others = (await listInstances()).filter((config) => config.name !== name); const serverArg = args.server ?? existing?.server; if (!serverArg) throw new UsageError('--server is required, e.g. --server https://esports.vash.software'); const channel = args.channel ?? existing?.channel; if (!channel) throw new UsageError('--channel is required (the channel id from its Broadcast page link)'); let token = args.token ?? process.env.VASH_TOKEN ?? existing?.token; if (args['token-stdin']) token = (await new Response(Bun.stdin.stream()).text()).trim(); if (!token) throw new UsageError('--token is required (Broadcast page → Agent token)'); const accessId = args['access-id'] ?? process.env.VASH_ACCESS_CLIENT_ID; const accessSecret = args['access-secret'] ?? process.env.VASH_ACCESS_CLIENT_SECRET; if (!!accessId !== !!accessSecret) throw new UsageError('--access-id and --access-secret go together'); const height = Number(args['tourney-height'] ?? existing?.tourneyHeight ?? 720); const encoder = (args.encoder ?? existing?.encoder ?? 'amd') as InstanceConfig['encoder']; const config: InstanceConfig = { version: 1, name, server: validateServer(serverArg), channel, token, access: accessId ? { clientId: accessId, clientSecret: accessSecret! } : (existing?.access ?? null), ports: existing?.ports ?? allocatePorts(others.map((other) => other.ports)), obsPassword: existing?.obsPassword ?? secret(), ingestKey: existing?.ingestKey ?? secret(), osuSource: existing?.osuSource ?? null, tourneyHeight: height, encoder, autostart: args['no-autostart'] ? false : args.autostart ? true : (existing?.autostart ?? true) }; for (const other of others) if (other.server === config.server && other.channel === config.channel) say(`Note: instance "${other.name}" follows the same channel.`); if (await running(name)) throw new Error( `The "${name}" worker is running. Stop it first: vash-broadcast stop --instance ${name}` ); say(`Checking ${config.server} …`); const api = new VashApi(config.server, config.channel, config.token, accessHeaders(config)); const stream = await api.stream().catch((err) => { if (err instanceof VashApiError && err.status === 401) throw new Error( 'Vash refused the channel id or agent token. Copy them again from the Broadcast page.' ); throw err; }); say( ` Channel "${stream.name ?? channel}": ${stream.width}×${stream.height} ${stream.fps} fps, ${stream.bitrateKbps} kbps, ` + `${stream.destinations.length} destination(s), recording ${stream.record ? 'on' : 'off'}` ); const progress = (line: string) => say(` ${line}`); if (process.platform === 'win32') { say('Installing programs (pinned versions, checksums verified)…'); say(` vash-broadcast: ${await installSelf(paths)}`); await WindowsDesktop.writeScript(paths.helperScript); say(` FFmpeg ${PINS.ffmpeg.version}: ${await installFfmpeg(paths, progress)}`); say(` tosu ${PINS.tosu.version}: ${await installTosu(paths, progress)}`); await writeTosuEnv(paths, config.ports.tosu); say( ` OBS ${PINS.obs.version} (portable, this instance only): ${await installObs(paths, progress)}` ); await writeObsSettings(paths.obsConfig, { websocketPort: config.ports.obs, websocketPassword: config.obsPassword, width: stream.width, height: stream.height, fps: stream.fps, bitrateKbps: stream.bitrateKbps, encoder: config.encoder, ingestUrl: `rtmp://127.0.0.1:${config.ports.ingest}/live`, ingestKey: config.ingestKey }); const source = await findOsu(args['osu-exe'] ?? config.osuSource); if (!source) throw new Error( 'Could not find osu! stable. Pass the path of your osu!.exe with --osu-exe; it is only copied, never changed.' ); config.osuSource = source; say( ` osu!tourney folder (osu!.exe copied from ${source}): ${await prepareOsu(paths, source)}` ); } else say('Not on Windows: saving the configuration only.'); await saveConfig(config); if (process.platform === 'win32') { await registerTask({ instance: name, exe: paths.workerExe, cwd: paths.root, autostart: config.autostart }); say( ` Scheduled task: registered${config.autostart ? ', starts when you sign in to Windows' : ''}` ); } say(); say(`Instance "${name}" is set up in ${paths.dir}`); say( ` Ports: control ${config.ports.control}, OBS ${config.ports.obs}, tosu ${config.ports.tosu}, relay ${config.ports.ingest}` ); const login = await readSavedLogin(paths.osu); say(); if (!login.saved) say(`Next, sign in the spectator account once: vash-broadcast login --instance ${name}`); else say( `Spectator login saved (${login.username}). Start it: vash-broadcast start --instance ${name}` ); } // ── start / stop ──────────────────────────────────────────────────────── async function requireDesktop() { const user = await consoleUser(); if (!user) throw new Error( 'Nobody is signed in to Windows at the PC. The worker runs in the signed-in desktop; sign in once (or turn on automatic sign-in) and try again.' ); return user; } async function start(name: string) { requireWindows('start'); const paths = layout(name); await loadConfig(name); const status = await running(name); if (status) { say( `"${name}" is already running: ${status.state}${status.detail ? ` (${status.detail})` : ''}` ); return; } await requireDesktop(); const task = await taskInfo(name); if (!task.exists) throw new Error(`No scheduled task for "${name}". Run setup again.`); await writeJson(paths.request, { mode: 'run', at: Date.now() }); await runTask(name); say(`Starting "${name}" in the desktop session…`); for (let waited = 0; waited < 45; waited++) { await Bun.sleep(1000); const status = await running(name); if (status) { say(`Running: ${status.state}${status.detail ? ` (${status.detail})` : ''}`); say( `Follow it with: vash-broadcast status --instance ${name} or vash-broadcast logs --instance ${name} -f` ); return; } } const info = await taskInfo(name); throw new Error( `The worker did not come up in 45 s (task ${info.state}, last result ${info.lastTaskResult}). See: vash-broadcast logs --instance ${name}` ); } async function stop(name: string) { const paths = layout(name); const runtime = await readJson(paths.runtime); const status = await running(name); if (status) { say(`Stopping "${name}"${status.onAir ? ' (going off air and finishing the recording)' : ''}…`); await callWorker(paths.runtime, '/stop', { method: 'POST' }); for (let waited = 0; waited < 120; waited++) { await Bun.sleep(1000); if (!(await running(name))) { say('Stopped.'); return; } } say('It did not stop in 2 minutes; stopping it forcibly.'); } else if (!runtime) { say(`"${name}" is not running.`); } // Forcibly: the task (and its watchdog), the worker, then everything in the instance folder. if (process.platform === 'win32') { await endTask(name); const desktop = new WindowsDesktop(paths.helperScript); try { const pids = [runtime?.watchdogPid, runtime?.pid].filter( (pid): pid is number => !!pid && pid !== process.pid ); await desktop.kill(pids).catch(() => 0); const killed = await stopInstanceProcesses(desktop, paths, () => {}); if (killed) say(`Stopped ${killed} process(es) of "${name}".`); } finally { desktop.stop(); } } await rm(paths.runtime, { force: true }); } // ── status ────────────────────────────────────────────────────────────── function age(iso: string | null | undefined) { if (!iso) return 'never'; const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000); return seconds < 90 ? `${seconds}s ago` : seconds < 5400 ? `${Math.round(seconds / 60)} min ago` : `${Math.round(seconds / 3600)} h ago`; } function printState(state: WorkerState) { say(`State: ${state.state}${state.detail ? ` — ${state.detail}` : ''}`); if (state.match?.title) say( `Match: ${state.match.title}${state.match.lobbyId ? ` (mp/${state.match.lobbyId})` : ' (no osu! lobby)'}` ); else say('Match: none; the channel is idle'); say( `Tourney: ${state.tourney.state}${state.tourney.detail ? ` — ${state.tourney.detail}` : ''}${state.tourney.verified ? ' ✓ confirmed' : ''}` ); say( `Broadcast: ${state.live ? 'Go live is on' : 'off air'}${state.onAir ? `; OBS ${state.obsStreaming ? 'streaming' : 'NOT streaming'}, relay ${state.relayReceiving ? 'receiving' : 'waiting'}` : ''}` ); if (state.encoder) say(`Encoder: ${state.encoder}`); for (const destination of state.destinations) say( ` → ${destination.label}: ${destination.state}${destination.error ? ` (${destination.error})` : ''}` ); const components = state.components as Record>; for (const [label, component] of Object.entries(components)) say( `${`${label}:`.padEnd(11)}${Object.entries(component) .filter(([, value]) => value !== null && value !== undefined) .map(([key, value]) => `${key} ${value}`) .join(', ')}` ); say( `Vash: ${state.vash.connected ? `connected (channel "${state.vash.channel}")` : 'not connected'}${state.vash.lastError ? `; ${state.vash.lastError}` : ''}` ); if (state.desktop) say( `Desktop: ${state.desktop.inputDesktop === 'Default' ? 'unlocked' : 'LOCKED'}, ${state.desktop.screenWidth}×${state.desktop.screenHeight}` ); for (const warning of state.warnings) say(`Warning: ${warning}`); say(`Worker: pid ${state.pid}, up since ${state.startedAt} (v${state.version})`); } async function status(name: string) { const paths = layout(name); const live = await running(name); if (args.json) { say(JSON.stringify(live ?? { running: false, last: await readJson(paths.state) }, null, 2)); return; } say(`Instance "${name}"`); if (live) { printState(live); return; } say('Worker: not running'); const last = await readJson(paths.state); if (last) say( `Last seen: ${last.state}${last.detail ? ` — ${last.detail}` : ''} (${age(last.updatedAt)})` ); const login = await readSavedLogin(paths.osu); say( `Login: ${login.saved ? `saved (${login.username})` : `not saved; run vash-broadcast login --instance ${name}`}` ); if (process.platform === 'win32') { const task = await taskInfo(name).catch(() => null); if (task) say( `Task: ${task.exists ? `${task.state}, last run ${age(task.lastRunTime)} (result ${task.lastTaskResult})${task.autostart ? ', starts at sign-in' : ''}` : 'not registered; run setup'}` ); } const owner = await lockOwner(paths.lock); if (owner && owner.instance !== name) say(`Tourney: instance "${owner.instance}" is running osu!tourney on this PC`); } // ── logs, snapshot, doctor, list, remove ────────────────────────────────── async function logs(name: string) { const path = layout(name).log; if (!existsSync(path)) { say('No log yet.'); return; } const count = Number(args.lines ?? 60); const text = await readFile(path, 'utf8'); say(text.trimEnd().split('\n').slice(-count).join('\n')); if (!args.follow) return; let offset = (await stat(path)).size; for (;;) { await Bun.sleep(1000); const size = (await stat(path).catch(() => null))?.size ?? 0; if (size < offset) offset = 0; if (size > offset) { const file = Bun.file(path).slice(offset, size); process.stdout.write(await file.text()); offset = size; } } } async function snapshot(name: string) { const result = await callWorker<{ files: string[]; errors: string[] }>( layout(name).runtime, '/snapshot', { method: 'POST', timeoutMs: 30_000 } ); if (!result) throw new Error(`"${name}" is not running.`); for (const file of result.files) say(file); for (const error of result.errors) say(`Could not capture: ${error}`); if (result.files.length) say( `\nCopy them to your Mac with, for example: scp "${process.env.USERNAME ?? 'you'}@${hostname()}:${result.files[0].replace(/\\/g, '/')}" .` ); } async function doctor(name: string) { const paths = layout(name); const results: [boolean | null, string, string][] = []; const check = async (label: string, run: () => Promise) => { try { const outcome = await run(); results.push( typeof outcome === 'string' ? [true, label, outcome] : [outcome[0], label, outcome[1]] ); } catch (err) { results.push([false, label, (err as Error).message]); } }; const config = await loadConfig(name); await check('Vash', async () => { const stream = await new VashApi( config.server, config.channel, config.token, accessHeaders(config) ).stream(); return `${config.server}: channel "${stream.name}" answers; Go live is ${stream.live ? 'on' : 'off'}`; }); for (const [label, path] of [ ['OBS', paths.obsExe], ['tosu', paths.tosuExe], ['FFmpeg', paths.ffmpeg], ['osu!tourney folder', paths.osuExe], ['Worker program', paths.workerExe] ] as const) await check(label, async () => existsSync(path) ? path : [false, `missing ${path}; run setup`] ); await check('Spectator login', async () => { const login = await readSavedLogin(paths.osu); return login.saved ? `saved (${login.username})` : [false, `not saved; run vash-broadcast login --instance ${name}`]; }); if (process.platform === 'win32') { await check('Scheduled task', async () => { const task = await taskInfo(name); return task.exists ? `${task.state}, last result ${task.lastTaskResult}` : [false, 'not registered; run setup']; }); await check('Signed-in desktop', async () => `${await requireDesktop()} is signed in`); await check('GPU', async () => { const gpus = await gpuNames(); const amd = gpus.some((gpu) => /radeon|amd/i.test(gpu)); return [amd || config.encoder !== 'amd' ? true : null, gpus.join(', ') || 'unknown']; }); } await check('Tourney lock', async () => { const owner = await lockOwner(paths.lock); return !owner ? 'free' : owner.instance === name ? 'held by this instance' : [null, `held by "${owner.instance}"`]; }); const live = await running(name); if (live) { results.push([ live.state === 'error' ? false : true, 'Worker', `${live.state}${live.detail ? ` — ${live.detail}` : ''}` ]); if (live.desktop) results.push([ live.desktop.inputDesktop === 'Default', 'Desktop', live.desktop.inputDesktop === 'Default' ? 'unlocked' : 'locked: rooms cannot be selected while Windows is locked' ]); } else if (process.platform === 'win32') { // Checks that only mean something inside the desktop session run there, through the task. await rm(paths.doctor, { force: true }); await writeJson(paths.request, { mode: 'doctor', at: Date.now() }); await runTask(name); let report: { checks: { name: string; ok: boolean; detail: string }[] } | null = null; for (let waited = 0; waited < 60 && !report; waited++) { await Bun.sleep(1000); report = await readJson(paths.doctor); } if (!report) results.push([false, 'Desktop checks', 'the task did not report back in 60 s']); else for (const each of report.checks) results.push([each.ok, each.name, each.detail]); } for (const [ok, label, detail] of results) say(`${ok === true ? ' ok ' : ok === false ? 'FAIL' : ' ?? '} ${label}: ${detail}`); if (results.some(([ok]) => ok === false)) process.exitCode = 1; } async function list() { const configs = await listInstances(); if (!configs.length) { say('No instances. Set one up with: vash-broadcast setup --instance prod …'); return; } for (const config of configs) { const live = await running(config.name); say( `${config.name.padEnd(10)} ${config.server} ${live ? `${live.state}${live.onAir ? ', on air' : ''}` : 'stopped'}` ); } } async function remove(name: string) { if (!args.yes) throw new UsageError(`This unregisters "${name}". Add --yes to confirm.`); await stop(name); if (process.platform === 'win32') await unregisterTask(name); if (args['delete-files']) { await rm(layout(name).dir, { recursive: true, force: true }); say(`Removed "${name}" and its files.`); } else say(`Unregistered "${name}"; its files are still in ${layout(name).dir}.`); } /** Copies osu!'s remembered login from another instance's tourney folder. */ async function copyLogin(name: string, from: string) { const source = layout(from); const target = layout(name); await loadConfig(from); const login = await readSavedLogin(source.osu); if (!login.saved) throw new Error(`Instance "${from}" has no saved login to copy.`); if (await running(name)) throw new Error(`Stop "${name}" first: vash-broadcast stop --instance ${name}`); for (const file of (await readdir(source.osu)).filter((each) => /^osu!\..+\.cfg$/i.test(each))) await copyFile(join(source.osu, file), join(target.osu, file)); const copied = await readSavedLogin(target.osu); if (!copied.saved) throw new Error('The login did not copy; use vash-broadcast login without --copy-from.'); say(`Copied the remembered login (${copied.username}) from "${from}" to "${name}".`); say('If osu!tourney still asks to sign in, run vash-broadcast login without --copy-from.'); } async function login(name: string) { if (args['copy-from']) return copyLogin(name, args['copy-from']); requireWindows('login'); const paths = layout(name); await loadConfig(name); if (await running(name)) throw new Error( `Stop the worker first (signing in closes osu!tourney): vash-broadcast stop --instance ${name}` ); await requireDesktop(); await rm(paths.login, { force: true }); await writeJson(paths.request, { mode: 'login', at: Date.now() }); await runTask(name); let state: SignInState | null = null; for (let waited = 0; waited < 60 && state?.state !== 'waiting'; waited++) { await Bun.sleep(1000); state = await readJson(paths.login); if (state?.state === 'failed') throw new Error(state.message); } if (!state) throw new Error(`The sign-in did not start. See: vash-broadcast logs --instance ${name}`); const user = process.env.USERNAME ?? 'you'; say(`osu! is open on ${hostname()}'s screen for the one-time spectator sign-in. On your Mac, open a tunnel (leave it running): ssh -N -L ${state.port}:127.0.0.1:${state.port} ${user}@${hostname().toLowerCase()} Then open this link in your browser: http://127.0.0.1:${state.port}/#${state.token} Sign in with the spectator account (it needs osu!supporter for osu!tourney), tick Remember username and Remember password, then press "I have signed in: finish". `); let last = ''; for (;;) { await Bun.sleep(2000); const next = await readJson(paths.login); if (!next) continue; if (next.message !== last) say(next.message); last = next.message; if (next.state === 'done') { say(`\nNext: vash-broadcast start --instance ${name}`); return; } if (next.state === 'failed') { process.exitCode = 1; return; } } } // ── main ──────────────────────────────────────────────────────────────── async function main() { if (args.version) return say(VERSION); if (args.help || !command) return say(HELP); switch (command) { case 'setup': return setup(); case 'login': return login(instanceName()); case 'start': return start(instanceName()); case 'stop': if (args.all) { for (const config of await listInstances()) await stop(config.name); return; } return stop(instanceName()); case 'restart': if (args.all) { const configs = await listInstances(); for (const config of configs) if (await running(config.name)) { await stop(config.name); await start(config.name); } return; } await stop(instanceName()); return start(instanceName()); case 'status': return status(instanceName()); case 'logs': return logs(instanceName()); case 'snapshot': return snapshot(instanceName()); case 'doctor': return doctor(instanceName()); case 'list': return list(); case 'remove': return remove(instanceName()); // Started by the scheduled task, inside the desktop session: case 'task': process.exitCode = await runWatchdog(instanceName()); return; case 'worker': process.exit(await runWorker(instanceName())); return; default: throw new UsageError(`Unknown command "${command}"`); } } try { await main(); } catch (err) { console.error( err instanceof UsageError ? `${err.message}\n\nRun vash-broadcast --help for usage.` : `Error: ${(err as Error).message}` ); process.exit(1); }