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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
215
apps/api/src/live-monitor/poller/poller.ts
Normal file
215
apps/api/src/live-monitor/poller/poller.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue