Pult: Stream-PC per Tipp freigeben, Sound-Quelle legt er selbst in OBS an
Paket bauen und veroeffentlichen / paket (push) Successful in 42s

- 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 <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-10-26 13:03:06 +01:00
1 parent 32416d1916
commit 54dbe1265b
15 files changed
+675 -50

No files matched your search

@@ -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
+83 -1
View File
@@ -3,7 +3,9 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { ObsClient } from '#obs/client.ts'; 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', () => { test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
const z = sseZerleger(); const z = sseZerleger();
@@ -67,3 +69,83 @@ test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
assert.equal(s.puffer, false); assert.equal(s.puffer, false);
assert.deepEqual(s.szenen, ['A']); 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<string, string[]>(szenen.map((s) => [s, []]));
const quellen = new Map<string, Record<string, unknown>>();
const aufrufe: string[] = [];
const obs: ObsClient = {
run: async () => {},
isConnected: () => true,
request: async <T>(type: string, data?: unknown): Promise<T> => {
const d = (data ?? {}) as Record<string, unknown>;
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<string, unknown>);
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']);
});
+143 -10
View File
@@ -8,9 +8,13 @@
// POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub) // POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub)
// POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub) // POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub)
// //
// Gegenstueck: apps/overlay-server/src/fern.ts. Eingerichtet wird mit // Gegenstueck: apps/overlay-server/src/fern.ts.
// `abdulive fern:verbinden <adresse> <schluessel>`; beides steht fertig auf //
// der Uebersichtsseite des Hubs. // 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 { logger } from '#core/logger.ts';
import { forever, sleep } from '#core/retry.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. */ /** Der Hub schickt alle 15 s einen Herzschlag. Drei verpasste heisst: tot. */
const TOT_NACH_MS = 45_000; 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. */ /** Nach diesen OBS-Ereignissen stimmt der gemeldete Stand nicht mehr. */
const STAND_EREIGNISSE = new Set([ const STAND_EREIGNISSE = new Set([
'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved', 'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
@@ -41,6 +57,21 @@ export type Befehl =
| { id: string; art: 'clip' } | { id: string; art: 'clip' }
| { id: string; art: 'stumm'; quelle: string; stumm: boolean }; | { 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<string, unknown>;
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 * Zerlegt einen SSE-Strom in Nachrichten. Bekommt beliebige Stuecke - eine
* Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem * Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem
@@ -90,7 +121,9 @@ export async function standLesen(obs: ObsClient): Promise<Stand> {
// OBS zaehlt von unten. Im Fenster steht die mit dem hoechsten Index oben, // OBS zaehlt von unten. Im Fenster steht die mit dem hoechsten Index oben,
// und in dieser Reihenfolge erwartet man sie auch am Handy. // und in dieser Reihenfolge erwartet man sie auch am Handy.
const szenen = [...liste.scenes] 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'); const eingaenge = await obs.request<{ inputs: Array<{ inputName: string }> }>('GetInputList');
// Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute // Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute
@@ -110,9 +143,63 @@ export async function standLesen(obs: ObsClient): Promise<Stand> {
} }
} }
/**
* 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<string[]> {
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<string, unknown> }>(
'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 = { export type FernDeps = {
url: string; url: string;
key: string; key: string;
/** So erscheint der PC bei der Freigabe im Pult. */
name: string;
obs: ObsClient; obs: ObsClient;
/** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */ /** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
clip: () => Promise<{ ok: boolean; reason?: string }>; clip: () => Promise<{ ok: boolean; reason?: string }>;
@@ -127,11 +214,20 @@ export type FernAnschluss = {
verbunden: () => boolean; verbunden: () => boolean;
}; };
export function createFernanschluss(deps: FernDeps): FernAnschluss { export function createFernanschluss(deps: FernDeps): FernAnschluss {
const basis = deps.url.replace(/\/+$/, ''); 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 verbunden = false;
let sammler: NodeJS.Timeout | null = null; 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<void> { async function post(pfad: string, body: unknown): Promise<void> {
try { try {
@@ -159,6 +255,21 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300); sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300);
}; };
/** Sound-Quelle in OBS anlegen bzw. pruefen. Nie zweimal gleichzeitig. */
const einrichten = async (): Promise<void> => {
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 }> { async function ausfuehren(b: Befehl): Promise<{ ok: boolean; text: string }> {
if (b.art === 'clip') { if (b.art === 'clip') {
const r = await deps.clip(); const r = await deps.clip();
@@ -190,9 +301,24 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
}, 10_000); }, 10_000);
try { try {
const res = await fetch(`${basis}/fern/befehle`, { let res: Response;
headers: { ...kopf, accept: 'text/event-stream' }, signal: beides, 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) { if (res.status === 403) {
log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' + log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
'von der Uebersichtsseite: abdulive fern:verbinden ...'); '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 }))) { for (const daten of zerleger(decoder.decode(stueck, { stream: true }))) {
let roh: unknown; let roh: unknown;
try { roh = JSON.parse(daten); } catch { continue; } try { roh = JSON.parse(daten); } catch { continue; }
const e = einrichtenAus(roh);
if (e) { soundsUrl = e.soundsUrl; void einrichten(); continue; }
const b = befehlAus(roh); const b = befehlAus(roh);
if (!b) continue; if (!b) continue;
void ausfuehren(b).then((a) => { 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); if (n >= 3) log.warn(zeile); else log.debug(zeile);
}, },
}), }),
obsEreignis: (type) => { if (STAND_EREIGNISSE.has(type)) spaeterSenden(); }, obsEreignis: (type) => {
obsWechsel: spaeterSenden, 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, verbunden: () => verbunden,
}; };
} }
+26 -6
View File
@@ -15,7 +15,9 @@ import { createObs } from '#obs/client.ts';
import { isWindows } from '#core/platform.ts'; import { isWindows } from '#core/platform.ts';
import { helix } from '#twitch/helix.ts'; import { helix } from '#twitch/helix.ts';
import { createIrc } from '#twitch/irc.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 { streamWechsel } from '#twitch/stream-state.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts'; import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createClipper } from './clips.ts'; import { createClipper } from './clips.ts';
@@ -116,19 +118,21 @@ export async function runIngest(): Promise<void> {
onRawReady: (job) => renderer.enqueue(job), onRawReady: (job) => renderer.enqueue(job),
}); });
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Nur, wenn mit // ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Einzurichten ist
// `abdulive fern:verbinden` eingerichtet - sonst laeuft alles wie vorher. // nichts: beim ersten Start wird ein Schluessel gewuerfelt, der PC meldet
const fernZugang = readSecrets().fern; // sich beim Hub, und im Pult erscheint "Freigeben".
const fern = fernZugang?.url && fernZugang.key const fernZugang = fernZugangHolen(cfg.fern.url);
const fern = fernZugang
? createFernanschluss({ ? createFernanschluss({
url: fernZugang.url, url: fernZugang.url,
key: fernZugang.key, key: fernZugang.key,
name: os.hostname(),
obs, obs,
clip: () => clipper.trigger('manual', { login: 'Pult' }), clip: () => clipper.trigger('manual', { login: 'Pult' }),
}) })
: null; : null;
if (fern) void fern.run(abort.signal); 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 // ---- Go-Live-Ankuendigung auf Discord
const announcer = createAnnouncer({ const announcer = createAnnouncer({
@@ -221,6 +225,22 @@ export async function runIngest(): Promise<void> {
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve())); await new Promise<void>((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 = { type State = {
streamId: string | null; broadcasterId: string | null; streamId: string | null; broadcasterId: string | null;
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null; lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
+5
View File
@@ -39,6 +39,8 @@ export type Zugang = {
darfFern: (req: http.IncomingMessage) => boolean; darfFern: (req: http.IncomingMessage) => boolean;
fernKey: () => string; fernKey: () => string;
neuerFernKey: () => 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. */ /** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
hatPasswort: () => boolean; hatPasswort: () => boolean;
}; };
@@ -184,6 +186,9 @@ export function createZugang(): Zugang {
return Boolean(gegeben && gleich(gegeben, fernKey())); return Boolean(gegeben && gleich(gegeben, fernKey()));
}, },
fernKey, fernKey,
fernKeySetzen(k) {
updateSecrets((s) => { s.web = { ...s.web, fernKey: k }; });
},
neuerFernKey() { neuerFernKey() {
const neu = crypto.randomBytes(24).toString('base64url'); const neu = crypto.randomBytes(24).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; }); updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
+6
View File
@@ -132,6 +132,12 @@ export type OverlayEvent =
| { kind: 'sound-stopp' } | { kind: 'sound-stopp' }
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */ /** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
| { kind: 'sounds-geaendert' } | { 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 | ObsStandEvent
| { kind: 'clear'; userId?: string; messageId?: string } | { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number } | { kind: 'beat'; t: number }
+36
View File
@@ -114,3 +114,39 @@ test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
assert.equal(sichererPfad('//boese.example'), '/'); assert.equal(sichererPfad('//boese.example'), '/');
assert.equal(sichererPfad(null), '/'); 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' });
});
+84 -1
View File
@@ -32,9 +32,28 @@ export type FernBefehl =
export type FernAntwort = { ok: boolean; text: string }; 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 = { export type Fernsteuerung = {
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */ /** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void; 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. */ /** Stand von OBS, wie ihn der PC meldet. */
melden: (roh: unknown) => void; melden: (roh: unknown) => void;
antwort: (roh: unknown) => void; antwort: (roh: unknown) => void;
@@ -66,15 +85,77 @@ export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
}; };
} }
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<string, unknown> | null;
};
export function createFernsteuerung(bus: Bus, fo: FernOptionen = {}): Fernsteuerung {
let leitung: http.ServerResponse | null = null; let leitung: http.ServerResponse | null = null;
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>(); const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
const anfragen = new Map<string, FernAnfrage & { key: string; zuletzt: number }>();
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => { const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
bus.publish({ kind: 'obs', ...stand }); 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 { 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) { annehmen(req, res) {
if (leitung) { if (leitung) {
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf. // Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
@@ -92,6 +173,8 @@ export function createFernsteuerung(bus: Bus): Fernsteuerung {
leitung = res; leitung = res;
log.info('Stream-PC verbunden'); log.info('Stream-PC verbunden');
veroeffentlichen({ ...LEER, pc: true }); veroeffentlichen({ ...LEER, pc: true });
const hallo = fo.willkommen?.();
if (hallo) res.write(`data: ${JSON.stringify({ art: 'einrichten', ...hallo })}\n\n`);
const beat = setInterval( const beat = setInterval(
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`), () => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
+70 -7
View File
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
import { paths } from '#core/config.ts'; import { paths } from '#core/config.ts';
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.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 { createBus, type Bus, type OverlayEvent } from './bus.ts';
import { startDemo } from './demo.ts'; import { startDemo } from './demo.ts';
import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts'; import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
@@ -130,7 +130,14 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
const lz: Laufzeit = { const lz: Laufzeit = {
vorfuehrung: { abbruch: null }, vorfuehrung: { abbruch: null },
sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')), 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(), quellen: new Map(),
}; };
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne // 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 // ---- Die Leitung zum Stream-PC. Eigener Schluessel im Kopf, nie in der
// Adresse: wer ihn hat, schaltet Szenen um. // Adresse: wer ihn hat, schaltet Szenen um.
if (p.startsWith('/fern/')) { 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/befehle' && req.method === 'GET') return fern.annehmen(req, res);
if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') { if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') {
let roh: unknown; let roh: unknown;
@@ -285,6 +303,32 @@ async function handle(
tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null, 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') { if (p === '/pult/obs' && req.method === 'POST') {
const form = await readForm(req); const form = await readForm(req);
const befehl = befehlAus(form); const befehl = befehlAus(form);
@@ -410,10 +454,15 @@ async function handle(
if (p === '/schluessel/neu' && req.method === 'POST') { if (p === '/schluessel/neu' && req.method === 'POST') {
zugang.neuerOverlayKey(); 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, '/'); return redirect(res, '/');
} }
if (p === '/fernschluessel/neu' && req.method === 'POST') { if (p === '/fernschluessel/neu' && req.method === 'POST') {
zugang.neuerFernKey(); zugang.neuerFernKey();
fern.trennen();
return redirect(res, '/#stream-pc'); return redirect(res, '/#stream-pc');
} }
@@ -588,6 +637,16 @@ export function sichererPfad(roh: string | null | undefined): string {
return roh === '/pult' ? '/pult' : '/'; 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<Buffer> { function readBody(req: http.IncomingMessage, max: number): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -856,12 +915,16 @@ ${lzFeld}
<h2 id="stream-pc">Stream-PC verbinden</h2> <h2 id="stream-pc">Stream-PC verbinden</h2>
<p class="lead">Damit das Pult Szenen umschalten, Clips speichern und Ton stummschalten <p class="lead">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 kann, wählt sich der Stream-PC hier ein. Das macht er von selbst: beim ersten
ausführen, danach <code>abdulive ingest</code> neu starten:</p> Start meldet er sich, und im Pult unter <a href="/pult#szenen">Szenen</a> erscheint
<p><code id="fernbefehl">${escape(fernBefehl)}</code> „Freigeben“. Danach legt er in OBS auch die Sound-Quelle selbst an.</p>
<button data-copy="fernbefehl">kopieren</button></p>
<p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${ <p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p> fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p>
<details><summary class="hint">Von Hand verbinden, falls die Freigabe nicht klappt</summary>
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
<button data-copy="fernbefehl">kopieren</button></p>
<p class="hint">Auf dem Stream-PC in PowerShell, danach <code>abdulive ingest</code> neu starten.</p>
</details>
<form method="post" action="/fernschluessel/neu"> <form method="post" action="/fernschluessel/neu">
<button type="submit" style="margin-left:0">Schlüssel neu würfeln</button> <button type="submit" style="margin-left:0">Schlüssel neu würfeln</button>
<span class="hint">Dieser Schlüssel schaltet OBS. Nie in eine Browser-Source eintragen, <span class="hint">Dieser Schlüssel schaltet OBS. Nie in eine Browser-Source eintragen,
+24 -7
View File
@@ -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 **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 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.
``` Einzurichten ist nichts. Beim ersten Start würfelt `ingest` einen Schlüssel
abdulive fern:verbinden https://abdu.dimension47.de <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 <adresse> <schlüssel>` bleibt
als Weg von Hand (Befehl auf der Übersicht).
Der fertige Befehl steht auf der Übersicht unter „Stream-PC verbinden“. Jeder Knopf wartet auf die Antwort von OBS und sagt ehrlich, wenn es nicht ging.
Danach `abdulive ingest` neu starten. 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`. Die App-Symbole zeichnet `node scripts/make-icons.ts`.
+11
View File
@@ -278,6 +278,17 @@ main {
.ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; } .ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; }
.ton-zeile.stumm svg { 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 { text-align: center; padding: 28px 18px; }
.leer-karte h2 { font-size: 18px; margin: 0 0 8px; } .leer-karte h2 { font-size: 18px; margin: 0 0 8px; }
.leer-karte p { color: var(--leise); margin: 0; font-size: 14px; } .leer-karte p { color: var(--leise); margin: 0; font-size: 14px; }
+4
View File
@@ -66,6 +66,7 @@
<!-- ======================================================== Szenen --> <!-- ======================================================== Szenen -->
<section id="tab-szenen" class="tab" aria-labelledby="nav-szenen"> <section id="tab-szenen" class="tab" aria-labelledby="nav-szenen">
<div id="anfragen" class="anfragen"></div>
<div id="obs-fehlt" class="karte leer-karte" hidden> <div id="obs-fehlt" class="karte leer-karte" hidden>
<h2 id="obs-fehlt-titel">Stream-PC nicht verbunden</h2> <h2 id="obs-fehlt-titel">Stream-PC nicht verbunden</h2>
<p id="obs-fehlt-text"></p> <p id="obs-fehlt-text"></p>
@@ -83,6 +84,8 @@
<h3 class="abschnitt">Ton</h3> <h3 class="abschnitt">Ton</h3>
<div id="ton" class="ton"></div> <div id="ton" class="ton"></div>
<button id="trennen" class="knopf breit leise" type="button">Stream-PC trennen</button>
</div> </div>
</section> </section>
@@ -140,6 +143,7 @@
<button id="nav-szenen" data-tab="szenen" type="button"> <button id="nav-szenen" data-tab="szenen" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="13" rx="2"/><path d="M8 21h8"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="13" rx="2"/><path d="M8 21h8"/></svg>
Szenen Szenen
<i id="nav-fern" class="punkt" hidden></i>
</button> </button>
<button id="nav-stream" data-tab="stream" type="button"> <button id="nav-stream" data-tab="stream" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
+58 -2
View File
@@ -369,8 +369,8 @@
$('obs-da').hidden = fehlt; $('obs-da').hidden = fehlt;
if (!o.pc) { if (!o.pc) {
$('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden'; $('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden';
$('obs-fehlt-text').textContent = 'Auf dem Stream-PC muss abdulive ingest laufen und einmal mit ' + $('obs-fehlt-text').textContent = 'Sobald abdulive auf dem Stream-PC läuft, meldet er sich hier ' +
'„abdulive fern:verbinden …“ eingerichtet sein. Der Befehl steht auf der Übersicht unter „Stream-PC verbinden“.'; 'und du tippst einmal auf „Freigeben“. Läuft er schon, ist er vielleicht aus oder offline.';
} else if (!o.obs) { } else if (!o.obs) {
$('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar'; $('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar';
$('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' + $('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' +
@@ -436,6 +436,60 @@
obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' }); obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' });
}); });
// ------------------------------------------------------------ Freigabe
// Ein Stream-PC, der sich zum ersten Mal meldet, wartet hier auf einen
// Tipp. Name und Adresse kommen vom PC selbst - eine Anfrage, die man nicht
// erwartet, lehnt man ab.
function zeigeAnfragen(liste) {
var box = $('anfragen');
box.textContent = '';
$('nav-fern').hidden = !liste.length;
liste.forEach(function (a) {
var k = el('div', 'anfrage');
k.appendChild(el('h2', '', 'Stream-PC „' + a.name + '“ möchte sich verbinden'));
var seit = new Date(a.seit);
k.appendChild(el('p', '', 'Von ' + a.adresse + ', seit ' +
seit.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) +
' Uhr. Danach steuert das Pult OBS auf diesem Rechner.'));
var reihe = el('div', 'reihe');
var ja = el('button', 'knopf primaer', 'Freigeben');
ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = a.id;
var nein = el('button', 'knopf', 'Ablehnen');
nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = a.id;
reihe.appendChild(ja); reihe.appendChild(nein);
k.appendChild(reihe);
box.appendChild(k);
});
}
function ladeAnfragen() {
holen('/pult/fern').then(function (b) { zeigeAnfragen(b.anfragen || []); }, function () { /* still */ });
}
$('anfragen').addEventListener('click', function (e) {
var b = e.target.closest('button[data-was]');
if (!b) return;
tippen();
mitWarten(b, rufen('/pult/fern', { was: b.dataset.was, id: b.dataset.id })).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (r.anfragen) zeigeAnfragen(r.anfragen);
});
});
$('trennen').addEventListener('click', function () {
var b = $('trennen');
if (!b.dataset.sicher) {
b.dataset.sicher = '1';
b.textContent = 'Wirklich trennen? Danach neu freigeben.';
setTimeout(function () { b.dataset.sicher = ''; b.textContent = 'Stream-PC trennen'; }, 4000);
return;
}
b.dataset.sicher = '';
b.textContent = 'Stream-PC trennen';
rufen('/pult/fern', { was: 'trennen' }).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); });
});
// ------------------------------------------------------------ Liegestuetze // ------------------------------------------------------------ Liegestuetze
function zeigeLz(st) { function zeigeLz(st) {
@@ -581,6 +635,7 @@
case 'sound': knall(ev.id); break; case 'sound': knall(ev.id); break;
case 'sounds-geaendert': ladeSounds(); break; case 'sounds-geaendert': ladeSounds(); break;
case 'tts': ladeWartend(); break; case 'tts': ladeWartend(); break;
case 'fern-anfrage': ladeAnfragen(); break;
} }
}; };
} }
@@ -611,6 +666,7 @@
ladeWartend(); ladeWartend();
}, function () { /* abgemeldet oder weg - zeigt sich oben */ }); }, function () { /* abgemeldet oder weg - zeigt sich oben */ });
ladeSounds(); ladeSounds();
ladeAnfragen();
verbinde(); verbinde();
wachHolen(); wachHolen();
+19 -16
View File
@@ -1,6 +1,6 @@
# Übergabe # Übergabe
Stand **2026-10-26**, letztes gebautes Paket 0.5.6. Wer hier weitermacht, Stand **2026-10-26**, letztes gebautes Paket 0.5.7. Wer hier weitermacht,
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
selbst gibt es das [Handbuch](handbuch/handbuch.pdf). selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
@@ -121,26 +121,29 @@ Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
## Das Pult: Handy als Stream Deck ## Das Pult: Handy als Stream Deck
Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.6, **nicht an echtem Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.7, **nicht an echtem
OBS geprüft**. Auf Abdus Rechner fehlt noch `abdulive update`, danach OBS geprüft**. Auf Abdus Rechner ist nichts auszuführen: das Update kommt über
`fern:verbinden` und die Browser-Source `/sounds` in OBS. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit die tägliche Aufgabe, danach meldet sich der PC von selbst, Abdu tippt im Pult
einmal „Freigeben“, und die Sound-Quelle legt `ingest` selbst in OBS an. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit
Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen, Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen,
Vorlese-Freigabe und den Testknöpfen. Beschreibung in Vorlese-Freigabe und den Testknöpfen. Beschreibung in
[`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult). [`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult).
- **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf - **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf
dem Hub unter `<db>/sounds/`, gespielt wird über die neue Browser-Source dem Hub unter `<db>/sounds/`, gespielt wird über die Browser-Source
`/sounds` (muss in OBS eingetragen werden, mit Ton). `abdulive Sounds`, die `ingest` selbst in OBS anlegt.
- **Szenen** brauchen die neue Leitung Stream-PC → Hub: einmal - **Szenen** laufen über die Leitung Stream-PC → Hub. Der PC meldet sich
`abdulive fern:verbinden <adresse> <schlüssel>` auf dem Stream-PC (Befehl beim ersten Start von selbst, die Freigabe ist ein Tipp im Pult. In OBS
steht auf der Übersicht), dann läuft sie in `abdulive ingest` mit. Das muss nur obs-websocket an sein.
heißt auch: **ein neues Paket für den Stream-PC** ist nötig, nicht nur - **Restrisiko der Freigabe:** Anfragen kann jeder, der die Adresse kennt.
ein Ausrollen des Hubs. Freigeben kann nur, wer im Pult angemeldet ist, und das Pult zeigt Name und
- Geprüft: 311 Tests, Typprüfung, ein Durchlauf im Browser gegen einen Adresse des Rechners. Wer etwas freigibt, das er nicht erwartet, gibt einem
nachgebauten Stream-PC mit falschem OBS (Szenenwechsel, Stummschalten, Fremden Szenen, Stummschalter und Clip-Knopf - nicht mehr.
abgelehnte Szene, Hochladen, Abspielen in der Sound-Quelle). Ungeprüft: - Geprüft: 319 Tests, Typprüfung, Durchläufe im Browser gegen einen
echtes OBS, iPhone/Android-Installation, der Weg durch Nginx Proxy Manager nachgebauten Stream-PC mit falschem OBS (Freigabe, Einrichten der
für die lange offene `/fern/befehle`-Leitung und Uploads bis 8 MB. Sound-Quelle, Szenenwechsel, Stummschalten, Hochladen, Abspielen); 8 MB
Upload durch den Proxy. Ungeprüft: echtes OBS (vor allem das Anlegen der
Quelle), iPhone/Android-Installation.
## Kanalpunkte: „Nachricht vorlesen" läuft ## Kanalpunkte: „Nachricht vorlesen" läuft
+13
View File
@@ -80,6 +80,12 @@ export type Config = {
* Redirect-URI passen muss. * Redirect-URI passen muss.
*/ */
publicUrl: string; publicUrl: string;
/**
* Wo der Hub steht, bei dem sich der Stream-PC fuers Pult anmeldet. Als
* Vorgabe fest eingetragen, damit auf Abdus Rechner nichts einzurichten
* ist. Leer heisst: keine Fernsteuerung (auch per FERN_URL= abschaltbar).
*/
fern: { url: string };
ingestServer: { port: number }; ingestServer: { port: number };
dashboard: { port: number }; dashboard: { port: number };
raw: YamlMap; raw: YamlMap;
@@ -151,6 +157,7 @@ const DEFAULTS = {
}, },
overlay: { port: 4791, enabled: true }, overlay: { port: 4791, enabled: true },
publicUrl: '', publicUrl: '',
fern: { url: 'https://abdu.dimension47.de' },
ingestServer: { port: 4790 }, ingestServer: { port: 4790 },
dashboard: { port: 4792 }, dashboard: { port: 4792 },
}; };
@@ -180,6 +187,12 @@ export function loadConfig(): Config {
// Unit-Datei, nicht in einer YAML, die mitversioniert wird. // Unit-Datei, nicht in einer YAML, die mitversioniert wird.
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL; if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
merged.publicUrl = merged.publicUrl.replace(/\/+$/, ''); merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
// Auch leer gesetzt gewinnt die Umgebung: so meldet sich ein Rechner zum
// Entwickeln nicht beim echten Hub an.
// Neues Objekt statt Zuweisung: ohne Eintrag in der YAML ist merged.fern
// dasselbe Objekt wie in DEFAULTS.
const fernUrl = process.env.FERN_URL ?? merged.fern.url;
merged.fern = { ...merged.fern, url: fernUrl.replace(/\/+$/, '') };
cached = { ...merged, raw } as Config; cached = { ...merged, raw } as Config;
return cached; return cached;
} }