From 54dbe1265bb22970ea0b4a14f475412a677aa388 Mon Sep 17 00:00:00 2001 From: ZeasyAlex Date: Mon, 26 Oct 2026 13:03:06 +0100 Subject: [PATCH] Pult: Stream-PC per Tipp freigeben, Sound-Quelle legt er selbst in OBS an - ingest wuerfelt beim ersten Start einen Schluessel und meldet sich beim Hub; der antwortet 202, bis im Pult jemand auf Freigeben tippt - nach dem Verbinden schickt der Hub die Adresse von /sounds, ingest legt Szene "abdulive Ton" mit der Browser-Source an und haengt sie in jede Szene - fern:verbinden bleibt als Weg von Hand Co-Authored-By: Claude Opus 5.5 --- .../console-2026-10-26T12-01-16-482Z.log | 93 +++++++++++ apps/ingest/src/fern.test.ts | 84 +++++++++- apps/ingest/src/fern.ts | 153 ++++++++++++++++-- apps/ingest/src/index.ts | 32 +++- apps/overlay-server/src/auth.ts | 5 + apps/overlay-server/src/bus.ts | 6 + apps/overlay-server/src/fern.test.ts | 36 +++++ apps/overlay-server/src/fern.ts | 85 +++++++++- apps/overlay-server/src/server.ts | 77 ++++++++- apps/overlay-web/README.md | 31 +++- apps/overlay-web/public/pult.css | 11 ++ apps/overlay-web/public/pult.html | 4 + apps/overlay-web/public/pult.js | 60 ++++++- docs/handoff.md | 35 ++-- packages/core/src/config.ts | 13 ++ 15 files changed, 675 insertions(+), 50 deletions(-) create mode 100644 .playwright-mcp/console-2026-10-26T12-01-16-482Z.log 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.

+
Dieser Schlüssel schaltet OBS. Nie in eine Browser-Source eintragen, diff --git a/apps/overlay-web/README.md b/apps/overlay-web/README.md index d9db277..c07a997 100644 --- a/apps/overlay-web/README.md +++ b/apps/overlay-web/README.md @@ -114,15 +114,32 @@ Sounds gleichzeitig (`?max=`), `?laut=` dämpft alle. **Szenen** gehen über den Stream-PC, weil der Hub OBS nicht erreicht. Der PC wählt sich mit eigenem Schlüssel beim Hub ein (`apps/ingest/src/fern.ts` und -`apps/overlay-server/src/fern.ts`). Einmal auf dem Stream-PC: +`apps/overlay-server/src/fern.ts`). In OBS muss nur obs-websocket an sein - +dasselbe, was `!clip` braucht. -``` -abdulive fern:verbinden https://abdu.dimension47.de -``` +Einzurichten ist nichts. Beim ersten Start würfelt `ingest` einen Schlüssel +und meldet sich bei `fern.url` (Vorgabe `https://abdu.dimension47.de`, +`FERN_URL=` schaltet ab). Im Pult unter Szenen steht dann „Stream-PC … möchte +sich verbinden“ mit **Freigeben**. Ein Tipp, nach ein paar Sekunden ist er da. +„Stream-PC trennen“ unten im Reiter würfelt den Schlüssel neu; danach muss er +neu freigegeben werden. `abdulive fern:verbinden ` bleibt +als Weg von Hand (Befehl auf der Übersicht). -Der fertige Befehl steht auf der Übersicht unter „Stream-PC verbinden“. -Danach `abdulive ingest` neu starten. Jeder Knopf wartet auf die Antwort von -OBS und sagt ehrlich, wenn es nicht ging. +Jeder Knopf wartet auf die Antwort von OBS und sagt ehrlich, wenn es nicht ging. + +**Die Sound-Quelle legt der PC selbst an.** Nach dem Verbinden schickt der Hub +die Adresse von `/sounds` mit Schlüssel, und `ingest` legt in OBS an: + +- die Szene `abdulive Ton` mit der Browser-Source `abdulive Sounds` darin + („Audio über OBS steuern“ an, beim Unsichtbarwerden nicht abschalten); +- in **jeder** Szene ein Verweis auf `abdulive Ton`. OBS gibt Ton nur aus + Quellen der gerade gezeigten Szene aus; so gibt es die Quelle einmal, sie + läuft beim Szenenwechsel durch und ist überall zu hören. + +Neue Szenen bekommen den Verweis sofort, ein neuer Overlay-Schlüssel wird in +die Quelle nachgetragen. Die Ton-Szene taucht im Pult nicht als Szene auf, die +Quelle aber unter Ton - damit lässt sich das Soundboard stumm schalten. Wer den +Verweis aus einer Szene löscht, bekommt ihn beim nächsten Start zurück. Die App-Symbole zeichnet `node scripts/make-icons.ts`. diff --git a/apps/overlay-web/public/pult.css b/apps/overlay-web/public/pult.css index 9de697d..a0a2139 100644 --- a/apps/overlay-web/public/pult.css +++ b/apps/overlay-web/public/pult.css @@ -278,6 +278,17 @@ main { .ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; } .ton-zeile.stumm svg { color: #ffb3b3; } +.anfragen { display: grid; gap: 12px; margin-bottom: 4px; } +.anfrage { + border-radius: var(--rund); padding: 16px; margin-bottom: 12px; + background: rgba(169, 112, 255, .1); border: 1px solid var(--lila); +} +.anfrage h2 { font-size: 18px; margin: 0 0 4px; } +.anfrage p { margin: 0 0 14px; color: var(--leise); font-size: 14px; } +.anfrage .reihe { display: flex; gap: 8px; } +.anfrage .reihe .primaer { flex: 1; min-height: 52px; font-size: 16px; } +.knopf.leise { margin-top: 28px; background: transparent; color: var(--leise); border-color: var(--linie); font-weight: 500; } + .leer-karte { text-align: center; padding: 28px 18px; } .leer-karte h2 { font-size: 18px; margin: 0 0 8px; } .leer-karte p { color: var(--leise); margin: 0; font-size: 14px; } diff --git a/apps/overlay-web/public/pult.html b/apps/overlay-web/public/pult.html index 15013c3..7b1ce26 100644 --- a/apps/overlay-web/public/pult.html +++ b/apps/overlay-web/public/pult.html @@ -66,6 +66,7 @@
+
@@ -140,6 +143,7 @@