lilith-platform.live/codebase/@features/broadcast/controller/broadcast-controller.ts
Natalie d77a63d7ff feat(broadcast): controller + destination-store + types updates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:35:17 -04:00

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,
};
}
}