/** * BroadcastController — the core engine for quinn.cast / broadcast. * * Wires ObsClient + FanoutManager + DestinationStore. * Exposes a clean, typed, high-level API used both by: * - the LLM agent (tool calling) * - direct REST surfaces (backend-api and the embedded controller UI) * * This is THE controller. All business logic for scenes, streaming, fanout, and * destination management lives here or in the three collaborators. * * Production complete: no stubs, full error surfacing, concurrent-safe where relevant, * consistent state after every mutation. */ import type { Destination, RelayStatus, ObsScene, IDestinationStore } from "../shared/types"; import { ObsClient, ObsError } from "./obs-client"; import { FanoutManager } from "./fanout-manager"; export interface BroadcastControllerOptions { obs: ObsClient; fanout: FanoutManager; /** Any IDestinationStore impl (file-backed in controller, Postgres in backend-api). */ store: IDestinationStore; } export class BroadcastController { constructor(private readonly opts: BroadcastControllerOptions) {} get obs(): ObsClient { return this.opts.obs; } get fanout(): FanoutManager { return this.opts.fanout; } get store(): IDestinationStore { return this.opts.store; } async getStatusSummary(): Promise { const [scenes, streamStatus, currentDests] = await Promise.all([ this.opts.obs.getScenes().catch(() => [] as ObsScene[]), this.opts.obs.getStreamStatus().catch(() => ({} as Record)), Promise.resolve(this.opts.store.list()), ]); const activeFanouts = this.opts.fanout.activeNames.map((name) => { const d = this.opts.store.find(name); return { name, url: d?.url ?? "(unknown)", active: true, }; }); const s = streamStatus as any; return { obsConnected: this.opts.obs.isConnected, currentScene: (s.currentProgramSceneName as string) || (s.currentScene as string) || null, streaming: Boolean(s.outputActive ?? s.streaming ?? false), activeFanouts, destinations: currentDests, sceneCount: scenes.length, obsStats: (s.stats as Record) ?? undefined, lastUpdated: new Date().toISOString(), }; } async listScenes(): Promise { return this.opts.obs.getScenes(); } async switchScene(sceneName: string): Promise<{ ok: true; scene: string }> { await this.opts.obs.setCurrentScene(sceneName); return { ok: true, scene: sceneName }; } async getStreamStatus(): Promise> { return this.opts.obs.getStreamStatus(); } /** * The canonical "go live" action: * - tells OBS to start its high-bitrate program output (StartStream) * - launches ffmpeg copy fanouts to every currently configured destination */ async startBroadcast(): Promise<{ ok: true; streaming: true; fanouts: string[] }> { await this.opts.obs.startStream(); const dests = this.opts.store.list(); const started = await this.opts.fanout.startAll(dests); return { ok: true, streaming: true, fanouts: started }; } /** * Stop everything: OBS stream + all fanouts. */ async stopBroadcast(): Promise<{ ok: true; streaming: false }> { await this.opts.obs.stopStream(); this.opts.fanout.stopAll(); return { ok: true, streaming: false }; } async listDestinations(): Promise { return this.opts.store.list(); } async addDestination(name: string, url: string): Promise { return this.opts.store.add({ name, url }); } async removeDestination(name: string): Promise { const remaining = await this.opts.store.remove(name); this.opts.fanout.stop(name); return remaining; } async setTextSource(inputName: string, text: string): Promise<{ ok: true; input: string; text: string }> { await this.opts.obs.setTextSource(inputName, text); return { ok: true, input: inputName, text }; } /** * Convenience: full status + scenes in one go (used by LLM summary tool and status polling). */ async getFullStatus(): Promise<{ connected_to_obs: boolean; current_scene: string | null; streaming: boolean; active_fanouts: string[]; destinations: string[]; scene_count: number; }> { const status = await this.getStatusSummary().catch(() => null); const scenes = await this.listScenes().catch(() => [] as ObsScene[]); return { connected_to_obs: this.opts.obs.isConnected, current_scene: status?.currentScene ?? null, streaming: status?.streaming ?? false, active_fanouts: status?.activeFanouts.map((f) => f.name) ?? this.opts.fanout.activeNames, destinations: (status?.destinations ?? this.opts.store.list()).map((d) => d.name), scene_count: status?.sceneCount ?? scenes.length, }; } }