diff --git a/.playwright-mcp/console-2026-10-26T12-01-16-482Z.log b/.playwright-mcp/console-2026-10-26T12-01-16-482Z.log
new file mode 100644
index 0000000..9805f76
--- /dev/null
+++ b/.playwright-mcp/console-2026-10-26T12-01-16-482Z.log
@@ -0,0 +1,93 @@
+[ 54050ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_RESET @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 54054ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 55062ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 55068ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 56069ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 56071ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 57087ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 57089ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 58102ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 58104ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 59105ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 59107ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 60048ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 60109ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 60111ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 61142ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 61160ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 62276ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 62524ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 63583ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 63620ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 64985ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 65532ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 66017ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 66032ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 67490ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 67498ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 68503ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 68506ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 69510ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 69512ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 70526ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 70529ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 71536ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 71538ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 72551ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 72554ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 73562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 73565ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 74571ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 74574ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 75587ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 75590ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 76603ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 76606ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 77607ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 77608ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 78614ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 78619ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 79627ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 79629ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 80112ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 80649ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 80663ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 82183ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 82313ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 84634ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 84970ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 85652ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 85687ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 87032ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 87063ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 88257ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 88277ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 89301ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 89323ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 90312ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 90315ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 91330ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 91333ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 93470ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 94174ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 94469ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 94472ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 95485ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 95487ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 96489ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 96491ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 97495ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 97500ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 98504ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 98507ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 99514ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 99519ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 100093ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 100096ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 100104ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 103134ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 103167ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 106151ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 106162ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+[ 109164ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
+[ 109166ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
diff --git a/apps/ingest/src/fern.test.ts b/apps/ingest/src/fern.test.ts
index 10016ce..e375067 100644
--- a/apps/ingest/src/fern.test.ts
+++ b/apps/ingest/src/fern.test.ts
@@ -3,7 +3,9 @@
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';
+import {
+ befehlAus, einrichtenAus, SOUND_QUELLE, soundQuelleEinrichten, sseZerleger, standLesen, TON_SZENE,
+} from './fern.ts';
test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
const z = sseZerleger();
@@ -67,3 +69,83 @@ test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
assert.equal(s.puffer, false);
assert.deepEqual(s.szenen, ['A']);
});
+
+test('nur eine Adresse auf die Sound-Seite wird als Browser-Source angelegt', () => {
+ assert.deepEqual(einrichtenAus({ art: 'einrichten', soundsUrl: 'https://abdu.dimension47.de/sounds?key=4OB0-x_y' }),
+ { art: 'einrichten', soundsUrl: 'https://abdu.dimension47.de/sounds?key=4OB0-x_y' });
+ assert.equal(einrichtenAus({ art: 'einrichten', soundsUrl: 'https://boese.example/irgendwas' }), null);
+ assert.equal(einrichtenAus({ art: 'einrichten', soundsUrl: 'javascript:alert(1)//sounds?key=a' }), null);
+ assert.equal(einrichtenAus({ art: 'szene', soundsUrl: 'https://a/sounds?key=a' }), null);
+});
+
+/** OBS zum Mitschreiben: Szenen, Quellen und was darin steckt. */
+function obsMitSzenen(szenen: string[]) {
+ const inhalt = new Map(szenen.map((s) => [s, []]));
+ const quellen = new Map>();
+ const aufrufe: string[] = [];
+ const obs: ObsClient = {
+ run: async () => {},
+ isConnected: () => true,
+ request: async (type: string, data?: unknown): Promise => {
+ const d = (data ?? {}) as Record;
+ aufrufe.push(type);
+ switch (type) {
+ case 'GetSceneList': return { scenes: [...inhalt.keys()].map((sceneName) => ({ sceneName })) } as T;
+ case 'CreateScene': inhalt.set(String(d['sceneName']), []); return {} as T;
+ case 'GetInputSettings': {
+ const q = quellen.get(String(d['inputName']));
+ if (!q) throw new Error('OBS lehnt ab (600): keine Quelle');
+ return { inputSettings: q } as T;
+ }
+ case 'CreateInput':
+ quellen.set(String(d['inputName']), d['inputSettings'] as Record);
+ inhalt.get(String(d['sceneName']))!.push(String(d['inputName']));
+ return {} as T;
+ case 'SetInputSettings':
+ Object.assign(quellen.get(String(d['inputName']))!, d['inputSettings']);
+ return {} as T;
+ case 'GetSceneItemList':
+ return { sceneItems: inhalt.get(String(d['sceneName']))!.map((sourceName) => ({ sourceName })) } as T;
+ case 'CreateSceneItem':
+ inhalt.get(String(d['sceneName']))!.push(String(d['sourceName']));
+ return {} as T;
+ }
+ throw new Error(`unerwartet: ${type}`);
+ },
+ };
+ return { obs, inhalt, quellen, aufrufe };
+}
+
+test('Sound-Quelle: einmal angelegt, in jeder Szene, ein zweiter Lauf aendert nichts', async () => {
+ const o = obsMitSzenen(['Gameplay', 'Pause']);
+ const url = 'https://hub/sounds?key=a';
+ const getan = await soundQuelleEinrichten(o.obs, url);
+ assert.equal(getan.length, 4);
+ assert.deepEqual(o.inhalt.get(TON_SZENE), [SOUND_QUELLE]);
+ assert.deepEqual(o.inhalt.get('Gameplay'), [TON_SZENE]);
+ assert.deepEqual(o.inhalt.get('Pause'), [TON_SZENE]);
+ const q = o.quellen.get(SOUND_QUELLE)!;
+ assert.equal(q['reroute_audio'], true, 'sonst geht der Ton am Stream vorbei');
+ assert.equal(q['shutdown'], false);
+
+ assert.deepEqual(await soundQuelleEinrichten(o.obs, url), []);
+ assert.equal(o.aufrufe.filter((a) => a.startsWith('Create')).length, 4);
+});
+
+test('Sound-Quelle: neue Szene und neuer Schluessel werden nachgezogen', async () => {
+ const o = obsMitSzenen(['Gameplay']);
+ await soundQuelleEinrichten(o.obs, 'https://hub/sounds?key=alt');
+ o.inhalt.set('Just Chatting', []);
+ const getan = await soundQuelleEinrichten(o.obs, 'https://hub/sounds?key=neu');
+ assert.deepEqual(getan, [`Adresse von "${SOUND_QUELLE}" erneuert`, 'in "Just Chatting" eingehaengt']);
+ assert.equal(o.quellen.get(SOUND_QUELLE)!['url'], 'https://hub/sounds?key=neu');
+});
+
+test('die eigene Ton-Szene steht nicht in der Szenenauswahl', async () => {
+ const obs = falschesObs({
+ GetSceneList: () => ({ currentProgramSceneName: 'A', scenes: [
+ { sceneName: 'A', sceneIndex: 1 }, { sceneName: TON_SZENE, sceneIndex: 0 }] }),
+ GetInputList: () => ({ inputs: [] }),
+ });
+ assert.deepEqual((await standLesen(obs)).szenen, ['A']);
+});
diff --git a/apps/ingest/src/fern.ts b/apps/ingest/src/fern.ts
index 218aaf3..21d6f49 100644
--- a/apps/ingest/src/fern.ts
+++ b/apps/ingest/src/fern.ts
@@ -8,9 +8,13 @@
// 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.
+// Gegenstueck: apps/overlay-server/src/fern.ts.
+//
+// Einzurichten ist nichts. Beim ersten Start wuerfelt der PC einen Schluessel
+// und klopft damit beim Hub an; der antwortet 202, bis im Pult jemand auf
+// "Freigeben" tippt. Danach schickt der Hub die Adresse der Sound-Quelle, und
+// der PC legt sie in OBS selbst an. `abdulive fern:verbinden` bleibt als Weg
+// von Hand.
import { logger } from '#core/logger.ts';
import { forever, sleep } from '#core/retry.ts';
@@ -21,6 +25,18 @@ const log = logger('fern');
/** Der Hub schickt alle 15 s einen Herzschlag. Drei verpasste heisst: tot. */
const TOT_NACH_MS = 45_000;
+/** So oft fragt ein noch nicht freigegebener PC nach. */
+const WARTEN_MS = 10_000;
+
+/**
+ * Was der PC in OBS anlegt. Die Sound-Quelle sitzt in einer eigenen Szene,
+ * und diese Szene haengt in jeder anderen: OBS gibt den Ton einer Quelle nur
+ * aus, wenn sie in der gerade gezeigten Szene liegt. So gibt es die Quelle
+ * trotzdem nur einmal, und ein Szenenwechsel startet sie nicht neu.
+ */
+export const TON_SZENE = 'abdulive Ton';
+export const SOUND_QUELLE = 'abdulive Sounds';
+
/** Nach diesen OBS-Ereignissen stimmt der gemeldete Stand nicht mehr. */
const STAND_EREIGNISSE = new Set([
'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
@@ -41,6 +57,21 @@ export type Befehl =
| { id: string; art: 'clip' }
| { id: string; art: 'stumm'; quelle: string; stumm: boolean };
+/** Kommt gleich nach dem Verbinden, ohne ID: darauf antwortet niemand. */
+export type Einrichten = { art: 'einrichten'; soundsUrl: string };
+
+export function einrichtenAus(roh: unknown): Einrichten | null {
+ if (!roh || typeof roh !== 'object') return null;
+ const r = roh as Record;
+ const url = r['soundsUrl'];
+ // Nur eine Adresse auf eine Sound-Seite. Was der Hub hier schickt, landet
+ // als Browser-Source in OBS - eine beliebige Seite haette dort nichts verloren.
+ if (r['art'] !== 'einrichten' || typeof url !== 'string' || !/^https?:\/\/[^\s]+\/sounds\?key=[\w-]+$/.test(url)) {
+ return null;
+ }
+ return { art: 'einrichten', soundsUrl: url };
+}
+
/**
* Zerlegt einen SSE-Strom in Nachrichten. Bekommt beliebige Stuecke - eine
* Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem
@@ -90,7 +121,9 @@ export async function standLesen(obs: ObsClient): Promise {
// 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);
+ .sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName)
+ // Die eigene Ton-Szene ist zum Einhaengen da, nicht zum Umschalten.
+ .filter((s) => s !== TON_SZENE);
const eingaenge = await obs.request<{ inputs: Array<{ inputName: string }> }>('GetInputList');
// Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute
@@ -110,9 +143,63 @@ export async function standLesen(obs: ObsClient): Promise {
}
}
+/**
+ * Legt die Sound-Quelle in OBS an, wenn sie fehlt, und haengt die Ton-Szene
+ * in jede Szene, in der sie noch nicht steckt. Laeuft beim Verbinden und bei
+ * jeder neuen Szene; ein zweiter Lauf aendert nichts.
+ *
+ * Gibt zurueck, was getan wurde - fuers Protokoll.
+ */
+export async function soundQuelleEinrichten(obs: ObsClient, url: string): Promise {
+ const getan: string[] = [];
+ const liste = await obs.request<{ scenes: Array<{ sceneName: string }> }>('GetSceneList');
+ const namen = liste.scenes.map((s) => s.sceneName);
+
+ if (!namen.includes(TON_SZENE)) {
+ await obs.request('CreateScene', { sceneName: TON_SZENE });
+ getan.push(`Szene "${TON_SZENE}" angelegt`);
+ }
+
+ const vorhanden = await obs.request<{ inputSettings: Record }>(
+ 'GetInputSettings', { inputName: SOUND_QUELLE }).catch(() => null);
+ const einstellungen = {
+ url, width: 400, height: 120,
+ // "Audio ueber OBS steuern": ohne das geht der Ton am Mixer vorbei
+ // direkt auf die Lautsprecher und nie in den Stream.
+ reroute_audio: true,
+ // Weiterlaufen, auch wenn gerade nicht sichtbar, und beim Szenenwechsel
+ // nicht neu laden - sonst bricht jeder Wechsel den laufenden Sound ab.
+ shutdown: false,
+ restart_when_active: false,
+ };
+ if (!vorhanden) {
+ await obs.request('CreateInput', {
+ sceneName: TON_SZENE, inputName: SOUND_QUELLE, inputKind: 'browser_source',
+ inputSettings: einstellungen, sceneItemEnabled: true,
+ });
+ getan.push(`Quelle "${SOUND_QUELLE}" angelegt`);
+ } else if (vorhanden.inputSettings['url'] !== url) {
+ // Neuer Overlay-Schluessel auf dem Hub: die alte Adresse ist tot.
+ await obs.request('SetInputSettings', { inputName: SOUND_QUELLE, inputSettings: { url } });
+ getan.push(`Adresse von "${SOUND_QUELLE}" erneuert`);
+ }
+
+ for (const szene of namen) {
+ if (szene === TON_SZENE) continue;
+ const inhalt = await obs.request<{ sceneItems: Array<{ sourceName: string }> }>(
+ 'GetSceneItemList', { sceneName: szene });
+ if (inhalt.sceneItems.some((i) => i.sourceName === TON_SZENE)) continue;
+ await obs.request('CreateSceneItem', { sceneName: szene, sourceName: TON_SZENE, sceneItemEnabled: true });
+ getan.push(`in "${szene}" eingehaengt`);
+ }
+ return getan;
+}
+
export type FernDeps = {
url: string;
key: string;
+ /** So erscheint der PC bei der Freigabe im Pult. */
+ name: string;
obs: ObsClient;
/** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
clip: () => Promise<{ ok: boolean; reason?: string }>;
@@ -127,11 +214,20 @@ export type FernAnschluss = {
verbunden: () => boolean;
};
+
export function createFernanschluss(deps: FernDeps): FernAnschluss {
const basis = deps.url.replace(/\/+$/, '');
- const kopf = { authorization: `Bearer ${deps.key}` };
+ // Der Rechnername steht nur in der Freigabe im Pult. Nur ASCII: ein Umlaut
+ // im Namen machte den ganzen Kopf ungueltig, und fetch wirft.
+ const kopf = {
+ authorization: `Bearer ${deps.key}`,
+ 'x-abdulive-rechner': deps.name.replace(/[^\x20-\x7e]/g, '?').slice(0, 40),
+ };
let verbunden = false;
let sammler: NodeJS.Timeout | null = null;
+ /** Kommt vom Hub nach dem Verbinden. Bis dahin wird in OBS nichts angelegt. */
+ let soundsUrl: string | null = null;
+ let richtetEin = false;
async function post(pfad: string, body: unknown): Promise {
try {
@@ -159,6 +255,21 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300);
};
+ /** Sound-Quelle in OBS anlegen bzw. pruefen. Nie zweimal gleichzeitig. */
+ const einrichten = async (): Promise => {
+ if (!soundsUrl || !deps.obs.isConnected() || richtetEin) return;
+ richtetEin = true;
+ try {
+ const getan = await soundQuelleEinrichten(deps.obs, soundsUrl);
+ if (getan.length) log.info(`OBS eingerichtet: ${getan.join(', ')}`);
+ if (getan.length) spaeterSenden();
+ } catch (err) {
+ log.warn(`Sound-Quelle in OBS nicht eingerichtet: ${(err as Error).message}`);
+ } finally {
+ richtetEin = false;
+ }
+ };
+
async function ausfuehren(b: Befehl): Promise<{ ok: boolean; text: string }> {
if (b.art === 'clip') {
const r = await deps.clip();
@@ -190,9 +301,24 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
}, 10_000);
try {
- const res = await fetch(`${basis}/fern/befehle`, {
- headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
- });
+ let res: Response;
+ let gemeldet = false;
+ // Solange der Hub 202 sagt, ist der PC noch nicht freigegeben. Dann im
+ // festen Takt nachfragen - die Freigabe soll ein paar Sekunden nach dem
+ // Tipp im Pult greifen, nicht erst nach einer Minute Rueckzug.
+ for (;;) {
+ res = await fetch(`${basis}/fern/befehle`, {
+ headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
+ });
+ if (res.status !== 202) break;
+ await res.body?.cancel().catch(() => {});
+ if (!gemeldet) {
+ log.info(`Wartet auf Freigabe: im Pult (${basis}/pult) unter Szenen auf „Freigeben“ tippen`);
+ gemeldet = true;
+ }
+ zuletzt = Date.now();
+ await sleep(WARTEN_MS, beides);
+ }
if (res.status === 403) {
log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
'von der Uebersichtsseite: abdulive fern:verbinden ...');
@@ -213,6 +339,8 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
for (const daten of zerleger(decoder.decode(stueck, { stream: true }))) {
let roh: unknown;
try { roh = JSON.parse(daten); } catch { continue; }
+ const e = einrichtenAus(roh);
+ if (e) { soundsUrl = e.soundsUrl; void einrichten(); continue; }
const b = befehlAus(roh);
if (!b) continue;
void ausfuehren(b).then((a) => {
@@ -240,8 +368,13 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
if (n >= 3) log.warn(zeile); else log.debug(zeile);
},
}),
- obsEreignis: (type) => { if (STAND_EREIGNISSE.has(type)) spaeterSenden(); },
- obsWechsel: spaeterSenden,
+ obsEreignis: (type) => {
+ if (STAND_EREIGNISSE.has(type)) spaeterSenden();
+ // Eine neue Szene bekommt die Ton-Szene gleich mit, sonst ist dort
+ // das Soundboard stumm.
+ if (type === 'SceneCreated') void einrichten();
+ },
+ obsWechsel: () => { spaeterSenden(); void einrichten(); },
verbunden: () => verbunden,
};
}
diff --git a/apps/ingest/src/index.ts b/apps/ingest/src/index.ts
index 723fdae..eb1a019 100644
--- a/apps/ingest/src/index.ts
+++ b/apps/ingest/src/index.ts
@@ -15,7 +15,9 @@ import { createObs } from '#obs/client.ts';
import { isWindows } from '#core/platform.ts';
import { helix } from '#twitch/helix.ts';
import { createIrc } from '#twitch/irc.ts';
-import { readSecrets } from '#twitch/secrets.ts';
+import { readSecrets, updateSecrets } from '#twitch/secrets.ts';
+import crypto from 'node:crypto';
+import os from 'node:os';
import { streamWechsel } from '#twitch/stream-state.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createClipper } from './clips.ts';
@@ -116,19 +118,21 @@ 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
+ // ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Einzurichten ist
+ // nichts: beim ersten Start wird ein Schluessel gewuerfelt, der PC meldet
+ // sich beim Hub, und im Pult erscheint "Freigeben".
+ const fernZugang = fernZugangHolen(cfg.fern.url);
+ const fern = fernZugang
? createFernanschluss({
url: fernZugang.url,
key: fernZugang.key,
+ name: os.hostname(),
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)');
+ else log.info('Pult-Fernsteuerung aus (fern.url ist leer)');
// ---- Go-Live-Ankuendigung auf Discord
const announcer = createAnnouncer({
@@ -221,6 +225,22 @@ export async function runIngest(): Promise {
await new Promise((resolve) => abort.signal.addEventListener('abort', () => resolve()));
}
+/**
+ * Adresse und Schluessel fuer die Leitung zum Hub. Was mit
+ * `abdulive fern:verbinden` von Hand eingetragen ist, gewinnt. Sonst wird
+ * einmal gewuerfelt und gemerkt - der Hub kennt den Schluessel erst nach der
+ * Freigabe im Pult, also darf er sich nicht bei jedem Start aendern.
+ */
+function fernZugangHolen(vorgabeUrl: string): { url: string; key: string } | null {
+ const da = readSecrets().fern;
+ if (da?.url && da.key) return da;
+ if (!vorgabeUrl) return null;
+ const neu = { url: vorgabeUrl, key: crypto.randomBytes(24).toString('base64url') };
+ updateSecrets((s) => { s.fern = neu; });
+ log.info(`Neuer Schluessel fuer das Pult gewuerfelt - meldet sich bei ${vorgabeUrl}`);
+ return neu;
+}
+
type State = {
streamId: string | null; broadcasterId: string | null;
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
diff --git a/apps/overlay-server/src/auth.ts b/apps/overlay-server/src/auth.ts
index 0d7a161..986f9f8 100644
--- a/apps/overlay-server/src/auth.ts
+++ b/apps/overlay-server/src/auth.ts
@@ -39,6 +39,8 @@ export type Zugang = {
darfFern: (req: http.IncomingMessage) => boolean;
fernKey: () => string;
neuerFernKey: () => string;
+ /** Den Schluessel eines im Pult freigegebenen PCs eintragen. */
+ fernKeySetzen: (key: string) => void;
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
hatPasswort: () => boolean;
};
@@ -184,6 +186,9 @@ export function createZugang(): Zugang {
return Boolean(gegeben && gleich(gegeben, fernKey()));
},
fernKey,
+ fernKeySetzen(k) {
+ updateSecrets((s) => { s.web = { ...s.web, fernKey: k }; });
+ },
neuerFernKey() {
const neu = crypto.randomBytes(24).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts
index 6f8c709..fe3dc74 100644
--- a/apps/overlay-server/src/bus.ts
+++ b/apps/overlay-server/src/bus.ts
@@ -132,6 +132,12 @@ export type OverlayEvent =
| { kind: 'sound-stopp' }
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
| { kind: 'sounds-geaendert' }
+ /**
+ * Ein Stream-PC wartet auf Freigabe, oder die Liste hat sich geaendert.
+ * Ohne Inhalt: wer und von wo, holt sich das Pult mit Anmeldung - der
+ * Ereignisstrom geht auch an die Browser-Sources.
+ */
+ | { kind: 'fern-anfrage' }
| ObsStandEvent
| { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number }
diff --git a/apps/overlay-server/src/fern.test.ts b/apps/overlay-server/src/fern.test.ts
index 18b96bc..d5b8c76 100644
--- a/apps/overlay-server/src/fern.test.ts
+++ b/apps/overlay-server/src/fern.test.ts
@@ -114,3 +114,39 @@ test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
assert.equal(sichererPfad('//boese.example'), '/');
assert.equal(sichererPfad(null), '/');
});
+
+test('ein unbekannter PC wartet auf Freigabe und bekommt dann seinen Schluessel eingetragen', () => {
+ const bus = createBus();
+ const ereignisse: string[] = [];
+ bus.subscribe((e) => ereignisse.push(e.kind));
+ const fern = createFernsteuerung(bus);
+ const key = 'k'.repeat(32);
+
+ fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
+ fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
+ const [a, ...rest] = fern.offeneAnfragen();
+ assert.equal(rest.length, 0, 'derselbe PC fragt alle paar Sekunden, das ist eine Anfrage');
+ assert.equal(a?.name, 'ABDU-PC');
+ assert.equal('key' in (a as object), false, 'der Schluessel geht nie ans Pult');
+ assert.ok(ereignisse.includes('fern-anfrage'));
+
+ assert.equal(fern.freigeben(a!.id), key);
+ assert.deepEqual(fern.offeneAnfragen(), []);
+ assert.equal(fern.freigeben(a!.id), null, 'zweimal freigeben geht nicht');
+});
+
+test('mehr als drei fremde Anfragen verdraengen die aeltesten', () => {
+ const fern = createFernsteuerung(createBus());
+ for (let i = 0; i < 5; i++) fern.anfragen(`schluessel-${i}`.padEnd(30, 'x'), `PC${i}`, '');
+ assert.deepEqual(fern.offeneAnfragen().map((a) => a.name).sort(), ['PC2', 'PC3', 'PC4']);
+});
+
+test('beim Verbinden erfaehrt der PC die Adresse der Sound-Quelle', () => {
+ const fern = createFernsteuerung(createBus(), {
+ willkommen: () => ({ soundsUrl: 'https://hub.example/sounds?key=abc' }),
+ });
+ const l = leitung();
+ fern.annehmen(l.req, l.res);
+ const [hallo] = l.befehle();
+ assert.deepEqual(hallo, { art: 'einrichten', soundsUrl: 'https://hub.example/sounds?key=abc' });
+});
diff --git a/apps/overlay-server/src/fern.ts b/apps/overlay-server/src/fern.ts
index 2f54d5d..1343553 100644
--- a/apps/overlay-server/src/fern.ts
+++ b/apps/overlay-server/src/fern.ts
@@ -32,9 +32,28 @@ export type FernBefehl =
export type FernAntwort = { ok: boolean; text: string };
+/**
+ * Ein Stream-PC, der sich mit einem noch unbekannten Schluessel meldet. Er
+ * wartet, bis jemand im Pult auf "Freigeben" tippt - damit Abdu keinen Befehl
+ * abtippen muss. Der Schluessel selbst geht nie ans Pult zurueck.
+ */
+export type FernAnfrage = { id: string; name: string; adresse: string; seit: string };
+
+/** So viele Anfragen gleichzeitig, so lange gueltig. Mehr ist Laerm oder ein Angriff. */
+const MAX_ANFRAGEN = 3;
+const ANFRAGE_MS = 10 * 60_000;
+
export type Fernsteuerung = {
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
+ /** Ein unbekannter Schluessel klopft an. */
+ anfragen: (key: string, name: string, adresse: string) => void;
+ offeneAnfragen: () => FernAnfrage[];
+ /** Gibt den Schluessel der Anfrage zurueck, damit er eingetragen werden kann. */
+ freigeben: (id: string) => string | null;
+ ablehnen: (id: string) => boolean;
+ /** Leitung schliessen, z. B. nachdem der Schluessel neu gewuerfelt wurde. */
+ trennen: () => void;
/** Stand von OBS, wie ihn der PC meldet. */
melden: (roh: unknown) => void;
antwort: (roh: unknown) => void;
@@ -66,15 +85,77 @@ export function standAus(roh: unknown): Omit {
};
}
-export function createFernsteuerung(bus: Bus): Fernsteuerung {
+export type FernOptionen = {
+ /**
+ * Was der PC gleich nach dem Verbinden erfaehrt, z. B. die Adresse der
+ * Sound-Quelle mit Schluessel. Damit richtet er OBS selbst ein.
+ */
+ willkommen?: () => Record | null;
+};
+
+export function createFernsteuerung(bus: Bus, fo: FernOptionen = {}): Fernsteuerung {
let leitung: http.ServerResponse | null = null;
const offen = new Map void; timer: NodeJS.Timeout }>();
+ const anfragen = new Map();
const veroeffentlichen = (stand: Omit): void => {
bus.publish({ kind: 'obs', ...stand });
};
+ const aufraeumen = (): void => {
+ const jetzt = Date.now();
+ for (const [id, a] of anfragen) if (jetzt - a.zuletzt > ANFRAGE_MS) anfragen.delete(id);
+ };
+
return {
+ anfragen(key, name, adresse) {
+ aufraeumen();
+ // Derselbe PC klopft alle paar Sekunden erneut an. Das ist dieselbe
+ // Anfrage, keine neue - sonst stuende er nach einer Minute zehnmal da.
+ for (const a of anfragen.values()) {
+ if (a.key === key) { a.zuletzt = Date.now(); return; }
+ }
+ if (anfragen.size >= MAX_ANFRAGEN) {
+ const aelteste = [...anfragen.values()].sort((x, y) => x.zuletzt - y.zuletzt)[0];
+ if (aelteste) anfragen.delete(aelteste.id);
+ }
+ const id = crypto.randomBytes(6).toString('hex');
+ const sauber = (s: string, n: number): string =>
+ s.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, n) || 'unbekannt';
+ anfragen.set(id, {
+ id, key, name: sauber(name, 40), adresse: sauber(adresse, 60),
+ seit: new Date().toISOString(), zuletzt: Date.now(),
+ });
+ log.info(`Stream-PC "${sauber(name, 40)}" (${sauber(adresse, 60)}) wartet auf Freigabe im Pult`);
+ bus.publish({ kind: 'fern-anfrage' });
+ },
+
+ offeneAnfragen() {
+ aufraeumen();
+ return [...anfragen.values()].map(({ id, name, adresse, seit }) => ({ id, name, adresse, seit }));
+ },
+
+ freigeben(id) {
+ aufraeumen();
+ const a = anfragen.get(id);
+ if (!a) return null;
+ // Es gibt nur einen Stream-PC. Wer freigibt, verwirft die anderen.
+ anfragen.clear();
+ log.info(`Stream-PC "${a.name}" freigegeben`);
+ bus.publish({ kind: 'fern-anfrage' });
+ return a.key;
+ },
+
+ ablehnen(id) {
+ const weg = anfragen.delete(id);
+ if (weg) bus.publish({ kind: 'fern-anfrage' });
+ return weg;
+ },
+
+ trennen() {
+ leitung?.end();
+ },
+
annehmen(req, res) {
if (leitung) {
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
@@ -92,6 +173,8 @@ export function createFernsteuerung(bus: Bus): Fernsteuerung {
leitung = res;
log.info('Stream-PC verbunden');
veroeffentlichen({ ...LEER, pc: true });
+ const hallo = fo.willkommen?.();
+ if (hallo) res.write(`data: ${JSON.stringify({ art: 'einrichten', ...hallo })}\n\n`);
const beat = setInterval(
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
diff --git a/apps/overlay-server/src/server.ts b/apps/overlay-server/src/server.ts
index 2cead66..c7a8a93 100644
--- a/apps/overlay-server/src/server.ts
+++ b/apps/overlay-server/src/server.ts
@@ -23,7 +23,7 @@ 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 { bearer, 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';
@@ -130,7 +130,14 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
const lz: Laufzeit = {
vorfuehrung: { abbruch: null },
sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')),
- fern: createFernsteuerung(bus),
+ // Der PC bekommt beim Verbinden die Adresse der Sound-Quelle und legt sie
+ // damit selbst in OBS an. Der Overlay-Schluessel darf nur lesen, und der
+ // PC haette ihn ohnehin - er steht in jeder Browser-Source in OBS.
+ fern: createFernsteuerung(bus, {
+ willkommen: () => ({
+ soundsUrl: `${opts.publicUrl || `http://127.0.0.1:${opts.port}`}/sounds?key=${zugang.overlayKey()}`,
+ }),
+ }),
quellen: new Map(),
};
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
@@ -210,7 +217,18 @@ async function handle(
// ---- 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 (!zugang.darfFern(req)) {
+ // Ein unbekannter Schluessel an der Leitung ist ein PC, der sich
+ // anmelden will. Er wartet auf "Freigeben" im Pult und fragt so lange
+ // nach. Alles andere unter /fern/ bleibt ohne gueltigen Schluessel zu.
+ const key = bearer(req.headers.authorization);
+ if (p === '/fern/befehle' && req.method === 'GET' && key && key.length >= 24 && key.length <= 128) {
+ fern.anfragen(key, String(req.headers['x-abdulive-rechner'] ?? ''), absender(req));
+ return send(res, 202, 'application/json; charset=utf-8',
+ JSON.stringify({ text: 'Wartet auf Freigabe im Pult.' }));
+ }
+ 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;
@@ -285,6 +303,32 @@ async function handle(
tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null,
}));
}
+ if (p === '/pult/fern') {
+ const liste = (code: number, text: string): void =>
+ send(res, code, 'application/json; charset=utf-8', JSON.stringify({
+ text, anfragen: fern.offeneAnfragen(), verbunden: fern.verbunden(),
+ }));
+ if (req.method === 'GET') return liste(200, '');
+ const form = await readForm(req);
+ const was = form.get('was');
+ if (was === 'freigeben') {
+ const key = fern.freigeben(form.get('id') ?? '');
+ if (!key) return liste(404, 'Die Anfrage ist abgelaufen. Der PC fragt gleich neu an.');
+ zugang.fernKeySetzen(key);
+ // Die alte Leitung (falls es eine gab) gehoert zu einem anderen Schluessel.
+ fern.trennen();
+ return liste(200, 'Freigegeben. Der Stream-PC verbindet sich in ein paar Sekunden.');
+ }
+ if (was === 'ablehnen') {
+ return liste(200, fern.ablehnen(form.get('id') ?? '') ? 'Abgelehnt.' : 'Schon weg.');
+ }
+ if (was === 'trennen') {
+ zugang.neuerFernKey();
+ fern.trennen();
+ return liste(200, 'Getrennt. Der PC muss neu freigegeben werden.');
+ }
+ return liste(400, 'Unbekannt.');
+ }
if (p === '/pult/obs' && req.method === 'POST') {
const form = await readForm(req);
const befehl = befehlAus(form);
@@ -410,10 +454,15 @@ async function handle(
if (p === '/schluessel/neu' && req.method === 'POST') {
zugang.neuerOverlayKey();
+ // Der PC verbindet sich neu, bekommt die neue Adresse der Sound-Quelle
+ // und traegt sie in OBS ein. Die uebrigen Browser-Sources nicht - die hat
+ // jemand von Hand angelegt, und nur der weiss, wo sie liegen.
+ fern.trennen();
return redirect(res, '/');
}
if (p === '/fernschluessel/neu' && req.method === 'POST') {
zugang.neuerFernKey();
+ fern.trennen();
return redirect(res, '/#stream-pc');
}
@@ -588,6 +637,16 @@ export function sichererPfad(roh: string | null | undefined): string {
return roh === '/pult' ? '/pult' : '/';
}
+/**
+ * Woher eine Anfrage kommt. Hinter dem Proxy steht die echte Adresse im Kopf,
+ * die eigene Verbindung kommt immer vom Proxy. Nur zur Anzeige bei der
+ * Freigabe - nichts haengt daran, ob sie stimmt.
+ */
+function absender(req: http.IncomingMessage): string {
+ const kopf = req.headers['x-real-ip'] ?? String(req.headers['x-forwarded-for'] ?? '').split(',')[0];
+ return String(kopf || req.socket.remoteAddress || '').trim();
+}
+
function readBody(req: http.IncomingMessage, max: number): Promise {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -856,12 +915,16 @@ ${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)}
-
+kann, wählt sich der Stream-PC hier ein. Das macht er von selbst: beim ersten
+Start meldet er sich, und im Pult unter Szenen erscheint
+„Freigeben“. Danach legt er in OBS auch die Sound-Quelle selbst an.
${
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}
+Von Hand verbinden, falls die Freigabe nicht klappt
+
${escape(fernBefehl)}
+
+
Auf dem Stream-PC in PowerShell, danach abdulive ingest neu starten.