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

213 lines
6.3 KiB
TypeScript

/**
* Settings for an instance's portable OBS, written while OBS is closed:
* no first-run wizard or crash dialog, websocket on the instance's port with
* its own password, and a "Vash" profile with the channel's size, frame rate
* and bitrate, AMD AMF hardware encoding, and the local relay as its server.
* Keys OBS wrote itself are kept.
*/
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
export type Ini = Map<string, Map<string, string>>;
export function parseIni(text: string): Ini {
const ini: Ini = new Map();
let section = new Map<string, string>();
ini.set('', section);
for (const raw of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith(';') || line.startsWith('#')) continue;
const header = /^\[(.+)\]$/.exec(line);
if (header) {
section = ini.get(header[1]) ?? new Map();
ini.set(header[1], section);
continue;
}
const eq = line.indexOf('=');
if (eq > 0) section.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
}
return ini;
}
export function stringifyIni(ini: Ini) {
const parts: string[] = [];
for (const [name, section] of ini) {
if (!section.size) continue;
if (name) parts.push(`[${name}]`);
for (const [key, value] of section) parts.push(`${key}=${value}`);
parts.push('');
}
return parts.join('\r\n');
}
export function setIni(
ini: Ini,
values: Record<string, Record<string, string | number | boolean>>
) {
for (const [name, entries] of Object.entries(values)) {
const section = ini.get(name) ?? new Map<string, string>();
ini.set(name, section);
for (const [key, value] of Object.entries(entries)) section.set(key, String(value));
}
return ini;
}
async function updateIni(
path: string,
values: Record<string, Record<string, string | number | boolean>>
) {
const existing = existsSync(path) ? await readFile(path, 'utf8') : '';
const next = stringifyIni(setIni(parseIni(existing), values));
await mkdir(join(path, '..'), { recursive: true });
if (next !== existing) await writeFile(path, next);
}
export const PROFILE = 'Vash';
export const COLLECTION = 'Vash';
export interface ObsSettings {
websocketPort: number;
websocketPassword: string;
width: number;
height: number;
fps: number;
bitrateKbps: number;
encoder: 'amd' | 'x264';
ingestUrl: string;
ingestKey: string;
}
/** Everything a fresh or existing portable OBS needs; safe to run before every start. */
export async function writeObsSettings(configDir: string, settings: ObsSettings) {
// Settings OBS reads from the app (global.ini) and user (user.ini) files;
// written to both so either OBS layout picks them up.
const general = {
General: {
FirstRun: true,
ConfirmOnExit: false,
WarnBeforeStartingStream: false,
WarnBeforeStoppingStream: false,
WarnBeforeStoppingRecord: false,
EnableAutoUpdates: false,
OpenStatsOnStartup: false
},
BasicWindow: {
PreviewEnabled: false,
SysTrayEnabled: true,
SysTrayWhenStarted: true,
SysTrayMinimizeToTray: true
},
Basic: {
Profile: PROFILE,
ProfileDir: PROFILE,
SceneCollection: COLLECTION,
SceneCollectionFile: COLLECTION,
ConfigOnNewProfile: false
}
};
await updateIni(join(configDir, 'user.ini'), general);
await updateIni(join(configDir, 'global.ini'), general);
const profile = join(configDir, 'basic', 'profiles', PROFILE);
await updateIni(join(profile, 'basic.ini'), {
General: { Name: PROFILE },
Output: {
Mode: 'Simple',
Reconnect: true,
RetryDelay: 2,
MaxRetries: 10000,
DelayEnable: false
},
SimpleOutput: {
StreamEncoder: settings.encoder,
VBitrate: settings.bitrateKbps,
ABitrate: 160,
UseAdvanced: false,
StreamAudioEncoder: 'aac',
Preset: 'veryfast',
AMDPreset: 'quality',
RecQuality: 'Stream'
},
Stream1: { IgnoreRecommended: true },
Video: {
BaseCX: settings.width,
BaseCY: settings.height,
OutputCX: settings.width,
OutputCY: settings.height,
FPSType: 1,
FPSInt: settings.fps,
ScaleType: 'bicubic',
ColorFormat: 'NV12',
ColorSpace: '709',
ColorRange: 'Partial'
},
Audio: { SampleRate: 48000, ChannelSetup: 'Stereo' }
});
await writeFile(
join(profile, 'service.json'),
JSON.stringify({
type: 'rtmp_custom',
settings: {
server: settings.ingestUrl,
key: settings.ingestKey,
use_auth: false,
bwtest: false
}
})
);
const websocket = join(configDir, 'plugin_config', 'obs-websocket');
await mkdir(websocket, { recursive: true });
await writeFile(
join(websocket, 'config.json'),
JSON.stringify({
first_load: false,
server_enabled: true,
server_port: settings.websocketPort,
alerts_enabled: false,
auth_required: true,
server_password: settings.websocketPassword
})
);
// A leftover run sentinel makes OBS ask about safe mode after a forced stop; the
// worker only force-stops its own OBS, so clear it.
const sentinel = join(configDir, '.sentinel');
for (const name of await readdir(sentinel).catch(() => [] as string[]))
if (name.startsWith('run_')) await rm(join(sentinel, name), { force: true });
}
/** Arguments for obs64.exe, started with its bin\64bit folder as the working directory. */
export const OBS_ARGS = [
'--portable',
'--multi',
'--disable-updater',
'--disable-missing-files-check',
'--minimize-to-tray'
];
/** Which video encoder the latest OBS log shows starting, if any. */
export function encoderFromLog(log: string): 'amd' | 'x264' | 'other' | null {
let latest: { index: number; kind: 'amd' | 'x264' | 'other' } | null = null;
const patterns: [RegExp, 'amd' | 'x264' | 'other'][] = [
[/\[(texture|fallback)-amf-(h264|h265|av1): /g, 'amd'],
[/\[x264 encoder: /g, 'x264'],
[/\[(obs_nvenc|jim-nvenc|NVENC|qsv|obs-qsv11)[^\]]*: /gi, 'other']
];
for (const [pattern, kind] of patterns)
for (const match of log.matchAll(pattern))
if (!latest || match.index! > latest.index) latest = { index: match.index!, kind };
return latest?.kind ?? null;
}
export async function latestObsLog(configDir: string) {
const dir = join(configDir, 'logs');
const names = (await readdir(dir).catch(() => [] as string[])).filter((name) =>
name.endsWith('.txt')
);
names.sort();
const latest = names.at(-1);
return latest ? readFile(join(dir, latest), 'utf8').catch(() => '') : '';
}