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:
716
streamer/windows/cli.ts
Normal file
716
streamer/windows/cli.ts
Normal file
@@ -0,0 +1,716 @@
|
||||
#!/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 <id> --token <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<WorkerState>(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<Runtime>(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<string, Record<string, unknown>>;
|
||||
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<WorkerState>(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<string | [boolean | null, string]>) => {
|
||||
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<SignInState>(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<SignInState>(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);
|
||||
}
|
||||
Reference in New Issue
Block a user