diff --git a/apps/cli/main.ts b/apps/cli/main.ts
index db3dfb2..a18139d 100644
--- a/apps/cli/main.ts
+++ b/apps/cli/main.ts
@@ -690,6 +690,51 @@ async function webPasswort(args: string[]): Promise {
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
}
+/**
+ * Den Stream-PC beim Hub anmelden. Prueft vorher, ob der Hub den Schluessel
+ * annimmt - ein Tippfehler soll hier auffallen und nicht erst im Stream, wenn
+ * der Szenenknopf am Handy nichts tut.
+ */
+async function fernVerbinden(args: string[]): Promise {
+ const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts');
+ const [adresse, schluessel] = args;
+ if (adresse === 'aus') {
+ updateSecrets((s) => { delete s.fern; });
+ console.log('Fernsteuerung getrennt. Wirkt nach dem naechsten Start von abdulive ingest.');
+ return;
+ }
+ if (!adresse || !schluessel) {
+ const da = readSecrets().fern;
+ console.log(da ? `Eingerichtet fuer ${da.url}.` : 'Nicht eingerichtet.');
+ console.log('Aufruf: abdulive fern:verbinden (steht auf der Uebersichtsseite)');
+ return;
+ }
+ const url = adresse.replace(/\/+$/, '');
+ if (!/^https?:\/\//.test(url)) throw new Error('Die Adresse muss mit https:// anfangen.');
+
+ const probe = new AbortController();
+ let status: number;
+ try {
+ const res = await fetch(`${url}/fern/befehle`, {
+ headers: { authorization: `Bearer ${schluessel}` },
+ signal: AbortSignal.any([probe.signal, AbortSignal.timeout(10_000)]),
+ });
+ status = res.status;
+ } catch (err) {
+ throw new Error(`Hub unter ${url} nicht erreichbar: ${(err as Error).message}`);
+ } finally {
+ // Die Leitung nur oeffnen, um den Schluessel zu pruefen. Offen gelassen
+ // haette sie der Hub als verbundenen PC gezaehlt.
+ probe.abort();
+ }
+ if (status === 403) throw new Error('Der Hub lehnt den Schluessel ab. Neu von der Uebersichtsseite kopieren.');
+ if (status !== 200) throw new Error(`Der Hub antwortet ${status}. Laeuft dort eine Fassung mit Pult?`);
+
+ updateSecrets((s) => { s.fern = { url, key: schluessel }; });
+ console.log(`${C.ok}Verbunden.${C.off} Der Hub nimmt den Schluessel an.`);
+ console.log('Jetzt abdulive ingest neu starten - danach steuert das Pult OBS.');
+}
+
// ---------------------------------------------------------------- golive
/**
@@ -901,6 +946,10 @@ abdulive - Werkzeugkasten fuer Abdus Stream
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
web:passwort [wort] Passwort fuer die Weboberflaeche setzen
+ fern:verbinden
+ Stream-PC beim Hub anmelden, damit das Pult OBS
+ steuern kann. Der Befehl steht fertig auf der
+ Uebersichtsseite des Hubs. "aus" statt Adresse trennt.
check-env Umgebung pruefen
db:migrate Migrationen anwenden
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
@@ -956,6 +1005,7 @@ try {
}
case 'overlay': await overlayOnly(args); break;
case 'web:passwort': await webPasswort(args); break;
+ case 'fern:verbinden': await fernVerbinden(args); break;
case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break;
default:
diff --git a/apps/ingest/src/fern.test.ts b/apps/ingest/src/fern.test.ts
new file mode 100644
index 0000000..10016ce
--- /dev/null
+++ b/apps/ingest/src/fern.test.ts
@@ -0,0 +1,69 @@
+// Die Leitung zum Hub, Seite des Stream-PCs.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import type { ObsClient } from '#obs/client.ts';
+import { befehlAus, sseZerleger, standLesen } from './fern.ts';
+
+test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
+ const z = sseZerleger();
+ assert.deepEqual(z('retry: 2000\n\n'), [], 'retry allein ist keine Nachricht');
+ assert.deepEqual(z('data: {"a"'), []);
+ assert.deepEqual(z(':1}\n\ndata: 2\n\nda'), ['{"a":1}', '2']);
+ assert.deepEqual(z('ta: 3\r\n\r\n'), ['3'], 'auch mit Windows-Zeilenenden');
+});
+
+test('nur bekannte Befehle mit ID werden ausgefuehrt', () => {
+ assert.deepEqual(befehlAus({ id: 'x', art: 'szene', szene: 'Pause' }), { id: 'x', art: 'szene', szene: 'Pause' });
+ assert.deepEqual(befehlAus({ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true }),
+ { id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true });
+ assert.equal(befehlAus({ art: 'clip' }), null, 'ohne ID gibt es niemanden, dem man antwortet');
+ assert.equal(befehlAus({ id: 'x', art: 'beat' }), null);
+ assert.equal(befehlAus({ id: 'x', art: 'StopStream' }), null);
+});
+
+function falschesObs(antworten: Record) => unknown>): ObsClient {
+ return {
+ run: async () => {},
+ isConnected: () => true,
+ request: async (type: string, data?: unknown): Promise => {
+ const f = antworten[type];
+ if (!f) throw new Error(`OBS lehnt ab (604): ${type}`);
+ return f((data ?? {}) as Record) as T;
+ },
+ };
+}
+
+test('Stand: Szenen in OBS-Reihenfolge, nur Eingaenge mit Ton', async () => {
+ const obs = falschesObs({
+ GetSceneList: () => ({
+ currentProgramSceneName: 'Gameplay',
+ // OBS zaehlt von unten: Index 0 steht im Fenster ganz unten.
+ scenes: [{ sceneName: 'Pause', sceneIndex: 0 }, { sceneName: 'Gameplay', sceneIndex: 2 },
+ { sceneName: 'Just Chatting', sceneIndex: 1 }],
+ }),
+ GetInputList: () => ({ inputs: [{ inputName: 'Mikro' }, { inputName: 'Facecam' }, { inputName: 'Desktop' }] }),
+ GetInputMute: (d) => {
+ if (d['inputName'] === 'Facecam') throw new Error('kein Ton');
+ return { inputMuted: d['inputName'] === 'Desktop' };
+ },
+ GetReplayBufferStatus: () => ({ outputActive: true }),
+ });
+ assert.deepEqual(await standLesen(obs), {
+ obs: true,
+ szenen: ['Gameplay', 'Just Chatting', 'Pause'],
+ aktuell: 'Gameplay',
+ ton: [{ name: 'Mikro', stumm: false }, { name: 'Desktop', stumm: true }],
+ puffer: true,
+ });
+});
+
+test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
+ const obs = falschesObs({
+ GetSceneList: () => ({ currentProgramSceneName: 'A', scenes: [{ sceneName: 'A', sceneIndex: 0 }] }),
+ GetInputList: () => ({ inputs: [] }),
+ });
+ const s = await standLesen(obs);
+ assert.equal(s.puffer, false);
+ assert.deepEqual(s.szenen, ['A']);
+});
diff --git a/apps/ingest/src/fern.ts b/apps/ingest/src/fern.ts
new file mode 100644
index 0000000..218aaf3
--- /dev/null
+++ b/apps/ingest/src/fern.ts
@@ -0,0 +1,247 @@
+// Fernsteuerung fuer OBS, Seite des Stream-PCs.
+//
+// Das Pult am Handy redet mit dem Hub, OBS laeuft hier. Der Hub kommt an
+// diesen Rechner nicht heran (fremder Router, keine Portfreigabe), also waehlt
+// sich der PC beim Hub ein und haelt die Leitung offen:
+//
+// GET /fern/befehle SSE, Befehle kommen herein (Hub -> PC)
+// POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub)
+// POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub)
+//
+// Gegenstueck: apps/overlay-server/src/fern.ts. Eingerichtet wird mit
+// `abdulive fern:verbinden `; beides steht fertig auf
+// der Uebersichtsseite des Hubs.
+
+import { logger } from '#core/logger.ts';
+import { forever, sleep } from '#core/retry.ts';
+import type { ObsClient } from '#obs/client.ts';
+
+const log = logger('fern');
+
+/** Der Hub schickt alle 15 s einen Herzschlag. Drei verpasste heisst: tot. */
+const TOT_NACH_MS = 45_000;
+
+/** Nach diesen OBS-Ereignissen stimmt der gemeldete Stand nicht mehr. */
+const STAND_EREIGNISSE = new Set([
+ 'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
+ 'SceneNameChanged', 'InputMuteStateChanged', 'InputCreated', 'InputRemoved',
+ 'InputNameChanged', 'ReplayBufferStateChanged',
+]);
+
+export type Stand = {
+ obs: boolean;
+ szenen: string[];
+ aktuell: string | null;
+ ton: Array<{ name: string; stumm: boolean }>;
+ puffer: boolean;
+};
+
+export type Befehl =
+ | { id: string; art: 'szene'; szene: string }
+ | { id: string; art: 'clip' }
+ | { id: string; art: 'stumm'; quelle: string; stumm: boolean };
+
+/**
+ * Zerlegt einen SSE-Strom in Nachrichten. Bekommt beliebige Stuecke - eine
+ * Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem
+ * Stueck stecken.
+ */
+export function sseZerleger(): (stueck: string) => string[] {
+ let rest = '';
+ return (stueck) => {
+ rest += stueck.replace(/\r\n?/g, '\n');
+ const fertig: string[] = [];
+ let i: number;
+ while ((i = rest.indexOf('\n\n')) !== -1) {
+ const block = rest.slice(0, i);
+ rest = rest.slice(i + 2);
+ const daten = block.split('\n')
+ .filter((z) => z.startsWith('data:'))
+ .map((z) => z.slice(5).replace(/^ /, ''));
+ if (daten.length) fertig.push(daten.join('\n'));
+ }
+ return fertig;
+ };
+}
+
+/** Was vom Hub kommt, pruefen. Unbekanntes wird nicht ausgefuehrt. */
+export function befehlAus(roh: unknown): Befehl | null {
+ if (!roh || typeof roh !== 'object') return null;
+ const r = roh as Record;
+ const id = typeof r['id'] === 'string' ? r['id'] : '';
+ if (!id) return null;
+ if (r['art'] === 'szene' && typeof r['szene'] === 'string') return { id, art: 'szene', szene: r['szene'] };
+ if (r['art'] === 'clip') return { id, art: 'clip' };
+ if (r['art'] === 'stumm' && typeof r['quelle'] === 'string') {
+ return { id, art: 'stumm', quelle: r['quelle'], stumm: r['stumm'] === true };
+ }
+ return null;
+}
+
+/** Szenen, aktuelle Szene, stummschaltbare Eingaenge und der Puffer. */
+export async function standLesen(obs: ObsClient): Promise {
+ const leer: Stand = { obs: false, szenen: [], aktuell: null, ton: [], puffer: false };
+ if (!obs.isConnected()) return leer;
+ try {
+ const liste = await obs.request<{
+ currentProgramSceneName: string;
+ scenes: Array<{ sceneName: string; sceneIndex: number }>;
+ }>('GetSceneList');
+ // OBS zaehlt von unten. Im Fenster steht die mit dem hoechsten Index oben,
+ // und in dieser Reihenfolge erwartet man sie auch am Handy.
+ const szenen = [...liste.scenes]
+ .sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName);
+
+ const eingaenge = await obs.request<{ inputs: Array<{ inputName: string }> }>('GetInputList');
+ // Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute
+ // antwortet, hat welchen; Bildquellen lehnen ab.
+ const stumm = await Promise.allSettled(eingaenge.inputs.map((e) =>
+ obs.request<{ inputMuted: boolean }>('GetInputMute', { inputName: e.inputName })
+ .then((r) => ({ name: e.inputName, stumm: r.inputMuted }))));
+ const ton = stumm.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));
+
+ const puffer = await obs.request<{ outputActive: boolean }>('GetReplayBufferStatus')
+ .then((r) => r.outputActive, () => false);
+
+ return { obs: true, szenen, aktuell: liste.currentProgramSceneName || null, ton, puffer };
+ } catch (err) {
+ log.debug(`Stand nicht gelesen: ${(err as Error).message}`);
+ return { ...leer, obs: obs.isConnected() };
+ }
+}
+
+export type FernDeps = {
+ url: string;
+ key: string;
+ obs: ObsClient;
+ /** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
+ clip: () => Promise<{ ok: boolean; reason?: string }>;
+};
+
+export type FernAnschluss = {
+ run: (signal: AbortSignal) => Promise;
+ /** Nach einem OBS-Ereignis rufen. Sammelt kurz, dann geht ein Stand raus. */
+ obsEreignis: (type: string) => void;
+ /** OBS verbunden oder getrennt. */
+ obsWechsel: () => void;
+ verbunden: () => boolean;
+};
+
+export function createFernanschluss(deps: FernDeps): FernAnschluss {
+ const basis = deps.url.replace(/\/+$/, '');
+ const kopf = { authorization: `Bearer ${deps.key}` };
+ let verbunden = false;
+ let sammler: NodeJS.Timeout | null = null;
+
+ async function post(pfad: string, body: unknown): Promise {
+ try {
+ const res = await fetch(`${basis}${pfad}`, {
+ method: 'POST',
+ headers: { ...kopf, 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!res.ok) log.warn(`${pfad}: Hub antwortet ${res.status}`);
+ } catch (err) {
+ log.debug(`${pfad} nicht zugestellt: ${(err as Error).message}`);
+ }
+ }
+
+ const standSenden = async (): Promise => {
+ if (!verbunden) return;
+ await post('/fern/stand', await standLesen(deps.obs));
+ };
+
+ const spaeterSenden = (): void => {
+ // OBS feuert beim Szenenwechsel mehrere Ereignisse kurz hintereinander.
+ // Ein Stand danach reicht.
+ if (sammler) clearTimeout(sammler);
+ sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300);
+ };
+
+ async function ausfuehren(b: Befehl): Promise<{ ok: boolean; text: string }> {
+ if (b.art === 'clip') {
+ const r = await deps.clip();
+ return r.ok ? { ok: true, text: 'Clip wird gesichert.' }
+ : { ok: false, text: r.reason ?? 'Clip ging nicht.' };
+ }
+ if (!deps.obs.isConnected()) return { ok: false, text: 'OBS ist auf dem Stream-PC nicht verbunden.' };
+ try {
+ if (b.art === 'szene') {
+ await deps.obs.request('SetCurrentProgramScene', { sceneName: b.szene });
+ return { ok: true, text: `Szene: ${b.szene}` };
+ }
+ await deps.obs.request('SetInputMute', { inputName: b.quelle, inputMuted: b.stumm });
+ return { ok: true, text: `${b.quelle} ${b.stumm ? 'stumm' : 'wieder an'}` };
+ } catch (err) {
+ return { ok: false, text: (err as Error).message };
+ }
+ }
+
+ async function einmal(signal: AbortSignal): Promise {
+ const wachhund = new AbortController();
+ const beides = AbortSignal.any([signal, wachhund.signal]);
+ let zuletzt = Date.now();
+ const takt = setInterval(() => {
+ if (Date.now() - zuletzt > TOT_NACH_MS) {
+ log.warn('Vom Hub nichts mehr gehoert, verbinde neu');
+ wachhund.abort();
+ }
+ }, 10_000);
+
+ try {
+ const res = await fetch(`${basis}/fern/befehle`, {
+ headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
+ });
+ if (res.status === 403) {
+ log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
+ 'von der Uebersichtsseite: abdulive fern:verbinden ...');
+ // Nicht im Sekundentakt anklopfen, der Schluessel wird nicht von allein richtig.
+ await sleep(5 * 60_000, signal).catch(() => {});
+ throw new Error('Schluessel abgelehnt');
+ }
+ if (!res.ok || !res.body) throw new Error(`Hub antwortet ${res.status}`);
+
+ verbunden = true;
+ log.info(`Mit dem Hub verbunden (${basis}) - das Pult kann OBS jetzt steuern`);
+ void standSenden();
+
+ const zerleger = sseZerleger();
+ const decoder = new TextDecoder();
+ for await (const stueck of res.body as unknown as AsyncIterable) {
+ zuletzt = Date.now();
+ for (const daten of zerleger(decoder.decode(stueck, { stream: true }))) {
+ let roh: unknown;
+ try { roh = JSON.parse(daten); } catch { continue; }
+ const b = befehlAus(roh);
+ if (!b) continue;
+ void ausfuehren(b).then((a) => {
+ log.info(`Pult: ${b.art} -> ${a.ok ? 'ok' : 'abgelehnt'} (${a.text})`);
+ return post('/fern/antwort', { id: b.id, ...a });
+ }).then(spaeterSenden);
+ }
+ }
+ throw new Error('Hub hat die Leitung geschlossen');
+ } catch (err) {
+ if (!signal.aborted) log.debug(`Leitung zum Hub weg: ${(err as Error).message}`);
+ throw err;
+ } finally {
+ clearInterval(takt);
+ if (verbunden) log.info('Leitung zum Hub getrennt');
+ verbunden = false;
+ }
+ }
+
+ return {
+ run: (signal) => forever(einmal, {
+ signal, baseMs: 2000, maxMs: 60_000,
+ onWait: (ms, n) => {
+ const zeile = `Hub nicht erreichbar, neuer Versuch in ${Math.round(ms / 1000)} s`;
+ if (n >= 3) log.warn(zeile); else log.debug(zeile);
+ },
+ }),
+ obsEreignis: (type) => { if (STAND_EREIGNISSE.has(type)) spaeterSenden(); },
+ obsWechsel: spaeterSenden,
+ verbunden: () => verbunden,
+ };
+}
diff --git a/apps/ingest/src/index.ts b/apps/ingest/src/index.ts
index 949c2a0..723fdae 100644
--- a/apps/ingest/src/index.ts
+++ b/apps/ingest/src/index.ts
@@ -19,6 +19,7 @@ import { readSecrets } from '#twitch/secrets.ts';
import { streamWechsel } from '#twitch/stream-state.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createClipper } from './clips.ts';
+import { createFernanschluss } from './fern.ts';
import { createRenderer } from './render.ts';
import { createSpikeDetector } from './spike.ts';
import { startServer } from './server.ts';
@@ -62,8 +63,9 @@ export async function runIngest(): Promise {
cfg.obs.url,
() => readSecrets().obs?.password,
{
- onIdentified: () => { state.obsConnected = true; },
- onClosed: () => { state.obsConnected = false; },
+ onIdentified: () => { state.obsConnected = true; fern?.obsWechsel(); },
+ onClosed: () => { state.obsConnected = false; fern?.obsWechsel(); },
+ onEvent: (type) => fern?.obsEreignis(type),
onSceneChanged: (scene) => {
state.obsScene = scene;
repo.event('obs.scene_changed', { scene }, state.streamId);
@@ -114,6 +116,20 @@ export async function runIngest(): Promise {
onRawReady: (job) => renderer.enqueue(job),
});
+ // ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Nur, wenn mit
+ // `abdulive fern:verbinden` eingerichtet - sonst laeuft alles wie vorher.
+ const fernZugang = readSecrets().fern;
+ const fern = fernZugang?.url && fernZugang.key
+ ? createFernanschluss({
+ url: fernZugang.url,
+ key: fernZugang.key,
+ obs,
+ clip: () => clipper.trigger('manual', { login: 'Pult' }),
+ })
+ : null;
+ if (fern) void fern.run(abort.signal);
+ else log.info('Pult-Fernsteuerung nicht eingerichtet (abdulive fern:verbinden)');
+
// ---- Go-Live-Ankuendigung auf Discord
const announcer = createAnnouncer({
cfg: {
@@ -138,6 +154,7 @@ export async function runIngest(): Promise {
goLive: announcer.lastResult() ?? state.goLive,
spikeArmed: spikes.isArmed(),
clipsWaitingForFile: clipper.pendingCount(),
+ pult: fern ? (fern.verbunden() ? 'verbunden' : 'getrennt') : 'nicht eingerichtet',
renderQueue: renderer.pending(),
rendering: renderer.currentJob(),
note: state.chatAnonymous
diff --git a/apps/overlay-server/README.md b/apps/overlay-server/README.md
index 5613ad2..07a9d5b 100644
--- a/apps/overlay-server/README.md
+++ b/apps/overlay-server/README.md
@@ -10,8 +10,14 @@ offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse.
/callback OAuth-Rückruf von Twitch offen, `state` schützt
/alerts … die Browser-Sources Schlüssel in der URL
/events der Ereignisstrom dahinter Schlüssel in der URL
+/pult Steuerpult fürs Handy (PWA) Passwort
+/soundboard/… Sounds hochladen, ändern, spielen Passwort (Datei: Schlüssel)
+/fern/… Leitung zum Stream-PC für OBS eigener Schlüssel im Kopf
```
+Der Fern-Schlüssel ist der dritte Zugang und der einzige, der etwas schaltet:
+er steht deshalb nie in einer Adresse, sondern in `authorization: Bearer …`.
+
## Server-Sent Events statt WebSocket
[`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen
diff --git a/apps/overlay-server/src/auth.test.ts b/apps/overlay-server/src/auth.test.ts
index af1358a..9621e5e 100644
--- a/apps/overlay-server/src/auth.test.ts
+++ b/apps/overlay-server/src/auth.test.ts
@@ -66,3 +66,14 @@ test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
// beenden lässt - dafür gibt es den Passwortwechsel.
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
});
+
+test('der Schluessel des Stream-PCs kommt aus dem Bearer-Kopf', async () => {
+ // Hier fehlten beim ersten Wurf die Backslashes in der Regex - jeder
+ // richtige Schluessel wurde abgelehnt, und nichts im Log sah danach aus.
+ const { bearer } = await import('./auth.ts');
+ assert.equal(bearer('Bearer 33qg61wQf_ad-x9v'), '33qg61wQf_ad-x9v');
+ assert.equal(bearer('Bearer abc'), 'abc');
+ assert.equal(bearer('Basic abc'), null);
+ assert.equal(bearer('Bearer'), null);
+ assert.equal(bearer(undefined), null);
+});
diff --git a/apps/overlay-server/src/auth.ts b/apps/overlay-server/src/auth.ts
index 95c2c7b..0d7a161 100644
--- a/apps/overlay-server/src/auth.ts
+++ b/apps/overlay-server/src/auth.ts
@@ -35,6 +35,10 @@ export type Zugang = {
abmelden: () => string;
overlayKey: () => string;
neuerOverlayKey: () => string;
+ /** Schluessel des Stream-PCs, im Kopf `authorization: Bearer ...`. */
+ darfFern: (req: http.IncomingMessage) => boolean;
+ fernKey: () => string;
+ neuerFernKey: () => string;
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
hatPasswort: () => boolean;
};
@@ -88,6 +92,11 @@ function signatur(key: Buffer, passwordHash: string, bis: string): string {
.digest('base64url');
}
+/** Der Schluessel aus `authorization: Bearer ...`, sonst `null`. */
+export function bearer(kopf: string | undefined): string | null {
+ return /^Bearer\s+(\S+)$/.exec(kopf ?? '')?.[1] ?? null;
+}
+
function cookies(req: http.IncomingMessage): Record {
const out: Record = {};
for (const teil of (req.headers.cookie ?? '').split(';')) {
@@ -114,6 +123,14 @@ export function createZugang(): Zugang {
return neu;
}
+ function fernKey(): string {
+ const vorhanden = store().fernKey;
+ if (vorhanden) return vorhanden;
+ const neu = crypto.randomBytes(24).toString('base64url');
+ updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
+ return neu;
+ }
+
/**
* Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird
* einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren,
@@ -161,6 +178,18 @@ export function createZugang(): Zugang {
abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
overlayKey: key,
+
+ darfFern(req) {
+ const gegeben = bearer(req.headers.authorization);
+ return Boolean(gegeben && gleich(gegeben, fernKey()));
+ },
+ fernKey,
+ neuerFernKey() {
+ const neu = crypto.randomBytes(24).toString('base64url');
+ updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
+ log.info('Schluessel fuer den Stream-PC neu gewuerfelt - dort neu eintragen');
+ return neu;
+ },
neuerOverlayKey() {
const neu = crypto.randomBytes(18).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts
index 8215b11..6f8c709 100644
--- a/apps/overlay-server/src/bus.ts
+++ b/apps/overlay-server/src/bus.ts
@@ -78,6 +78,25 @@ export type LiegestuetzeEvent = {
name?: string;
};
+/**
+ * Was der Stream-PC ueber OBS meldet. Kommt ueber die Fernsteuerung
+ * (fern.ts) herein und steht im Schnappschuss, damit das Pult nach dem
+ * Neuverbinden sofort die Szenen kennt.
+ */
+export type ObsStandEvent = {
+ kind: 'obs';
+ /** Ist der Stream-PC ueberhaupt mit dem Hub verbunden? */
+ pc: boolean;
+ /** Und hat er OBS erreicht? */
+ obs: boolean;
+ szenen: string[];
+ aktuell: string | null;
+ /** Toneingaenge, die sich stummschalten lassen. */
+ ton: Array<{ name: string; stumm: boolean }>;
+ /** Laeuft der Wiedergabepuffer? Ohne ihn gibt es keinen Clip. */
+ puffer: boolean;
+};
+
export type OverlayEvent =
| ChatEvent
| AlertEvent
@@ -103,12 +122,24 @@ export type OverlayEvent =
| { kind: 'still' }
| LiegestuetzeEvent
| { kind: 'scene'; scene: string }
+ /**
+ * Ein Sound vom Pult. Wie beim Vorlesen geht nur die ID hinaus; die Datei
+ * holt sich die Seite /sounds einzeln, damit Chat und Statusleiste keine
+ * Megabytes mitschleppen.
+ */
+ | { kind: 'sound'; id: string; name: string; laut: number }
+ /** Alle laufenden Sounds abbrechen. Vorlesen bleibt davon unberuehrt. */
+ | { kind: 'sound-stopp' }
+ /** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
+ | { kind: 'sounds-geaendert' }
+ | ObsStandEvent
| { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number }
| {
kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[];
/** Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */
liegestuetze: { offen: number; gemacht: number };
+ obs: ObsStandEvent;
};
/**
@@ -137,6 +168,9 @@ export function createBus(): Bus {
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
};
let liegestuetze = { offen: 0, gemacht: 0 };
+ let obs: ObsStandEvent = {
+ kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
+ };
return {
publish(ev) {
@@ -152,6 +186,8 @@ export function createBus(): Bus {
stream = ev;
} else if (ev.kind === 'liegestuetze') {
liegestuetze = { offen: ev.offen, gemacht: ev.gemacht };
+ } else if (ev.kind === 'obs') {
+ obs = ev;
} else if (ev.kind === 'clear') {
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
// naechste Neustart der Browser-Source es zurueck ins Bild.
@@ -174,7 +210,7 @@ export function createBus(): Bus {
return () => { listeners.delete(fn); };
},
- snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze }),
+ snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze, obs }),
clients: () => listeners.size,
};
}
diff --git a/apps/overlay-server/src/fern.test.ts b/apps/overlay-server/src/fern.test.ts
new file mode 100644
index 0000000..18b96bc
--- /dev/null
+++ b/apps/overlay-server/src/fern.test.ts
@@ -0,0 +1,116 @@
+// Die Leitung zum Stream-PC, Hub-Seite. Geprueft wird ohne Netz: die
+// SSE-Antwort ist ein nachgebautes Objekt, das mitschreibt.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import type http from 'node:http';
+import { createBus, type ObsStandEvent } from './bus.ts';
+import { createFernsteuerung, standAus } from './fern.ts';
+import { befehlAus, sichererPfad } from './server.ts';
+
+function leitung() {
+ const req = new EventEmitter();
+ const res = Object.assign(new EventEmitter(), {
+ geschrieben: [] as string[],
+ writeHead() { return this; },
+ write(s: string) { this.geschrieben.push(s); return true; },
+ end() { req.emit('close'); },
+ });
+ const befehle = (): Array> => res.geschrieben
+ .filter((s) => s.startsWith('data: '))
+ .map((s) => JSON.parse(s.slice(6)) as Record)
+ .filter((b) => b['art'] !== 'beat');
+ return {
+ req: req as unknown as http.IncomingMessage,
+ res: res as unknown as http.ServerResponse,
+ befehle,
+ trennen: () => req.emit('close'),
+ };
+}
+
+function obsStand(bus: ReturnType): ObsStandEvent {
+ const s = bus.snapshot();
+ if (s.kind !== 'state') throw new Error('kein Schnappschuss');
+ return s.obs;
+}
+
+test('ohne Stream-PC sagt ein Befehl ehrlich, dass es nicht ging', async () => {
+ const fern = createFernsteuerung(createBus());
+ const a = await fern.senden({ art: 'szene', szene: 'Pause' });
+ assert.equal(a.ok, false);
+ assert.match(a.text, /nicht verbunden/);
+});
+
+test('Befehl geht raus, Antwort kommt zurueck', async () => {
+ const bus = createBus();
+ const fern = createFernsteuerung(bus);
+ const l = leitung();
+ fern.annehmen(l.req, l.res);
+ assert.equal(obsStand(bus).pc, true);
+
+ const warten = fern.senden({ art: 'szene', szene: 'Just Chatting' });
+ const [b] = l.befehle();
+ assert.equal(b?.['art'], 'szene');
+ assert.equal(b?.['szene'], 'Just Chatting');
+
+ fern.antwort({ id: b!['id'], ok: true, text: 'Szene: Just Chatting' });
+ assert.deepEqual(await warten, { ok: true, text: 'Szene: Just Chatting' });
+});
+
+test('reisst die Leitung ab, wartet niemand weiter', async () => {
+ const bus = createBus();
+ const fern = createFernsteuerung(bus);
+ const l = leitung();
+ fern.annehmen(l.req, l.res);
+ fern.melden({ obs: true, szenen: ['A'], aktuell: 'A', ton: [], puffer: true });
+ assert.deepEqual(obsStand(bus).szenen, ['A']);
+
+ const warten = fern.senden({ art: 'clip' });
+ l.trennen();
+ const a = await warten;
+ assert.equal(a.ok, false);
+ assert.equal(fern.verbunden(), false);
+ // Alte Szenen stehen sonst als Knoepfe da, die nichts mehr tun.
+ assert.deepEqual(obsStand(bus), { kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
+});
+
+test('ein zweiter PC ersetzt den ersten', () => {
+ const bus = createBus();
+ const fern = createFernsteuerung(bus);
+ const alt = leitung();
+ const neu = leitung();
+ fern.annehmen(alt.req, alt.res);
+ fern.annehmen(neu.req, neu.res);
+ // Das Schliessen der alten Leitung darf die neue nicht als getrennt melden.
+ assert.equal(fern.verbunden(), true);
+ assert.equal(obsStand(bus).pc, true);
+ void fern.senden({ art: 'clip' });
+ assert.equal(neu.befehle().length, 1);
+ assert.equal(alt.befehle().length, 0);
+});
+
+test('der Stand vom PC wird auf die erwartete Form gebracht', () => {
+ assert.deepEqual(standAus({
+ obs: true, szenen: ['A', 7, 'B'], aktuell: 'A',
+ ton: [{ name: 'Mikro', stumm: true }, { name: 3 }, null], puffer: 'ja',
+ }), { obs: true, szenen: ['A', 'B'], aktuell: 'A', ton: [{ name: 'Mikro', stumm: true }], puffer: false });
+ assert.deepEqual(standAus('quatsch'), { obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
+});
+
+test('vom Pult kommen nur bekannte Befehle durch', () => {
+ const f = (o: Record): URLSearchParams => new URLSearchParams(o);
+ assert.deepEqual(befehlAus(f({ was: 'szene', szene: 'Pause' })), { art: 'szene', szene: 'Pause' });
+ assert.deepEqual(befehlAus(f({ was: 'stumm', quelle: 'Mikro', stumm: '1' })),
+ { art: 'stumm', quelle: 'Mikro', stumm: true });
+ assert.deepEqual(befehlAus(f({ was: 'clip' })), { art: 'clip' });
+ assert.equal(befehlAus(f({ was: 'szene' })), null);
+ assert.equal(befehlAus(f({ was: 'StopStream' })), null);
+});
+
+test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
+ assert.equal(sichererPfad('/pult'), '/pult');
+ assert.equal(sichererPfad('https://boese.example'), '/');
+ assert.equal(sichererPfad('//boese.example'), '/');
+ assert.equal(sichererPfad(null), '/');
+});
diff --git a/apps/overlay-server/src/fern.ts b/apps/overlay-server/src/fern.ts
new file mode 100644
index 0000000..2f54d5d
--- /dev/null
+++ b/apps/overlay-server/src/fern.ts
@@ -0,0 +1,155 @@
+// 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,
+ };
+}
diff --git a/apps/overlay-server/src/server.ts b/apps/overlay-server/src/server.ts
index 5856bd2..2cead66 100644
--- a/apps/overlay-server/src/server.ts
+++ b/apps/overlay-server/src/server.ts
@@ -8,6 +8,8 @@
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
// /alerts ... die Browser-Sources (Schluessel)
// /events der SSE-Strom dahinter (Schluessel)
+// /pult Steuerpult fuers Handy (PWA) (Passwort)
+// /fern/... Leitung zum Stream-PC fuer OBS (eigener Schluessel)
//
// Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit
// der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit
@@ -18,11 +20,14 @@ import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+import { paths } from '#core/config.ts';
import { logger } from '#core/logger.ts';
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
import { createZugang, type Zugang } from './auth.ts';
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
import { startDemo } from './demo.ts';
+import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
+import { createSoundboard, MAX_BYTES, nameAusDatei, type Soundboard } from './sounds.ts';
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
const log = logger('overlay');
@@ -46,6 +51,7 @@ const TYPES: Record = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.woff2': 'font/woff2',
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
};
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
@@ -64,6 +70,8 @@ export const PAGES = [
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
{ path: '/tts', file: 'tts.html', w: 600, h: 200,
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' },
+ { path: '/sounds', file: 'sounds.html', w: 400, h: 120,
+ title: 'Soundboard', hint: 'Spielt die Sounds vom Pult. Unsichtbar, braucht Ton in OBS („Audio über OBS steuern“ an).' },
{ path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200,
title: 'Liegestütze', hint: 'Offene und in diesem Stream gemachte Liegestütze. Zählt bei Einlösung hoch, bedienen unten auf dieser Seite.' },
] as const;
@@ -73,6 +81,7 @@ type LiegestuetzStand = { offen: number; gemacht: number };
export type OverlayServer = {
bus: Bus;
zugang: Zugang;
+ fern: Fernsteuerung;
close: () => void;
port: number;
};
@@ -83,6 +92,8 @@ export type OverlayOptions = {
/** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */
publicUrl: string;
bus?: Bus;
+ /** Wo die Sounds liegen. Vorgabe: neben der Datenbank. */
+ soundsDir?: string;
/** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */
setup?: ServerDeps;
/**
@@ -116,13 +127,18 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
const zugang = createZugang();
// Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll
// den ersten ersetzen, nicht danebenlaufen.
- const vorfuehrung = { abbruch: null as AbortController | null };
+ const lz: Laufzeit = {
+ vorfuehrung: { abbruch: null },
+ sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')),
+ fern: createFernsteuerung(bus),
+ quellen: new Map(),
+ };
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
// oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen.
const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1');
const server = http.createServer((req, res) => {
- void handle(req, res, bus, zugang, opts, vorfuehrung).catch((err: Error) => {
+ void handle(req, res, bus, zugang, opts, lz).catch((err: Error) => {
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Interner Fehler');
@@ -143,23 +159,35 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : ''));
});
- return { bus, zugang, close: () => server.close(), port: opts.port };
+ return { bus, zugang, fern: lz.fern, close: () => server.close(), port: opts.port };
}
type Vorfuehrung = { abbruch: AbortController | null };
+/** Was der Server zwischen den Anfragen haelt. */
+type Laufzeit = {
+ vorfuehrung: Vorfuehrung;
+ sounds: Soundboard;
+ fern: Fernsteuerung;
+ /** Verbundene Browser-Sources je Seite, z. B. sounds -> 1. */
+ quellen: Map;
+};
+
async function handle(
req: http.IncomingMessage, res: http.ServerResponse,
- bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung
+ bus: Bus, zugang: Zugang, opts: OverlayOptions, lz: Laufzeit
): Promise {
const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`);
const p = url.pathname;
+ const { vorfuehrung, sounds, fern } = lz;
// ---- Anmeldung. Bewusst vor allem anderen.
if (p === '/login' && req.method === 'POST') {
- const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? '');
- if (!cookie) return redirect(res, '/?fehler=1');
- res.writeHead(303, { location: '/', 'set-cookie': cookie });
+ const form = await readForm(req);
+ const weiter = sichererPfad(form.get('weiter'));
+ const cookie = zugang.anmelden(form.get('passwort') ?? '');
+ if (!cookie) return redirect(res, `${weiter}?fehler=1`);
+ res.writeHead(303, { location: weiter, 'set-cookie': cookie });
res.end();
return;
}
@@ -177,7 +205,25 @@ async function handle(
// ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten,
// also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen.
- if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
+ if (/\.(css|js|svg|png|jpg|woff2|webmanifest)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
+
+ // ---- Die Leitung zum Stream-PC. Eigener Schluessel im Kopf, nie in der
+ // Adresse: wer ihn hat, schaltet Szenen um.
+ if (p.startsWith('/fern/')) {
+ if (!zugang.darfFern(req)) return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
+ if (p === '/fern/befehle' && req.method === 'GET') return fern.annehmen(req, res);
+ if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') {
+ let roh: unknown;
+ try { roh = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8')); } catch {
+ return send(res, 400, 'text/plain; charset=utf-8', 'Kein JSON.');
+ }
+ if (p === '/fern/stand') fern.melden(roh); else fern.antwort(roh);
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden');
+ }
// ---- Die Browser-Sources und ihr Datenstrom.
// Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der
@@ -186,13 +232,27 @@ async function handle(
const page = req.method === 'GET' || req.method === 'HEAD'
? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined;
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
- if (page || p === '/events' || audioId) {
+ const soundId = /^\/soundboard\/datei\/([a-f0-9]+)$/.exec(p)?.[1];
+ if (page || p === '/events' || audioId || soundId) {
if (!zugang.darfOverlay(req, url)) {
return send(res, 403, 'text/plain; charset=utf-8',
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
'Sie steht fertig auf der Uebersichtsseite.');
}
- if (p === '/events') return sse(req, res, bus);
+ if (p === '/events') return sse(req, res, bus, url, lz.quellen);
+ if (soundId) {
+ const d = sounds.datei(soundId);
+ if (!d) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
+ res.writeHead(200, {
+ 'content-type': d.typ, 'content-length': d.daten.length,
+ // Darf zwischengespeichert werden: eine ID gehoert immer zu derselben
+ // Datei, und beim zweiten Druck auf denselben Knopf soll es sofort knallen.
+ 'cache-control': 'private, max-age=86400, immutable',
+ 'x-content-type-options': 'nosniff',
+ });
+ res.end(d.daten);
+ return;
+ }
if (audioId) {
const wav = opts.tts?.audio(audioId);
if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
@@ -207,10 +267,39 @@ async function handle(
// ---- Ab hier nur mit Anmeldung.
if (!zugang.istAngemeldet(req)) {
+ // Fragen von Skripten (fetch) bekommen eine kurze Antwort statt der
+ // Anmeldeseite, damit das Pult "abgemeldet" sagen kann statt HTML zu parsen.
+ if (req.method !== 'GET' || p.startsWith('/soundboard/') || p.startsWith('/pult/')) {
+ return send(res, 401, 'application/json; charset=utf-8',
+ JSON.stringify({ text: 'Abgemeldet. Bitte neu anmelden.', abgemeldet: true }));
+ }
return send(res, 401, 'text/html; charset=utf-8',
- loginPage(opts.channel, url.searchParams.has('fehler')));
+ loginPage(opts.channel, url.searchParams.has('fehler'), p));
}
+ // ---- Das Pult
+ if (p === '/pult' && req.method === 'GET') return sendFile(res, 'pult.html');
+ if (p === '/pult/knoepfe' && req.method === 'GET') {
+ return send(res, 200, 'application/json; charset=utf-8', JSON.stringify({
+ knoepfe: TEST_KNOEPFE, demo: vorfuehrung.abbruch !== null,
+ tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null,
+ }));
+ }
+ if (p === '/pult/obs' && req.method === 'POST') {
+ const form = await readForm(req);
+ const befehl = befehlAus(form);
+ if (!befehl) {
+ return send(res, 400, 'application/json; charset=utf-8',
+ JSON.stringify({ ok: false, text: 'Unbekannter Befehl.' }));
+ }
+ const a = await fern.senden(befehl);
+ if (a.ok) log.info(`Pult -> OBS: ${befehl.art}${'szene' in befehl ? ` "${befehl.szene}"` : ''}`);
+ return send(res, a.ok ? 200 : 502, 'application/json; charset=utf-8', JSON.stringify(a));
+ }
+
+ // ---- Soundboard
+ if (p.startsWith('/soundboard/')) return soundboardRoute(req, res, url, bus, lz);
+
// ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts
// davon landet in der Datenbank, auf Discord oder bei Twitch.
if (p === '/test' && req.method === 'POST') {
@@ -323,10 +412,15 @@ async function handle(
zugang.neuerOverlayKey();
return redirect(res, '/');
}
+ if (p === '/fernschluessel/neu' && req.method === 'POST') {
+ zugang.neuerFernKey();
+ return redirect(res, '/#stream-pc');
+ }
if (p === '/' || p === '/index.html') {
return send(res, 200, 'text/html; charset=utf-8',
- indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null));
+ indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null,
+ { key: zugang.fernKey(), verbunden: fern.verbunden() }));
}
if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return;
@@ -336,7 +430,16 @@ async function handle(
// ---------------------------------------------------------------- SSE
-function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void {
+function sse(
+ req: http.IncomingMessage, res: http.ServerResponse, bus: Bus,
+ url: URL, quellen: Map
+): void {
+ // Welche Seite da zuhoert. Nur zum Zaehlen: das Pult will wissen, ob die
+ // Sound-Quelle in OBS ueberhaupt offen ist, bevor es "gespielt" meldet.
+ const roh = url.searchParams.get('quelle') ?? '';
+ const quelle = /^[a-z0-9-]{1,20}$/.test(roh) ? roh : '';
+ if (quelle) quellen.set(quelle, (quellen.get(quelle) ?? 0) + 1);
+
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
@@ -366,7 +469,15 @@ function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): voi
HEARTBEAT_MS);
beat.unref();
- const end = (): void => { clearInterval(beat); unsubscribe(); };
+ // `close` und `error` kommen gern beide - gezaehlt wird nur einmal.
+ let zu = false;
+ const end = (): void => {
+ if (zu) return;
+ zu = true;
+ clearInterval(beat);
+ unsubscribe();
+ if (quelle) quellen.set(quelle, Math.max(0, (quellen.get(quelle) ?? 1) - 1));
+ };
req.on('close', end);
res.on('error', end);
}
@@ -378,8 +489,119 @@ function zustand(bus: Bus) {
return { live: s.stream.live, chat: s.chat };
}
+// ---------------------------------------------------------------- Pult
+
+/** Formular vom Pult in einen Befehl fuer OBS. Alles andere ist `null`. */
+export function befehlAus(form: URLSearchParams): FernBefehl | null {
+ const was = form.get('was');
+ const szene = form.get('szene');
+ const quelle = form.get('quelle');
+ if (was === 'szene' && szene) return { art: 'szene', szene };
+ if (was === 'clip') return { art: 'clip' };
+ if (was === 'stumm' && quelle) return { art: 'stumm', quelle, stumm: form.get('stumm') === '1' };
+ return null;
+}
+
+async function soundboardRoute(
+ req: http.IncomingMessage, res: http.ServerResponse, url: URL, bus: Bus, lz: Laufzeit
+): Promise {
+ const p = url.pathname;
+ const { sounds } = lz;
+ const json = (code: number, body: Record): void =>
+ send(res, code, 'application/json; charset=utf-8', JSON.stringify(body));
+ const mitListe = (code: number, text: string): void =>
+ json(code, { text, sounds: sounds.liste(), quellen: lz.quellen.get('sounds') ?? 0 });
+ const geaendert = (): void => bus.publish({ kind: 'sounds-geaendert' });
+ const zuGross = `Zu groß: höchstens ${MAX_BYTES / 1024 / 1024} MB.`;
+
+ if (p === '/soundboard/liste' && req.method === 'GET') return mitListe(200, '');
+ if (req.method !== 'POST') return json(405, { text: 'Nur POST.' });
+
+ if (p === '/soundboard/neu') {
+ // Die Datei kommt roh im Koerper, nicht als Formular: das erspart das
+ // Zerlegen von multipart, und das Pult schickt ohnehin genau eine Datei.
+ if (Number(req.headers['content-length'] ?? 0) > MAX_BYTES) return mitListe(413, zuGross);
+ let daten: Buffer;
+ try { daten = await readBody(req, MAX_BYTES); } catch { return mitListe(413, zuGross); }
+ try {
+ const s = sounds.hinzufuegen(nameAusDatei(url.searchParams.get('name') ?? ''), daten);
+ log.info(`Sound hochgeladen: ${s.name} (${(s.bytes / 1024).toFixed(0)} KB, ${s.typ})`);
+ geaendert();
+ return json(200, { text: `„${s.name}“ ist drin.`, sound: s, sounds: sounds.liste() });
+ } catch (err) {
+ return mitListe(400, (err as Error).message);
+ }
+ }
+
+ const form = await readForm(req);
+ const id = form.get('id') ?? '';
+
+ if (p === '/soundboard/spielen') {
+ const s = sounds.holen(id);
+ if (!s) return mitListe(404, 'Den Sound gibt es nicht mehr.');
+ bus.publish({ kind: 'sound', id: s.id, name: s.name, laut: s.laut });
+ const n = lz.quellen.get('sounds') ?? 0;
+ // Gespielt wird trotzdem: vielleicht ist die Quelle nur kurz weg. Aber
+ // wer auf einen Knopf drueckt und nichts hoert, soll wissen, warum.
+ return json(200, {
+ text: n ? s.name : `${s.name} – aber in OBS ist keine Sound-Quelle verbunden.`,
+ quellen: n,
+ });
+ }
+ if (p === '/soundboard/stopp') {
+ bus.publish({ kind: 'sound-stopp' });
+ return json(200, { text: 'Alle Sounds gestoppt.' });
+ }
+ if (p === '/soundboard/aendern') {
+ const was: Parameters[1] = {};
+ for (const k of ['name', 'emoji', 'farbe'] as const) {
+ const v = form.get(k);
+ if (v !== null) was[k] = v;
+ }
+ const laut = form.get('laut');
+ if (laut !== null) was.laut = Number(laut);
+ if (!sounds.aendern(id, was)) return mitListe(404, 'Den Sound gibt es nicht mehr.');
+ geaendert();
+ return mitListe(200, 'Gespeichert.');
+ }
+ if (p === '/soundboard/loeschen') {
+ const name = sounds.holen(id)?.name;
+ if (!sounds.entfernen(id)) return mitListe(404, 'Den Sound gibt es nicht mehr.');
+ log.info(`Sound geloescht: ${name}`);
+ geaendert();
+ return mitListe(200, `„${name}“ gelöscht.`);
+ }
+ if (p === '/soundboard/verschieben') {
+ if (sounds.verschieben(id, Number(form.get('um')))) geaendert();
+ return mitListe(200, '');
+ }
+ return json(404, { text: 'Nicht gefunden.' });
+}
+
// ---------------------------------------------------------------- Werkzeug
+/**
+ * Wohin es nach der Anmeldung geht. Nur eine feste Liste: ein freies Ziel
+ * machte /login zur Weiterleitung auf beliebige fremde Seiten.
+ */
+export function sichererPfad(roh: string | null | undefined): string {
+ return roh === '/pult' ? '/pult' : '/';
+}
+
+function readBody(req: http.IncomingMessage, max: number): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ req.on('data', (c: Buffer) => {
+ size += c.length;
+ if (size > max) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
+ chunks.push(c);
+ });
+ req.on('end', () => resolve(Buffer.concat(chunks)));
+ req.on('error', reject);
+ });
+}
+
function readForm(req: http.IncomingMessage): Promise {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -486,13 +708,19 @@ const SEITEN_CSS = `
button.tts { margin-left: .4rem; }
`;
-export function loginPage(channel: string, fehler: boolean): string {
+export function loginPage(channel: string, fehler: boolean, weiter = '/'): string {
return `
+
+
+
+
Anmelden
).join('')}
`).join('');
+ const fernBefehl = `abdulive fern:verbinden ${base} ${fernInfo.key}`;
+
return `
-Overlay - ${escape(opts.channel)}
+
+
+
+
+Overlay - ${escape(opts.channel)}
Overlay ${escape(opts.channel)}
+
+
Pult öffnen
+
Soundboard, Szenen, Liegestütze und Vorlesen fürs Handy.
+ Dort im Browser-Menü „Zum Startbildschirm hinzufügen“ bzw. „App installieren“ -
+ dann liegt es wie eine App auf dem Handy.
+
In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und
Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst
verpasst das Overlay alles, was in einer anderen Szene passiert.
@@ -607,6 +854,20 @@ ${ttsFeld}
${lzFeld}
+Stream-PC verbinden
+Damit das Pult Szenen umschalten, Clips speichern und Ton stummschalten
+kann, wählt sich der Stream-PC hier ein. Einmal auf dem Stream-PC in PowerShell
+ausführen, danach abdulive ingest neu starten:
+${escape(fernBefehl)}
+ kopieren
+ ${
+ fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}
+
+
Schlüssel