T-HUB/apps/api/src/live/go-live.service.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

171 lines
8.4 KiB
TypeScript

import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException, Optional } from "@nestjs/common";
import type { PoolClient } from "pg";
import { withTenant } from "../db/client.js";
import { LiveDestinationRepository, type OutputSummary } from "./live-destination.repository.js";
import { createGenericHlsResolver, GENERIC_HLS_PROVIDER, type LiveSourceResolver } from "./live-source-resolver.js";
export type TenantTransaction = <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
/**
* The media-plane target a go-live spawns: everything the relay needs, with
* raw secret material held only inside this service's resolution path.
*/
export interface ResolvedOutputTarget {
output: OutputSummary;
/** Ingest endpoint for the push (destination's, or null for a disabled/generic-missing output). */
endpointUrl: string;
/** True when the credential came through a destination (vs the output's own handle). */
throughDestination: boolean;
/** Vault handle the worker's VaultHandleBroker redeems at relay start. */
credentialHandleId: string | null;
}
/** Resolved program input for the take/start path. */
export interface ResolvedProgramSource {
playbackHlsUrl: string;
live: boolean;
provider: string;
}
export interface GoLiveDependencies {
transaction: TenantTransaction;
/** Provider-keyed resolvers; connectors register their own, generic-hls is built in. */
sourceResolvers: Map<string, LiveSourceResolver>;
}
export const GO_LIVE_DEPENDENCIES = Symbol("GO_LIVE_DEPENDENCIES");
/**
* Go-live wiring (WP6/WP9): take/start resolves StreamOutput ->
* LiveStreamDestination -> credential via the vault (same pattern as the
* publish credential accessor), and resolves the program source through
* connector-level resolvers. Nothing here mutates Redis or signs commands;
* callers feed the result into MediaControlService.setDesired/director.
*/
@Injectable()
export class GoLiveService {
private readonly resolvers: Map<string, LiveSourceResolver>;
private readonly transaction: TenantTransaction;
constructor(
@Inject(LiveDestinationRepository) private readonly destinations: LiveDestinationRepository,
@Optional() @Inject(GO_LIVE_DEPENDENCIES) dependencies?: Partial<GoLiveDependencies>,
) {
const defaults: GoLiveDependencies = {
transaction: withTenant,
sourceResolvers: new Map([[GENERIC_HLS_PROVIDER, createGenericHlsResolver()]]),
};
const merged = { ...defaults, ...dependencies };
this.resolvers = merged.sourceResolvers;
this.transaction = merged.transaction;
}
registerSourceResolver(provider: string, resolver: LiveSourceResolver): void {
this.resolvers.set(provider, resolver);
}
/**
* Resolve every enabled output of a channel into media-plane targets.
* Destination-referenced outputs resolve their credential handle through the
* destination (aggregate boundary); handle outputs pass theirs through
* untouched. The raw stream key never enters this surface — the worker
* redeems the handle at relay start.
*/
async resolveOutputs(workspaceId: string, virtualChannelId: string): Promise<ResolvedOutputTarget[]> {
const outputs = await this.listChannelOutputs(workspaceId, virtualChannelId);
const targets: ResolvedOutputTarget[] = [];
for (const output of outputs) {
if (!output.enabled) continue;
if (output.destinationId !== null) {
const destination = await this.destinations.get(workspaceId, output.destinationId);
if (destination.credentialId === null) {
throw new ConflictException(`destination ${destination.label} has no ingest credential`);
}
if (destination.credentialRevokedAt !== null) {
throw new ConflictException(`destination ${destination.label} ingest credential is revoked`);
}
targets.push({
output,
endpointUrl: destination.endpointUrl,
throughDestination: true,
credentialHandleId: destination.credentialId,
});
continue;
}
const handle = output.credentialHandleId;
if (handle === null) throw new ConflictException(`stream output ${output.id} has neither destination nor credential handle`);
targets.push({ output, endpointUrl: "", throughDestination: false, credentialHandleId: handle });
}
return targets;
}
/**
* Take/start resolution of the program source for a virtual channel source
* row: provider-keyed resolvers (twitch via Helix, generic-hls direct) map
* {channelId | externalUrl} to {playbackHlsUrl, live}.
*/
async resolveProgramSource(workspaceId: string, request: { provider: string; channelId?: string; externalUrl?: string }): Promise<ResolvedProgramSource> {
if (request.provider === "") throw new BadRequestException("provider is required");
const resolver = this.resolvers.get(request.provider);
if (resolver === undefined) throw new NotFoundException(`no live source resolver for provider ${request.provider}`);
const resolution = await resolver({ channelId: request.channelId, externalUrl: request.externalUrl });
if (resolution.playbackHlsUrl === null) {
throw new ConflictException(`live source for ${request.provider} is not live`);
}
return { playbackHlsUrl: resolution.playbackHlsUrl, live: resolution.live, provider: request.provider };
}
/**
* Redeem the destination ingest credential (vault.open with tenant + handle
* AAD) for a destination-referenced output. Called only by the relay spawn
* path; the returned secret never reaches logs, audit, or HTTP responses.
*/
async redeemDestinationCredential(workspaceId: string, destinationId: string): Promise<{ endpointUrl: string; streamKey: string; provider: string }> {
return this.destinations.resolveIngestCredential(workspaceId, destinationId);
}
/**
* Take/start: build a DesiredSpec for the go-live flow from the channel's
* enabled StreamOutputs and a resolved program source. Destination-referenced
* outputs contribute the destination's endpoint + credential handle (resolved
* through the vault at relay start); a single-output channel maps onto the
* v1 spec shape MediaControlService.setDesired expects. Channels with
* multiple enabled outputs are a multi-fan-out concern of the worker relay
* (WP9) and resolve per-output there.
*/
async desiredSpecForGoLive(workspaceId: string, virtualChannelId: string, source: { provider: string; channelId?: string; externalUrl?: string },
options?: { profile?: "720p30" | "1080p30" | "1080p60"; mode?: "passthrough" | "normalize" }): Promise<{
desiredStatus: "RUNNING";
source: { kind: "hls"; url: string };
output: { endpointUrl: string; credentialHandleId: string };
profile: "720p30" | "1080p30" | "1080p60";
mode: "passthrough" | "normalize";
}> {
const [program, outputs] = await Promise.all([
this.resolveProgramSource(workspaceId, source),
this.resolveOutputs(workspaceId, virtualChannelId),
]);
if (outputs.length === 0) throw new ConflictException("no enabled stream outputs for this channel");
const target = outputs[0]!;
return {
desiredStatus: "RUNNING",
source: { kind: "hls", url: program.playbackHlsUrl },
output: { endpointUrl: target.endpointUrl, credentialHandleId: target.credentialHandleId ?? "" },
profile: options?.profile ?? (target.output.profile as "720p30" | "1080p30" | "1080p60"),
mode: options?.mode ?? (target.output.mode as "passthrough" | "normalize"),
};
}
private async listChannelOutputs(workspaceId: string, virtualChannelId: string): Promise<OutputSummary[]> {
return this.transaction(workspaceId, async (client) => {
const result = await client.query("SELECT * FROM stream_outputs WHERE workspace_id = $1 AND virtual_channel_id = $2 ORDER BY created_at", [workspaceId, virtualChannelId]);
return result.rows.map((row) => ({
id: String(row.id), workspaceId: String(row.workspace_id), virtualChannelId: String(row.virtual_channel_id),
label: String(row.label), profile: String(row.profile), mode: String(row.mode), enabled: Boolean(row.enabled),
destinationId: row.destination_id === null ? null : String(row.destination_id),
credentialHandleId: row.credential_handle_id === null ? null : String(row.credential_handle_id),
createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at),
}));
});
}
}