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/.
215 lines
9 KiB
TypeScript
215 lines
9 KiB
TypeScript
import { Inject, Injectable, Logger, NotFoundException, OnApplicationBootstrap, OnApplicationShutdown, Optional } from "@nestjs/common";
|
|
import { withTenant } from "../../db/client.js";
|
|
import { LiveMonitorService } from "../live-monitor.service.js";
|
|
import type { PollDriver } from "./driver.js";
|
|
import { MockPollDriver } from "./mock-driver.js";
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
import { EgressPeerTubeProbeClient, PeerTubePollDriver } from "./peertube-driver.js";
|
|
import { monitorEgress } from "./egress.js";
|
|
import { HlsProbeDriver } from "./hls-driver.js";
|
|
import { BackoffPollDriver } from "./backoff-driver.js";
|
|
|
|
export interface PollSource {
|
|
provider: string;
|
|
sourceRef: string;
|
|
workspaceId: string;
|
|
connectionId: string;
|
|
scenario?: string;
|
|
scenarioSource?: "A" | "B" | "C";
|
|
playbackUrl?: string;
|
|
}
|
|
|
|
export interface PollerDependencies {
|
|
sources: PollSource[];
|
|
intervalMs: number;
|
|
enabled: boolean;
|
|
random: () => number;
|
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
cancel: (timer: ReturnType<typeof setTimeout>) => void;
|
|
resolveSourceId: (source: PollSource) => Promise<string>;
|
|
makeDriver: (source: PollSource, intervalMs: number) => Promise<PollDriver>;
|
|
}
|
|
|
|
export const MEDIA_POLLER_DEPENDENCIES = Symbol("MEDIA_POLLER_DEPENDENCIES");
|
|
|
|
export function jitteredInterval(intervalMs: number, random: number): number {
|
|
if (!Number.isFinite(intervalMs) || intervalMs <= 0) throw new Error("poll interval must be positive");
|
|
if (!Number.isFinite(random) || random < 0 || random > 1) throw new Error("random must be between 0 and 1");
|
|
return Math.round(intervalMs * (0.8 + random * 0.4));
|
|
}
|
|
|
|
function requiredString(value: unknown, name: string): string {
|
|
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
export function parsePollSources(raw: string | undefined): PollSource[] {
|
|
if (raw === undefined || raw.trim() === "") return [];
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) throw new Error("MEDIA_POLLER_SOURCES must be a JSON array");
|
|
return parsed.map((entry: unknown, index): PollSource => {
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
throw new Error(`MEDIA_POLLER_SOURCES[${index}] must be an object`);
|
|
}
|
|
const row = entry as Record<string, unknown>;
|
|
const source: PollSource = {
|
|
provider: requiredString(row.provider, "provider"),
|
|
sourceRef: requiredString(row.sourceRef, "sourceRef"),
|
|
workspaceId: requiredString(row.workspaceId, "workspaceId"),
|
|
connectionId: requiredString(row.connectionId, "connectionId"),
|
|
};
|
|
if (row.scenario !== undefined) source.scenario = requiredString(row.scenario, "scenario");
|
|
if (row.playbackUrl !== undefined) source.playbackUrl = requiredString(row.playbackUrl, "playbackUrl");
|
|
if (row.scenarioSource !== undefined) {
|
|
if (row.scenarioSource !== "A" && row.scenarioSource !== "B" && row.scenarioSource !== "C") {
|
|
throw new Error("scenarioSource must be A, B, or C");
|
|
}
|
|
source.scenarioSource = row.scenarioSource;
|
|
}
|
|
return source;
|
|
});
|
|
}
|
|
|
|
async function resolveSourceId(source: PollSource): Promise<string> {
|
|
return withTenant(source.workspaceId, async (client) => {
|
|
const result = await client.query(
|
|
"SELECT id FROM source_monitor_states WHERE connection_id = $1 AND remote_ref = $2",
|
|
[source.connectionId, source.sourceRef],
|
|
);
|
|
if (result.rowCount !== 1) throw new NotFoundException(`monitored source ${source.sourceRef} not found`);
|
|
return String(result.rows[0]?.id);
|
|
});
|
|
}
|
|
|
|
async function makeDriver(source: PollSource, intervalMs: number): Promise<PollDriver> {
|
|
// Provider routing lives here until the connector registry exposes monitor probes.
|
|
if (source.provider === "mock-livestream") {
|
|
return MockPollDriver.create({
|
|
scenario: source.scenario ?? "single-live", source: source.scenarioSource ?? "A",
|
|
sourceRef: source.sourceRef, workspaceId: source.workspaceId, connectionId: source.connectionId,
|
|
tickMs: intervalMs,
|
|
});
|
|
}
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
if (source.provider === "peertube" || source.provider === "hls") {
|
|
const egress = await monitorEgress();
|
|
const drivers: PollDriver[] = [];
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
if (source.provider === "peertube") {
|
|
const fields = await withTenant(source.workspaceId, async (client) => {
|
|
const result = await client.query<{ fields: Record<string, unknown> }>(
|
|
"SELECT fields FROM connections WHERE id = $1 AND workspace_id = $2 AND disconnected_at IS NULL",
|
|
[source.connectionId, source.workspaceId]);
|
|
if (result.rowCount !== 1) throw new NotFoundException("monitor connection not found");
|
|
return result.rows[0]!.fields;
|
|
});
|
|
const instanceUrl = requiredString(fields.instanceUrl, "connection instanceUrl");
|
|
const videoId = source.sourceRef.startsWith("video:")
|
|
? source.sourceRef.slice(6).split("@")[0]! : source.sourceRef;
|
|
drivers.push(new BackoffPollDriver(new PeerTubePollDriver(videoId,
|
|
new EgressPeerTubeProbeClient(instanceUrl, egress)), "api-poll"));
|
|
}
|
|
if (source.playbackUrl) drivers.push(new BackoffPollDriver(new HlsProbeDriver(source.playbackUrl, egress), "hls-probe"));
|
|
if (!drivers.length) throw new Error("HLS monitor source requires playbackUrl");
|
|
return { poll: async () => {
|
|
const observations = [];
|
|
for (const driver of drivers) observations.push(...await driver.poll());
|
|
return observations;
|
|
} };
|
|
}
|
|
throw new Error(`no live poll driver for provider ${source.provider}`);
|
|
}
|
|
|
|
function defaults(): PollerDependencies {
|
|
const interval = Number(process.env.MEDIA_POLLER_INTERVAL_MS ?? 10_000);
|
|
if (!Number.isFinite(interval) || interval <= 0) throw new Error("MEDIA_POLLER_INTERVAL_MS must be positive");
|
|
const enabled = process.env.MEDIA_POLLER_ENABLED === undefined
|
|
? process.env.NODE_ENV !== "production" : process.env.MEDIA_POLLER_ENABLED === "true";
|
|
return {
|
|
sources: parsePollSources(process.env.MEDIA_POLLER_SOURCES), intervalMs: interval, enabled,
|
|
random: Math.random, schedule: setTimeout, cancel: clearTimeout, resolveSourceId, makeDriver,
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class LiveMonitorPoller implements OnApplicationBootstrap, OnApplicationShutdown {
|
|
private readonly logger = new Logger(LiveMonitorPoller.name);
|
|
private readonly config: PollerDependencies;
|
|
private readonly drivers = new Map<string, PollDriver>();
|
|
private readonly sourceIds = new Map<string, string>();
|
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
private active: Promise<void> | undefined;
|
|
private running = false;
|
|
|
|
constructor(
|
|
@Inject(LiveMonitorService) private readonly monitor: LiveMonitorService,
|
|
@Optional() @Inject(MEDIA_POLLER_DEPENDENCIES) dependencies?: Partial<PollerDependencies>,
|
|
) {
|
|
this.config = { ...defaults(), ...dependencies };
|
|
}
|
|
|
|
onApplicationBootstrap(): void {
|
|
if (this.config.enabled) this.start();
|
|
}
|
|
|
|
async onApplicationShutdown(): Promise<void> {
|
|
await this.stop();
|
|
}
|
|
|
|
get isRunning(): boolean { return this.running; }
|
|
|
|
start(): { running: boolean } {
|
|
if (this.running) return { running: true };
|
|
this.running = true;
|
|
this.schedule(0);
|
|
return { running: true };
|
|
}
|
|
|
|
async stop(): Promise<{ running: boolean }> {
|
|
this.running = false;
|
|
if (this.timer !== undefined) this.config.cancel(this.timer);
|
|
this.timer = undefined;
|
|
await this.active;
|
|
return { running: false };
|
|
}
|
|
|
|
private schedule(delayMs: number): void {
|
|
if (!this.running) return;
|
|
this.timer = this.config.schedule(() => {
|
|
this.timer = undefined;
|
|
this.active = this.pollOnce().catch((error: unknown) => {
|
|
this.logger.error(`poll tick failed: ${String(error)}`);
|
|
}).finally(() => {
|
|
this.active = undefined;
|
|
this.schedule(jitteredInterval(this.config.intervalMs, this.config.random()));
|
|
});
|
|
}, delayMs);
|
|
}
|
|
|
|
/** One serialized tick; each source fails independently. */
|
|
async pollOnce(): Promise<void> {
|
|
for (const source of this.config.sources) {
|
|
if (!this.running) return;
|
|
const key = `${source.workspaceId}:${source.connectionId}:${source.sourceRef}`;
|
|
try {
|
|
let driver = this.drivers.get(key);
|
|
if (driver === undefined) {
|
|
driver = await this.config.makeDriver(source, this.config.intervalMs);
|
|
this.drivers.set(key, driver);
|
|
}
|
|
let sourceId = this.sourceIds.get(key);
|
|
if (sourceId === undefined) {
|
|
sourceId = await this.config.resolveSourceId(source);
|
|
this.sourceIds.set(key, sourceId);
|
|
}
|
|
const observations = await driver.poll();
|
|
for (const observation of observations) {
|
|
if (!this.running) return;
|
|
await this.monitor.recordObservation(source.workspaceId, sourceId, observation);
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(`poll failed for ${key}: ${String(error)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|