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

+83 -1
View File
@@ -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<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/antwort ob ein Befehl geklappt hat (PC -> Hub)
//
// Gegenstueck: apps/overlay-server/src/fern.ts. Eingerichtet wird mit
// `abdulive fern:verbinden <adresse> <schluessel>`; 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<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
* 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,
// 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<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 = {
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<void> {
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<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 }> {
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,
};
}
+26 -6
View File
@@ -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<void> {
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<void> {
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 = {
streamId: string | null; broadcasterId: string | null;
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;