3 Commits
Author SHA1 Message Date
ZeasyAlexandClaude Opus 5.5 54dbe1265b 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>
2026-10-26 13:03:06 +01:00
ZeasyAlexandClaude Opus 5.5 32416d1916 Uebergabe: Pult ausgerollt, Paket 0.5.6, Pipeline laeuft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:19:55 +01:00
ZeasyAlexandClaude Opus 5.5 5eaae9129f Paketbau: whisper-Archiv auch in den b-Veroeffentlichungen suchen, Manifest mitnehmen
Paket bauen und veroeffentlichen / paket (push) Successful in 43s
whisper.cpp v1.9.5 ist als latest markiert, hat aber keine Archive; die
Windows-Programme liegen in b5454. Daran ist der erste Lauf der Pipeline
gescheitert. Dazu fehlte manifest.webmanifest im Paket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:58:01 +01:00
16 changed files with 705 additions and 59 deletions

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();
+25 -19
View File
@@ -1,6 +1,6 @@
# Übergabe # Übergabe
Stand **2026-10-18**, letztes gebautes Paket 0.5.4. 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,25 +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 gebaut, **noch nicht ausgerollt und nicht an echtem OBS Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.7, **nicht an echtem
geprüft**. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit OBS geprüft**. Auf Abdus Rechner ist nichts auszuführen: das Update kommt über
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
@@ -353,9 +357,11 @@ packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau
ersetzt, damit die Adresse gleich bleibt. ersetzt, damit die Adresse gleich bleibt.
`.gitea/workflows/release.yml` macht das beim Tag automatisch. **Die Pipeline `.gitea/workflows/release.yml` macht das beim Tag automatisch. **Seit
ist noch nie gelaufen**, bisher wurde von Hand veröffentlicht. Der Runner 2026-10-26 läuft sie** (0.5.6). Der erste Lauf (`v0.5.5`) scheiterte daran,
(`ubuntu-latest`) steht. dass whisper.cpp seine Windows-Programme nicht mehr an `latest` hängt, sondern
an eigene `b…`-Veröffentlichungen; `githubAsset()` sucht jetzt die neueste
mit passendem Archiv. Unter `v0.5.5` liegt kein Paket.
## Windows-Fallen, die schon zugeschlagen haben ## Windows-Fallen, die schon zugeschlagen haben
@@ -536,7 +542,7 @@ hörbar („Danke" wurde „Dank es"), DejaVu Sans ist unauffällig, und das
fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs
konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md). konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
**Die CI ist geschrieben, aber ungetestet.** Beim ersten Tag zusehen. **Die CI läuft, aber nur einmal erfolgreich.** Beim nächsten Tag kurz hinsehen.
**Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation. **Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation.
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
+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;
} }
+24 -6
View File
@@ -72,15 +72,31 @@ const COMPONENTS: Component[] = [
}, },
]; ];
/**
* Das neueste Archiv, das zum Muster passt. Bewusst nicht nur `releases/latest`:
* whisper.cpp legt seit 2026-09 die Programme in eine eigene Veroeffentlichung
* `b<nummer>` am selben Tag wie die Version, und die Version selbst (z. B.
* v1.9.5, "latest") hat gar keine Archive. Die `b`-Staende sind mal als
* Vorabversion markiert, mal nicht - wer Vorabversionen ueberspringt, landet
* bei einem Stand von vor zwei Versionen. Der erste Lauf der Pipeline ist
* genau daran gescheitert.
*/
async function githubAsset(repoName: string, pattern: RegExp): Promise<string> { async function githubAsset(repoName: string, pattern: RegExp): Promise<string> {
const res = await fetch(`https://api.github.com/repos/${repoName}/releases/latest`, { const res = await fetch(`https://api.github.com/repos/${repoName}/releases?per_page=30`, {
headers: { 'user-agent': 'abdulive-build' }, headers: { 'user-agent': 'abdulive-build' },
}); });
if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`); if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`);
const rel = await res.json() as { assets: Array<{ name: string; browser_download_url: string }> }; const rels = await res.json() as Array<{
const asset = rel.assets.find((a) => pattern.test(a.name)); tag_name: string; draft: boolean;
if (!asset) throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`); assets: Array<{ name: string; browser_download_url: string }>;
return asset.browser_download_url; }>;
// Die API liefert die neuesten zuerst.
for (const rel of rels) {
if (rel.draft) continue;
const asset = rel.assets.find((a) => pattern.test(a.name));
if (asset) return asset.browser_download_url;
}
throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`);
} }
// ---------------------------------------------------------------- Werkzeug // ---------------------------------------------------------------- Werkzeug
@@ -159,7 +175,9 @@ async function main(): Promise<void> {
// html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in // html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in
// apps/overlay-web/public und werden zur Laufzeit von der Platte // apps/overlay-web/public und werden zur Laufzeit von der Platte
// gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay. // gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay.
if (!/\.(ts|sql|json|html|css|js|svg|png|woff2)$/.test(rel)) continue; // Dieselben Endungen, die der Overlay-Server ausliefert (TYPES in
// apps/overlay-server/src/server.ts) - sonst fehlt z. B. das Manifest des Pults.
if (!/\.(ts|sql|json|html|css|js|svg|png|jpg|woff2|webmanifest)$/.test(rel)) continue;
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) }); files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
} }
} }