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; cancel: (timer: ReturnType) => void; resolveSourceId: (source: PollSource) => Promise; makeDriver: (source: PollSource, intervalMs: number) => Promise; } 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; 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 { 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 { // 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 }>( "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(); private readonly sourceIds = new Map(); private timer: ReturnType | undefined; private active: Promise | undefined; private running = false; constructor( @Inject(LiveMonitorService) private readonly monitor: LiveMonitorService, @Optional() @Inject(MEDIA_POLLER_DEPENDENCIES) dependencies?: Partial, ) { this.config = { ...defaults(), ...dependencies }; } onApplicationBootstrap(): void { if (this.config.enabled) this.start(); } async onApplicationShutdown(): Promise { 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 { 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)}`); } } } }