- 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>
213 lines
6.3 KiB
TypeScript
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(() => '') : '';
|
|
}
|