146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
/**
|
|
* 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<RelayStatus> {
|
|
const [scenes, streamStatus, currentDests] = await Promise.all([
|
|
this.opts.obs.getScenes().catch(() => [] as ObsScene[]),
|
|
this.opts.obs.getStreamStatus().catch(() => ({} as Record<string, unknown>)),
|
|
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<string, unknown>) ?? undefined,
|
|
lastUpdated: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
async listScenes(): Promise<ObsScene[]> {
|
|
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<Record<string, unknown>> {
|
|
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<Destination[]> {
|
|
return this.opts.store.list();
|
|
}
|
|
|
|
async addDestination(name: string, url: string): Promise<Destination[]> {
|
|
return this.opts.store.add({ name, url });
|
|
}
|
|
|
|
async removeDestination(name: string): Promise<Destination[]> {
|
|
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,
|
|
};
|
|
}
|
|
}
|