// Fernsteuerung fuer OBS, Hub-Seite. // // OBS laeuft auf dem Stream-PC, das Pult am Handy spricht mit dem Hub. Der // Hub kommt an den Stream-PC nicht heran - der steht hinter einem fremden // Router. Also waehlt sich der PC beim Hub ein, nicht umgekehrt: // // Handy --POST /pult/obs--> Hub --SSE /fern/befehle--> Stream-PC --> OBS // Handy <--SSE /events---- Hub <--POST /fern/stand---- Stream-PC // // Dieselbe Bauform wie bei den Browser-Sources (SSE statt WebSocket, siehe // README), nur mit einem eigenen Schluessel: der fuer OBS darf nur lesen, // dieser hier schaltet Szenen um. // // Jeder Befehl wartet auf eine Antwort vom PC. Ein Knopf, der "erledigt" // meldet, waehrend OBS gar nicht erreichbar war, ist am Handy schlimmer als // einer, der ehrlich sagt, dass es nicht ging. import type http from 'node:http'; import crypto from 'node:crypto'; import { logger } from '#core/logger.ts'; import type { Bus, ObsStandEvent } from './bus.ts'; const log = logger('overlay:fern'); const HEARTBEAT_MS = 15_000; const ANTWORT_MS = 6_000; export type FernBefehl = | { art: 'szene'; szene: string } | { art: 'clip' } | { art: 'stumm'; quelle: string; stumm: boolean }; export type FernAntwort = { ok: boolean; text: string }; export type Fernsteuerung = { /** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */ annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void; /** Stand von OBS, wie ihn der PC meldet. */ melden: (roh: unknown) => void; antwort: (roh: unknown) => void; senden: (b: FernBefehl) => Promise; verbunden: () => boolean; }; const LEER: Omit = { pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false, }; /** Was vom PC kommt, auf die erwartete Form bringen. Fremdes faellt weg. */ export function standAus(roh: unknown): Omit { const r = (roh && typeof roh === 'object' ? roh : {}) as Record; const text = (v: unknown): string | null => (typeof v === 'string' && v.length <= 200 ? v : null); const szenen = Array.isArray(r['szenen']) ? r['szenen'].map(text).filter((s): s is string => s !== null).slice(0, 100) : []; const ton = Array.isArray(r['ton']) ? r['ton'].flatMap((t) => { const name = text((t as Record)?.['name']); return name === null ? [] : [{ name, stumm: (t as Record)['stumm'] === true }]; }).slice(0, 50) : []; return { obs: r['obs'] === true, szenen, aktuell: text(r['aktuell']), ton, puffer: r['puffer'] === true, }; } export function createFernsteuerung(bus: Bus): Fernsteuerung { let leitung: http.ServerResponse | null = null; const offen = new Map void; timer: NodeJS.Timeout }>(); const veroeffentlichen = (stand: Omit): void => { bus.publish({ kind: 'obs', ...stand }); }; return { annehmen(req, res) { if (leitung) { // Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf. // Der neuere gewinnt, meist ist der alte ohnehin eine tote Leitung. log.info('Stream-PC hat sich neu verbunden, alte Leitung geschlossen'); leitung.end(); } res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive', 'x-accel-buffering': 'no', }); res.write('retry: 2000\n\n'); leitung = res; log.info('Stream-PC verbunden'); veroeffentlichen({ ...LEER, pc: true }); const beat = setInterval( () => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`), HEARTBEAT_MS); beat.unref(); const ende = (): void => { clearInterval(beat); if (leitung !== res) return; // schon durch eine neuere ersetzt leitung = null; log.info('Stream-PC getrennt'); veroeffentlichen(LEER); for (const [id, o] of offen) { clearTimeout(o.timer); o.fertig({ ok: false, text: 'Verbindung zum Stream-PC abgerissen.' }); offen.delete(id); } }; req.on('close', ende); res.on('error', ende); }, melden(roh) { if (!leitung) return; veroeffentlichen({ pc: true, ...standAus(roh) }); }, antwort(roh) { const r = (roh && typeof roh === 'object' ? roh : {}) as Record; const id = typeof r['id'] === 'string' ? r['id'] : ''; const o = offen.get(id); if (!o) return; offen.delete(id); clearTimeout(o.timer); o.fertig({ ok: r['ok'] === true, text: typeof r['text'] === 'string' ? r['text'].slice(0, 300) : '', }); }, senden(b) { const res = leitung; if (!res) { return Promise.resolve({ ok: false, text: 'Der Stream-PC ist nicht verbunden. Läuft dort abdulive?', }); } const id = crypto.randomBytes(6).toString('hex'); return new Promise((fertig) => { const timer = setTimeout(() => { offen.delete(id); fertig({ ok: false, text: 'Der Stream-PC hat nicht geantwortet.' }); }, ANTWORT_MS); offen.set(id, { fertig, timer }); res.write(`data: ${JSON.stringify({ id, ...b })}\n\n`); }); }, verbunden: () => leitung !== null, }; }