Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole) - Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen ueber die neue Browser-Source /sounds - Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS - Browser-Sources melden, welche Seite sie sind Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70448967f5
commit
8be195d938
29 files changed
+2935
-28
No files matched your search
@@ -10,8 +10,14 @@ offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse.
|
||||
/callback OAuth-Rückruf von Twitch offen, `state` schützt
|
||||
/alerts … die Browser-Sources Schlüssel in der URL
|
||||
/events der Ereignisstrom dahinter Schlüssel in der URL
|
||||
/pult Steuerpult fürs Handy (PWA) Passwort
|
||||
/soundboard/… Sounds hochladen, ändern, spielen Passwort (Datei: Schlüssel)
|
||||
/fern/… Leitung zum Stream-PC für OBS eigener Schlüssel im Kopf
|
||||
```
|
||||
|
||||
Der Fern-Schlüssel ist der dritte Zugang und der einzige, der etwas schaltet:
|
||||
er steht deshalb nie in einer Adresse, sondern in `authorization: Bearer …`.
|
||||
|
||||
## Server-Sent Events statt WebSocket
|
||||
|
||||
[`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen
|
||||
|
||||
@@ -66,3 +66,14 @@ test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
|
||||
// beenden lässt - dafür gibt es den Passwortwechsel.
|
||||
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
|
||||
});
|
||||
|
||||
test('der Schluessel des Stream-PCs kommt aus dem Bearer-Kopf', async () => {
|
||||
// Hier fehlten beim ersten Wurf die Backslashes in der Regex - jeder
|
||||
// richtige Schluessel wurde abgelehnt, und nichts im Log sah danach aus.
|
||||
const { bearer } = await import('./auth.ts');
|
||||
assert.equal(bearer('Bearer 33qg61wQf_ad-x9v'), '33qg61wQf_ad-x9v');
|
||||
assert.equal(bearer('Bearer abc'), 'abc');
|
||||
assert.equal(bearer('Basic abc'), null);
|
||||
assert.equal(bearer('Bearer'), null);
|
||||
assert.equal(bearer(undefined), null);
|
||||
});
|
||||
@@ -35,6 +35,10 @@ export type Zugang = {
|
||||
abmelden: () => string;
|
||||
overlayKey: () => string;
|
||||
neuerOverlayKey: () => string;
|
||||
/** Schluessel des Stream-PCs, im Kopf `authorization: Bearer ...`. */
|
||||
darfFern: (req: http.IncomingMessage) => boolean;
|
||||
fernKey: () => string;
|
||||
neuerFernKey: () => string;
|
||||
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
|
||||
hatPasswort: () => boolean;
|
||||
};
|
||||
@@ -88,6 +92,11 @@ function signatur(key: Buffer, passwordHash: string, bis: string): string {
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
/** Der Schluessel aus `authorization: Bearer ...`, sonst `null`. */
|
||||
export function bearer(kopf: string | undefined): string | null {
|
||||
return /^Bearer\s+(\S+)$/.exec(kopf ?? '')?.[1] ?? null;
|
||||
}
|
||||
|
||||
function cookies(req: http.IncomingMessage): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const teil of (req.headers.cookie ?? '').split(';')) {
|
||||
@@ -114,6 +123,14 @@ export function createZugang(): Zugang {
|
||||
return neu;
|
||||
}
|
||||
|
||||
function fernKey(): string {
|
||||
const vorhanden = store().fernKey;
|
||||
if (vorhanden) return vorhanden;
|
||||
const neu = crypto.randomBytes(24).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
|
||||
return neu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird
|
||||
* einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren,
|
||||
@@ -161,6 +178,18 @@ export function createZugang(): Zugang {
|
||||
|
||||
abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||
overlayKey: key,
|
||||
|
||||
darfFern(req) {
|
||||
const gegeben = bearer(req.headers.authorization);
|
||||
return Boolean(gegeben && gleich(gegeben, fernKey()));
|
||||
},
|
||||
fernKey,
|
||||
neuerFernKey() {
|
||||
const neu = crypto.randomBytes(24).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
|
||||
log.info('Schluessel fuer den Stream-PC neu gewuerfelt - dort neu eintragen');
|
||||
return neu;
|
||||
},
|
||||
neuerOverlayKey() {
|
||||
const neu = crypto.randomBytes(18).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||
|
||||
@@ -78,6 +78,25 @@ export type LiegestuetzeEvent = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Was der Stream-PC ueber OBS meldet. Kommt ueber die Fernsteuerung
|
||||
* (fern.ts) herein und steht im Schnappschuss, damit das Pult nach dem
|
||||
* Neuverbinden sofort die Szenen kennt.
|
||||
*/
|
||||
export type ObsStandEvent = {
|
||||
kind: 'obs';
|
||||
/** Ist der Stream-PC ueberhaupt mit dem Hub verbunden? */
|
||||
pc: boolean;
|
||||
/** Und hat er OBS erreicht? */
|
||||
obs: boolean;
|
||||
szenen: string[];
|
||||
aktuell: string | null;
|
||||
/** Toneingaenge, die sich stummschalten lassen. */
|
||||
ton: Array<{ name: string; stumm: boolean }>;
|
||||
/** Laeuft der Wiedergabepuffer? Ohne ihn gibt es keinen Clip. */
|
||||
puffer: boolean;
|
||||
};
|
||||
|
||||
export type OverlayEvent =
|
||||
| ChatEvent
|
||||
| AlertEvent
|
||||
@@ -103,12 +122,24 @@ export type OverlayEvent =
|
||||
| { kind: 'still' }
|
||||
| LiegestuetzeEvent
|
||||
| { kind: 'scene'; scene: string }
|
||||
/**
|
||||
* Ein Sound vom Pult. Wie beim Vorlesen geht nur die ID hinaus; die Datei
|
||||
* holt sich die Seite /sounds einzeln, damit Chat und Statusleiste keine
|
||||
* Megabytes mitschleppen.
|
||||
*/
|
||||
| { kind: 'sound'; id: string; name: string; laut: number }
|
||||
/** Alle laufenden Sounds abbrechen. Vorlesen bleibt davon unberuehrt. */
|
||||
| { kind: 'sound-stopp' }
|
||||
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
|
||||
| { kind: 'sounds-geaendert' }
|
||||
| ObsStandEvent
|
||||
| { kind: 'clear'; userId?: string; messageId?: string }
|
||||
| { kind: 'beat'; t: number }
|
||||
| {
|
||||
kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[];
|
||||
/** Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */
|
||||
liegestuetze: { offen: number; gemacht: number };
|
||||
obs: ObsStandEvent;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -137,6 +168,9 @@ export function createBus(): Bus {
|
||||
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||
};
|
||||
let liegestuetze = { offen: 0, gemacht: 0 };
|
||||
let obs: ObsStandEvent = {
|
||||
kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
|
||||
};
|
||||
|
||||
return {
|
||||
publish(ev) {
|
||||
@@ -152,6 +186,8 @@ export function createBus(): Bus {
|
||||
stream = ev;
|
||||
} else if (ev.kind === 'liegestuetze') {
|
||||
liegestuetze = { offen: ev.offen, gemacht: ev.gemacht };
|
||||
} else if (ev.kind === 'obs') {
|
||||
obs = ev;
|
||||
} else if (ev.kind === 'clear') {
|
||||
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
|
||||
// naechste Neustart der Browser-Source es zurueck ins Bild.
|
||||
@@ -174,7 +210,7 @@ export function createBus(): Bus {
|
||||
return () => { listeners.delete(fn); };
|
||||
},
|
||||
|
||||
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze }),
|
||||
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze, obs }),
|
||||
clients: () => listeners.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Die Leitung zum Stream-PC, Hub-Seite. Geprueft wird ohne Netz: die
|
||||
// SSE-Antwort ist ein nachgebautes Objekt, das mitschreibt.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type http from 'node:http';
|
||||
import { createBus, type ObsStandEvent } from './bus.ts';
|
||||
import { createFernsteuerung, standAus } from './fern.ts';
|
||||
import { befehlAus, sichererPfad } from './server.ts';
|
||||
|
||||
function leitung() {
|
||||
const req = new EventEmitter();
|
||||
const res = Object.assign(new EventEmitter(), {
|
||||
geschrieben: [] as string[],
|
||||
writeHead() { return this; },
|
||||
write(s: string) { this.geschrieben.push(s); return true; },
|
||||
end() { req.emit('close'); },
|
||||
});
|
||||
const befehle = (): Array<Record<string, unknown>> => res.geschrieben
|
||||
.filter((s) => s.startsWith('data: '))
|
||||
.map((s) => JSON.parse(s.slice(6)) as Record<string, unknown>)
|
||||
.filter((b) => b['art'] !== 'beat');
|
||||
return {
|
||||
req: req as unknown as http.IncomingMessage,
|
||||
res: res as unknown as http.ServerResponse,
|
||||
befehle,
|
||||
trennen: () => req.emit('close'),
|
||||
};
|
||||
}
|
||||
|
||||
function obsStand(bus: ReturnType<typeof createBus>): ObsStandEvent {
|
||||
const s = bus.snapshot();
|
||||
if (s.kind !== 'state') throw new Error('kein Schnappschuss');
|
||||
return s.obs;
|
||||
}
|
||||
|
||||
test('ohne Stream-PC sagt ein Befehl ehrlich, dass es nicht ging', async () => {
|
||||
const fern = createFernsteuerung(createBus());
|
||||
const a = await fern.senden({ art: 'szene', szene: 'Pause' });
|
||||
assert.equal(a.ok, false);
|
||||
assert.match(a.text, /nicht verbunden/);
|
||||
});
|
||||
|
||||
test('Befehl geht raus, Antwort kommt zurueck', async () => {
|
||||
const bus = createBus();
|
||||
const fern = createFernsteuerung(bus);
|
||||
const l = leitung();
|
||||
fern.annehmen(l.req, l.res);
|
||||
assert.equal(obsStand(bus).pc, true);
|
||||
|
||||
const warten = fern.senden({ art: 'szene', szene: 'Just Chatting' });
|
||||
const [b] = l.befehle();
|
||||
assert.equal(b?.['art'], 'szene');
|
||||
assert.equal(b?.['szene'], 'Just Chatting');
|
||||
|
||||
fern.antwort({ id: b!['id'], ok: true, text: 'Szene: Just Chatting' });
|
||||
assert.deepEqual(await warten, { ok: true, text: 'Szene: Just Chatting' });
|
||||
});
|
||||
|
||||
test('reisst die Leitung ab, wartet niemand weiter', async () => {
|
||||
const bus = createBus();
|
||||
const fern = createFernsteuerung(bus);
|
||||
const l = leitung();
|
||||
fern.annehmen(l.req, l.res);
|
||||
fern.melden({ obs: true, szenen: ['A'], aktuell: 'A', ton: [], puffer: true });
|
||||
assert.deepEqual(obsStand(bus).szenen, ['A']);
|
||||
|
||||
const warten = fern.senden({ art: 'clip' });
|
||||
l.trennen();
|
||||
const a = await warten;
|
||||
assert.equal(a.ok, false);
|
||||
assert.equal(fern.verbunden(), false);
|
||||
// Alte Szenen stehen sonst als Knoepfe da, die nichts mehr tun.
|
||||
assert.deepEqual(obsStand(bus), { kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
|
||||
});
|
||||
|
||||
test('ein zweiter PC ersetzt den ersten', () => {
|
||||
const bus = createBus();
|
||||
const fern = createFernsteuerung(bus);
|
||||
const alt = leitung();
|
||||
const neu = leitung();
|
||||
fern.annehmen(alt.req, alt.res);
|
||||
fern.annehmen(neu.req, neu.res);
|
||||
// Das Schliessen der alten Leitung darf die neue nicht als getrennt melden.
|
||||
assert.equal(fern.verbunden(), true);
|
||||
assert.equal(obsStand(bus).pc, true);
|
||||
void fern.senden({ art: 'clip' });
|
||||
assert.equal(neu.befehle().length, 1);
|
||||
assert.equal(alt.befehle().length, 0);
|
||||
});
|
||||
|
||||
test('der Stand vom PC wird auf die erwartete Form gebracht', () => {
|
||||
assert.deepEqual(standAus({
|
||||
obs: true, szenen: ['A', 7, 'B'], aktuell: 'A',
|
||||
ton: [{ name: 'Mikro', stumm: true }, { name: 3 }, null], puffer: 'ja',
|
||||
}), { obs: true, szenen: ['A', 'B'], aktuell: 'A', ton: [{ name: 'Mikro', stumm: true }], puffer: false });
|
||||
assert.deepEqual(standAus('quatsch'), { obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
|
||||
});
|
||||
|
||||
test('vom Pult kommen nur bekannte Befehle durch', () => {
|
||||
const f = (o: Record<string, string>): URLSearchParams => new URLSearchParams(o);
|
||||
assert.deepEqual(befehlAus(f({ was: 'szene', szene: 'Pause' })), { art: 'szene', szene: 'Pause' });
|
||||
assert.deepEqual(befehlAus(f({ was: 'stumm', quelle: 'Mikro', stumm: '1' })),
|
||||
{ art: 'stumm', quelle: 'Mikro', stumm: true });
|
||||
assert.deepEqual(befehlAus(f({ was: 'clip' })), { art: 'clip' });
|
||||
assert.equal(befehlAus(f({ was: 'szene' })), null);
|
||||
assert.equal(befehlAus(f({ was: 'StopStream' })), null);
|
||||
});
|
||||
|
||||
test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
|
||||
assert.equal(sichererPfad('/pult'), '/pult');
|
||||
assert.equal(sichererPfad('https://boese.example'), '/');
|
||||
assert.equal(sichererPfad('//boese.example'), '/');
|
||||
assert.equal(sichererPfad(null), '/');
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Fernsteuerung fuer OBS, Hub-Seite.
|
||||
//
|
||||
// OBS laeuft auf dem Stream-PC, das Pult am Handy spricht mit dem Hub. Der
|
||||
// Hub kommt an den Stream-PC nicht heran - der steht hinter einem fremden
|
||||
// Router. Also waehlt sich der PC beim Hub ein, nicht umgekehrt:
|
||||
//
|
||||
// Handy --POST /pult/obs--> Hub --SSE /fern/befehle--> Stream-PC --> OBS
|
||||
// Handy <--SSE /events---- Hub <--POST /fern/stand---- Stream-PC
|
||||
//
|
||||
// Dieselbe Bauform wie bei den Browser-Sources (SSE statt WebSocket, siehe
|
||||
// README), nur mit einem eigenen Schluessel: der fuer OBS darf nur lesen,
|
||||
// dieser hier schaltet Szenen um.
|
||||
//
|
||||
// Jeder Befehl wartet auf eine Antwort vom PC. Ein Knopf, der "erledigt"
|
||||
// meldet, waehrend OBS gar nicht erreichbar war, ist am Handy schlimmer als
|
||||
// einer, der ehrlich sagt, dass es nicht ging.
|
||||
|
||||
import type http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import type { Bus, ObsStandEvent } from './bus.ts';
|
||||
|
||||
const log = logger('overlay:fern');
|
||||
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const ANTWORT_MS = 6_000;
|
||||
|
||||
export type FernBefehl =
|
||||
| { art: 'szene'; szene: string }
|
||||
| { art: 'clip' }
|
||||
| { art: 'stumm'; quelle: string; stumm: boolean };
|
||||
|
||||
export type FernAntwort = { ok: boolean; text: string };
|
||||
|
||||
export type Fernsteuerung = {
|
||||
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
|
||||
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
||||
/** Stand von OBS, wie ihn der PC meldet. */
|
||||
melden: (roh: unknown) => void;
|
||||
antwort: (roh: unknown) => void;
|
||||
senden: (b: FernBefehl) => Promise<FernAntwort>;
|
||||
verbunden: () => boolean;
|
||||
};
|
||||
|
||||
const LEER: Omit<ObsStandEvent, 'kind'> = {
|
||||
pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
|
||||
};
|
||||
|
||||
/** Was vom PC kommt, auf die erwartete Form bringen. Fremdes faellt weg. */
|
||||
export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
|
||||
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
|
||||
const text = (v: unknown): string | null => (typeof v === 'string' && v.length <= 200 ? v : null);
|
||||
const szenen = Array.isArray(r['szenen'])
|
||||
? r['szenen'].map(text).filter((s): s is string => s !== null).slice(0, 100) : [];
|
||||
const ton = Array.isArray(r['ton'])
|
||||
? r['ton'].flatMap((t) => {
|
||||
const name = text((t as Record<string, unknown>)?.['name']);
|
||||
return name === null ? [] : [{ name, stumm: (t as Record<string, unknown>)['stumm'] === true }];
|
||||
}).slice(0, 50) : [];
|
||||
return {
|
||||
obs: r['obs'] === true,
|
||||
szenen,
|
||||
aktuell: text(r['aktuell']),
|
||||
ton,
|
||||
puffer: r['puffer'] === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFernsteuerung(bus: Bus): Fernsteuerung {
|
||||
let leitung: http.ServerResponse | null = null;
|
||||
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
|
||||
|
||||
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
|
||||
bus.publish({ kind: 'obs', ...stand });
|
||||
};
|
||||
|
||||
return {
|
||||
annehmen(req, res) {
|
||||
if (leitung) {
|
||||
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
|
||||
// Der neuere gewinnt, meist ist der alte ohnehin eine tote Leitung.
|
||||
log.info('Stream-PC hat sich neu verbunden, alte Leitung geschlossen');
|
||||
leitung.end();
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
leitung = res;
|
||||
log.info('Stream-PC verbunden');
|
||||
veroeffentlichen({ ...LEER, pc: true });
|
||||
|
||||
const beat = setInterval(
|
||||
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
|
||||
HEARTBEAT_MS);
|
||||
beat.unref();
|
||||
|
||||
const ende = (): void => {
|
||||
clearInterval(beat);
|
||||
if (leitung !== res) return; // schon durch eine neuere ersetzt
|
||||
leitung = null;
|
||||
log.info('Stream-PC getrennt');
|
||||
veroeffentlichen(LEER);
|
||||
for (const [id, o] of offen) {
|
||||
clearTimeout(o.timer);
|
||||
o.fertig({ ok: false, text: 'Verbindung zum Stream-PC abgerissen.' });
|
||||
offen.delete(id);
|
||||
}
|
||||
};
|
||||
req.on('close', ende);
|
||||
res.on('error', ende);
|
||||
},
|
||||
|
||||
melden(roh) {
|
||||
if (!leitung) return;
|
||||
veroeffentlichen({ pc: true, ...standAus(roh) });
|
||||
},
|
||||
|
||||
antwort(roh) {
|
||||
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
|
||||
const id = typeof r['id'] === 'string' ? r['id'] : '';
|
||||
const o = offen.get(id);
|
||||
if (!o) return;
|
||||
offen.delete(id);
|
||||
clearTimeout(o.timer);
|
||||
o.fertig({
|
||||
ok: r['ok'] === true,
|
||||
text: typeof r['text'] === 'string' ? r['text'].slice(0, 300) : '',
|
||||
});
|
||||
},
|
||||
|
||||
senden(b) {
|
||||
const res = leitung;
|
||||
if (!res) {
|
||||
return Promise.resolve({
|
||||
ok: false, text: 'Der Stream-PC ist nicht verbunden. Läuft dort abdulive?',
|
||||
});
|
||||
}
|
||||
const id = crypto.randomBytes(6).toString('hex');
|
||||
return new Promise<FernAntwort>((fertig) => {
|
||||
const timer = setTimeout(() => {
|
||||
offen.delete(id);
|
||||
fertig({ ok: false, text: 'Der Stream-PC hat nicht geantwortet.' });
|
||||
}, ANTWORT_MS);
|
||||
offen.set(id, { fertig, timer });
|
||||
res.write(`data: ${JSON.stringify({ id, ...b })}\n\n`);
|
||||
});
|
||||
},
|
||||
|
||||
verbunden: () => leitung !== null,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
|
||||
// /alerts ... die Browser-Sources (Schluessel)
|
||||
// /events der SSE-Strom dahinter (Schluessel)
|
||||
// /pult Steuerpult fuers Handy (PWA) (Passwort)
|
||||
// /fern/... Leitung zum Stream-PC fuer OBS (eigener Schluessel)
|
||||
//
|
||||
// Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit
|
||||
// der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit
|
||||
@@ -18,11 +20,14 @@ import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { paths } from '#core/config.ts';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
|
||||
import { createZugang, type Zugang } from './auth.ts';
|
||||
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
|
||||
import { startDemo } from './demo.ts';
|
||||
import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
|
||||
import { createSoundboard, MAX_BYTES, nameAusDatei, type Soundboard } from './sounds.ts';
|
||||
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||
|
||||
const log = logger('overlay');
|
||||
@@ -46,6 +51,7 @@ const TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.woff2': 'font/woff2',
|
||||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||
};
|
||||
|
||||
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
|
||||
@@ -64,6 +70,8 @@ export const PAGES = [
|
||||
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
|
||||
{ path: '/tts', file: 'tts.html', w: 600, h: 200,
|
||||
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' },
|
||||
{ path: '/sounds', file: 'sounds.html', w: 400, h: 120,
|
||||
title: 'Soundboard', hint: 'Spielt die Sounds vom Pult. Unsichtbar, braucht Ton in OBS („Audio über OBS steuern“ an).' },
|
||||
{ path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200,
|
||||
title: 'Liegestütze', hint: 'Offene und in diesem Stream gemachte Liegestütze. Zählt bei Einlösung hoch, bedienen unten auf dieser Seite.' },
|
||||
] as const;
|
||||
@@ -73,6 +81,7 @@ type LiegestuetzStand = { offen: number; gemacht: number };
|
||||
export type OverlayServer = {
|
||||
bus: Bus;
|
||||
zugang: Zugang;
|
||||
fern: Fernsteuerung;
|
||||
close: () => void;
|
||||
port: number;
|
||||
};
|
||||
@@ -83,6 +92,8 @@ export type OverlayOptions = {
|
||||
/** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */
|
||||
publicUrl: string;
|
||||
bus?: Bus;
|
||||
/** Wo die Sounds liegen. Vorgabe: neben der Datenbank. */
|
||||
soundsDir?: string;
|
||||
/** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */
|
||||
setup?: ServerDeps;
|
||||
/**
|
||||
@@ -116,13 +127,18 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
||||
const zugang = createZugang();
|
||||
// Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll
|
||||
// den ersten ersetzen, nicht danebenlaufen.
|
||||
const vorfuehrung = { abbruch: null as AbortController | null };
|
||||
const lz: Laufzeit = {
|
||||
vorfuehrung: { abbruch: null },
|
||||
sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')),
|
||||
fern: createFernsteuerung(bus),
|
||||
quellen: new Map(),
|
||||
};
|
||||
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
|
||||
// oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen.
|
||||
const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1');
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void handle(req, res, bus, zugang, opts, vorfuehrung).catch((err: Error) => {
|
||||
void handle(req, res, bus, zugang, opts, lz).catch((err: Error) => {
|
||||
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Interner Fehler');
|
||||
@@ -143,23 +159,35 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
||||
? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : ''));
|
||||
});
|
||||
|
||||
return { bus, zugang, close: () => server.close(), port: opts.port };
|
||||
return { bus, zugang, fern: lz.fern, close: () => server.close(), port: opts.port };
|
||||
}
|
||||
|
||||
type Vorfuehrung = { abbruch: AbortController | null };
|
||||
|
||||
/** Was der Server zwischen den Anfragen haelt. */
|
||||
type Laufzeit = {
|
||||
vorfuehrung: Vorfuehrung;
|
||||
sounds: Soundboard;
|
||||
fern: Fernsteuerung;
|
||||
/** Verbundene Browser-Sources je Seite, z. B. sounds -> 1. */
|
||||
quellen: Map<string, number>;
|
||||
};
|
||||
|
||||
async function handle(
|
||||
req: http.IncomingMessage, res: http.ServerResponse,
|
||||
bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung
|
||||
bus: Bus, zugang: Zugang, opts: OverlayOptions, lz: Laufzeit
|
||||
): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`);
|
||||
const p = url.pathname;
|
||||
const { vorfuehrung, sounds, fern } = lz;
|
||||
|
||||
// ---- Anmeldung. Bewusst vor allem anderen.
|
||||
if (p === '/login' && req.method === 'POST') {
|
||||
const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? '');
|
||||
if (!cookie) return redirect(res, '/?fehler=1');
|
||||
res.writeHead(303, { location: '/', 'set-cookie': cookie });
|
||||
const form = await readForm(req);
|
||||
const weiter = sichererPfad(form.get('weiter'));
|
||||
const cookie = zugang.anmelden(form.get('passwort') ?? '');
|
||||
if (!cookie) return redirect(res, `${weiter}?fehler=1`);
|
||||
res.writeHead(303, { location: weiter, 'set-cookie': cookie });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
@@ -177,7 +205,25 @@ async function handle(
|
||||
|
||||
// ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten,
|
||||
// also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen.
|
||||
if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
|
||||
if (/\.(css|js|svg|png|jpg|woff2|webmanifest)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
|
||||
|
||||
// ---- Die Leitung zum Stream-PC. Eigener Schluessel im Kopf, nie in der
|
||||
// Adresse: wer ihn hat, schaltet Szenen um.
|
||||
if (p.startsWith('/fern/')) {
|
||||
if (!zugang.darfFern(req)) return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
|
||||
if (p === '/fern/befehle' && req.method === 'GET') return fern.annehmen(req, res);
|
||||
if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') {
|
||||
let roh: unknown;
|
||||
try { roh = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8')); } catch {
|
||||
return send(res, 400, 'text/plain; charset=utf-8', 'Kein JSON.');
|
||||
}
|
||||
if (p === '/fern/stand') fern.melden(roh); else fern.antwort(roh);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden');
|
||||
}
|
||||
|
||||
// ---- Die Browser-Sources und ihr Datenstrom.
|
||||
// Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der
|
||||
@@ -186,13 +232,27 @@ async function handle(
|
||||
const page = req.method === 'GET' || req.method === 'HEAD'
|
||||
? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined;
|
||||
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
|
||||
if (page || p === '/events' || audioId) {
|
||||
const soundId = /^\/soundboard\/datei\/([a-f0-9]+)$/.exec(p)?.[1];
|
||||
if (page || p === '/events' || audioId || soundId) {
|
||||
if (!zugang.darfOverlay(req, url)) {
|
||||
return send(res, 403, 'text/plain; charset=utf-8',
|
||||
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
|
||||
'Sie steht fertig auf der Uebersichtsseite.');
|
||||
}
|
||||
if (p === '/events') return sse(req, res, bus);
|
||||
if (p === '/events') return sse(req, res, bus, url, lz.quellen);
|
||||
if (soundId) {
|
||||
const d = sounds.datei(soundId);
|
||||
if (!d) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
|
||||
res.writeHead(200, {
|
||||
'content-type': d.typ, 'content-length': d.daten.length,
|
||||
// Darf zwischengespeichert werden: eine ID gehoert immer zu derselben
|
||||
// Datei, und beim zweiten Druck auf denselben Knopf soll es sofort knallen.
|
||||
'cache-control': 'private, max-age=86400, immutable',
|
||||
'x-content-type-options': 'nosniff',
|
||||
});
|
||||
res.end(d.daten);
|
||||
return;
|
||||
}
|
||||
if (audioId) {
|
||||
const wav = opts.tts?.audio(audioId);
|
||||
if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
|
||||
@@ -207,10 +267,39 @@ async function handle(
|
||||
|
||||
// ---- Ab hier nur mit Anmeldung.
|
||||
if (!zugang.istAngemeldet(req)) {
|
||||
// Fragen von Skripten (fetch) bekommen eine kurze Antwort statt der
|
||||
// Anmeldeseite, damit das Pult "abgemeldet" sagen kann statt HTML zu parsen.
|
||||
if (req.method !== 'GET' || p.startsWith('/soundboard/') || p.startsWith('/pult/')) {
|
||||
return send(res, 401, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text: 'Abgemeldet. Bitte neu anmelden.', abgemeldet: true }));
|
||||
}
|
||||
return send(res, 401, 'text/html; charset=utf-8',
|
||||
loginPage(opts.channel, url.searchParams.has('fehler')));
|
||||
loginPage(opts.channel, url.searchParams.has('fehler'), p));
|
||||
}
|
||||
|
||||
// ---- Das Pult
|
||||
if (p === '/pult' && req.method === 'GET') return sendFile(res, 'pult.html');
|
||||
if (p === '/pult/knoepfe' && req.method === 'GET') {
|
||||
return send(res, 200, 'application/json; charset=utf-8', JSON.stringify({
|
||||
knoepfe: TEST_KNOEPFE, demo: vorfuehrung.abbruch !== null,
|
||||
tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null,
|
||||
}));
|
||||
}
|
||||
if (p === '/pult/obs' && req.method === 'POST') {
|
||||
const form = await readForm(req);
|
||||
const befehl = befehlAus(form);
|
||||
if (!befehl) {
|
||||
return send(res, 400, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ ok: false, text: 'Unbekannter Befehl.' }));
|
||||
}
|
||||
const a = await fern.senden(befehl);
|
||||
if (a.ok) log.info(`Pult -> OBS: ${befehl.art}${'szene' in befehl ? ` "${befehl.szene}"` : ''}`);
|
||||
return send(res, a.ok ? 200 : 502, 'application/json; charset=utf-8', JSON.stringify(a));
|
||||
}
|
||||
|
||||
// ---- Soundboard
|
||||
if (p.startsWith('/soundboard/')) return soundboardRoute(req, res, url, bus, lz);
|
||||
|
||||
// ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts
|
||||
// davon landet in der Datenbank, auf Discord oder bei Twitch.
|
||||
if (p === '/test' && req.method === 'POST') {
|
||||
@@ -323,10 +412,15 @@ async function handle(
|
||||
zugang.neuerOverlayKey();
|
||||
return redirect(res, '/');
|
||||
}
|
||||
if (p === '/fernschluessel/neu' && req.method === 'POST') {
|
||||
zugang.neuerFernKey();
|
||||
return redirect(res, '/#stream-pc');
|
||||
}
|
||||
|
||||
if (p === '/' || p === '/index.html') {
|
||||
return send(res, 200, 'text/html; charset=utf-8',
|
||||
indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null));
|
||||
indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null,
|
||||
{ key: zugang.fernKey(), verbunden: fern.verbunden() }));
|
||||
}
|
||||
|
||||
if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return;
|
||||
@@ -336,7 +430,16 @@ async function handle(
|
||||
|
||||
// ---------------------------------------------------------------- SSE
|
||||
|
||||
function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void {
|
||||
function sse(
|
||||
req: http.IncomingMessage, res: http.ServerResponse, bus: Bus,
|
||||
url: URL, quellen: Map<string, number>
|
||||
): void {
|
||||
// Welche Seite da zuhoert. Nur zum Zaehlen: das Pult will wissen, ob die
|
||||
// Sound-Quelle in OBS ueberhaupt offen ist, bevor es "gespielt" meldet.
|
||||
const roh = url.searchParams.get('quelle') ?? '';
|
||||
const quelle = /^[a-z0-9-]{1,20}$/.test(roh) ? roh : '';
|
||||
if (quelle) quellen.set(quelle, (quellen.get(quelle) ?? 0) + 1);
|
||||
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
@@ -366,7 +469,15 @@ function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): voi
|
||||
HEARTBEAT_MS);
|
||||
beat.unref();
|
||||
|
||||
const end = (): void => { clearInterval(beat); unsubscribe(); };
|
||||
// `close` und `error` kommen gern beide - gezaehlt wird nur einmal.
|
||||
let zu = false;
|
||||
const end = (): void => {
|
||||
if (zu) return;
|
||||
zu = true;
|
||||
clearInterval(beat);
|
||||
unsubscribe();
|
||||
if (quelle) quellen.set(quelle, Math.max(0, (quellen.get(quelle) ?? 1) - 1));
|
||||
};
|
||||
req.on('close', end);
|
||||
res.on('error', end);
|
||||
}
|
||||
@@ -378,8 +489,119 @@ function zustand(bus: Bus) {
|
||||
return { live: s.stream.live, chat: s.chat };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Pult
|
||||
|
||||
/** Formular vom Pult in einen Befehl fuer OBS. Alles andere ist `null`. */
|
||||
export function befehlAus(form: URLSearchParams): FernBefehl | null {
|
||||
const was = form.get('was');
|
||||
const szene = form.get('szene');
|
||||
const quelle = form.get('quelle');
|
||||
if (was === 'szene' && szene) return { art: 'szene', szene };
|
||||
if (was === 'clip') return { art: 'clip' };
|
||||
if (was === 'stumm' && quelle) return { art: 'stumm', quelle, stumm: form.get('stumm') === '1' };
|
||||
return null;
|
||||
}
|
||||
|
||||
async function soundboardRoute(
|
||||
req: http.IncomingMessage, res: http.ServerResponse, url: URL, bus: Bus, lz: Laufzeit
|
||||
): Promise<void> {
|
||||
const p = url.pathname;
|
||||
const { sounds } = lz;
|
||||
const json = (code: number, body: Record<string, unknown>): void =>
|
||||
send(res, code, 'application/json; charset=utf-8', JSON.stringify(body));
|
||||
const mitListe = (code: number, text: string): void =>
|
||||
json(code, { text, sounds: sounds.liste(), quellen: lz.quellen.get('sounds') ?? 0 });
|
||||
const geaendert = (): void => bus.publish({ kind: 'sounds-geaendert' });
|
||||
const zuGross = `Zu groß: höchstens ${MAX_BYTES / 1024 / 1024} MB.`;
|
||||
|
||||
if (p === '/soundboard/liste' && req.method === 'GET') return mitListe(200, '');
|
||||
if (req.method !== 'POST') return json(405, { text: 'Nur POST.' });
|
||||
|
||||
if (p === '/soundboard/neu') {
|
||||
// Die Datei kommt roh im Koerper, nicht als Formular: das erspart das
|
||||
// Zerlegen von multipart, und das Pult schickt ohnehin genau eine Datei.
|
||||
if (Number(req.headers['content-length'] ?? 0) > MAX_BYTES) return mitListe(413, zuGross);
|
||||
let daten: Buffer;
|
||||
try { daten = await readBody(req, MAX_BYTES); } catch { return mitListe(413, zuGross); }
|
||||
try {
|
||||
const s = sounds.hinzufuegen(nameAusDatei(url.searchParams.get('name') ?? ''), daten);
|
||||
log.info(`Sound hochgeladen: ${s.name} (${(s.bytes / 1024).toFixed(0)} KB, ${s.typ})`);
|
||||
geaendert();
|
||||
return json(200, { text: `„${s.name}“ ist drin.`, sound: s, sounds: sounds.liste() });
|
||||
} catch (err) {
|
||||
return mitListe(400, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const form = await readForm(req);
|
||||
const id = form.get('id') ?? '';
|
||||
|
||||
if (p === '/soundboard/spielen') {
|
||||
const s = sounds.holen(id);
|
||||
if (!s) return mitListe(404, 'Den Sound gibt es nicht mehr.');
|
||||
bus.publish({ kind: 'sound', id: s.id, name: s.name, laut: s.laut });
|
||||
const n = lz.quellen.get('sounds') ?? 0;
|
||||
// Gespielt wird trotzdem: vielleicht ist die Quelle nur kurz weg. Aber
|
||||
// wer auf einen Knopf drueckt und nichts hoert, soll wissen, warum.
|
||||
return json(200, {
|
||||
text: n ? s.name : `${s.name} – aber in OBS ist keine Sound-Quelle verbunden.`,
|
||||
quellen: n,
|
||||
});
|
||||
}
|
||||
if (p === '/soundboard/stopp') {
|
||||
bus.publish({ kind: 'sound-stopp' });
|
||||
return json(200, { text: 'Alle Sounds gestoppt.' });
|
||||
}
|
||||
if (p === '/soundboard/aendern') {
|
||||
const was: Parameters<Soundboard['aendern']>[1] = {};
|
||||
for (const k of ['name', 'emoji', 'farbe'] as const) {
|
||||
const v = form.get(k);
|
||||
if (v !== null) was[k] = v;
|
||||
}
|
||||
const laut = form.get('laut');
|
||||
if (laut !== null) was.laut = Number(laut);
|
||||
if (!sounds.aendern(id, was)) return mitListe(404, 'Den Sound gibt es nicht mehr.');
|
||||
geaendert();
|
||||
return mitListe(200, 'Gespeichert.');
|
||||
}
|
||||
if (p === '/soundboard/loeschen') {
|
||||
const name = sounds.holen(id)?.name;
|
||||
if (!sounds.entfernen(id)) return mitListe(404, 'Den Sound gibt es nicht mehr.');
|
||||
log.info(`Sound geloescht: ${name}`);
|
||||
geaendert();
|
||||
return mitListe(200, `„${name}“ gelöscht.`);
|
||||
}
|
||||
if (p === '/soundboard/verschieben') {
|
||||
if (sounds.verschieben(id, Number(form.get('um')))) geaendert();
|
||||
return mitListe(200, '');
|
||||
}
|
||||
return json(404, { text: 'Nicht gefunden.' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Werkzeug
|
||||
|
||||
/**
|
||||
* Wohin es nach der Anmeldung geht. Nur eine feste Liste: ein freies Ziel
|
||||
* machte /login zur Weiterleitung auf beliebige fremde Seiten.
|
||||
*/
|
||||
export function sichererPfad(roh: string | null | undefined): string {
|
||||
return roh === '/pult' ? '/pult' : '/';
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage, max: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > max) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -486,13 +708,19 @@ const SEITEN_CSS = `
|
||||
button.tts { margin-left: .4rem; }
|
||||
`;
|
||||
|
||||
export function loginPage(channel: string, fehler: boolean): string {
|
||||
export function loginPage(channel: string, fehler: boolean, weiter = '/'): string {
|
||||
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0d0b14">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
<title>Anmelden</title><style>${SEITEN_CSS}
|
||||
body { display: grid; place-items: center; height: 100vh; padding: 0; }
|
||||
form { text-align: left; }
|
||||
body { display: grid; place-items: center; height: 100vh; height: 100dvh; padding: 0 1rem; }
|
||||
form { text-align: left; max-width: 100%; }
|
||||
input[type=password] { max-width: 100%; }
|
||||
</style>
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="weiter" value="${sichererPfad(weiter)}">
|
||||
<h1>abdulive <span>${escape(channel)}</span></h1>
|
||||
<p class="lead">${fehler ? '<span class="warn">Passwort stimmt nicht.</span>' : 'Passwort eingeben.'}</p>
|
||||
<input type="password" name="passwort" autofocus autocomplete="current-password" size="30">
|
||||
@@ -507,7 +735,8 @@ export function loginPage(channel: string, fehler: boolean): string {
|
||||
* die Seitenliste, und das soll nicht an zwei Stellen stehen.
|
||||
*/
|
||||
export function indexPage(
|
||||
opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false
|
||||
opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false,
|
||||
fernInfo: { key: string; verbunden: boolean } = { key: '', verbunden: false }
|
||||
): string {
|
||||
// Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt -
|
||||
// im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben.
|
||||
@@ -571,9 +800,27 @@ für Korrekturen und zählt nicht. Die Zuschauer sehen jede Änderung.</p>
|
||||
).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
const fernBefehl = `abdulive fern:verbinden ${base} ${fernInfo.key}`;
|
||||
|
||||
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}</style>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0d0b14">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}
|
||||
.pultbox { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.2rem;
|
||||
border: 1px solid #3a3352; border-radius: .6rem; padding: .9rem 1.1rem;
|
||||
margin: 0 0 2rem; max-width: 74rem; background: #120e1d; }
|
||||
.pultbox a.gross { margin: 0; padding: .45rem 1rem; border-color: #a970ff; color: #fff;
|
||||
background: #6441a5; font-weight: 600; }
|
||||
</style>
|
||||
<h1>Overlay <span>${escape(opts.channel)}</span></h1>
|
||||
<div class="pultbox">
|
||||
<a class="knopf gross" href="/pult">Pult öffnen</a>
|
||||
<span class="hint">Soundboard, Szenen, Liegestütze und Vorlesen fürs Handy.
|
||||
Dort im Browser-Menü „Zum Startbildschirm hinzufügen“ bzw. „App installieren“ -
|
||||
dann liegt es wie eine App auf dem Handy.</span>
|
||||
</div>
|
||||
<p class="lead">In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und
|
||||
Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst
|
||||
verpasst das Overlay alles, was in einer anderen Szene passiert.</p>
|
||||
@@ -607,6 +854,20 @@ ${ttsFeld}
|
||||
|
||||
${lzFeld}
|
||||
|
||||
<h2 id="stream-pc">Stream-PC verbinden</h2>
|
||||
<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
|
||||
ausführen, danach <code>abdulive ingest</code> neu starten:</p>
|
||||
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
|
||||
<button data-copy="fernbefehl">kopieren</button></p>
|
||||
<p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${
|
||||
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p>
|
||||
<form method="post" action="/fernschluessel/neu">
|
||||
<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,
|
||||
nie zeigen. Nach dem Würfeln den Befehl oben auf dem Stream-PC erneut ausführen.</span>
|
||||
</form>
|
||||
|
||||
<h2>Schlüssel</h2>
|
||||
<form method="post" action="/schluessel/neu">
|
||||
<button type="submit">Schlüssel neu würfeln</button>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Der Sound-Speicher nimmt Dateien vom Handy an und liefert sie unter der
|
||||
// eigenen Adresse wieder aus. Hier wird vor allem geprueft, dass er nur Ton
|
||||
// annimmt - und dass die Liste einen Neustart uebersteht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createSoundboard, erkenneTyp, nameAusDatei, MAX_BYTES } from './sounds.ts';
|
||||
|
||||
const ordner = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-sounds-'));
|
||||
|
||||
const MP3 = Buffer.concat([Buffer.from('ID3'), Buffer.alloc(64, 1)]);
|
||||
const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(32)]);
|
||||
const M4A = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypM4A '), Buffer.alloc(32)]);
|
||||
|
||||
test('Tontypen werden an den ersten Bytes erkannt', () => {
|
||||
assert.equal(erkenneTyp(MP3), 'audio/mpeg');
|
||||
assert.equal(erkenneTyp(Buffer.from([0xff, 0xfb, 0x90, 0x64, 0, 0, 0, 0, 0, 0, 0, 0])), 'audio/mpeg');
|
||||
assert.equal(erkenneTyp(WAV), 'audio/wav');
|
||||
assert.equal(erkenneTyp(Buffer.concat([Buffer.from('OggS'), Buffer.alloc(20)])), 'audio/ogg');
|
||||
assert.equal(erkenneTyp(M4A), 'audio/mp4');
|
||||
});
|
||||
|
||||
test('HTML ist kein Ton, auch wenn der Browser es behauptet', () => {
|
||||
// Sonst waere das eine Ablage fuer beliebige Seiten unter der eigenen Adresse.
|
||||
assert.equal(erkenneTyp(Buffer.from('<!doctype html><script>alert(1)</script>')), null);
|
||||
assert.equal(erkenneTyp(Buffer.from('kurz')), null);
|
||||
const sb = createSoundboard(ordner());
|
||||
assert.throws(() => sb.hinzufuegen('x', Buffer.from('<html><body>hallo welt</body></html>')), /Kein Ton/);
|
||||
assert.equal(sb.liste().length, 0);
|
||||
});
|
||||
|
||||
test('zu gross wird abgelehnt, bevor etwas auf der Platte landet', () => {
|
||||
const dir = ordner();
|
||||
const sb = createSoundboard(dir);
|
||||
const riesig = Buffer.concat([MP3, Buffer.alloc(MAX_BYTES)]);
|
||||
assert.throws(() => sb.hinzufuegen('lied', riesig), /Zu gross/);
|
||||
assert.deepEqual(fs.readdirSync(dir), []);
|
||||
});
|
||||
|
||||
test('hochladen, abholen, nach Neustart noch da', () => {
|
||||
const dir = ordner();
|
||||
const sb = createSoundboard(dir);
|
||||
const s = sb.hinzufuegen('Tusch', MP3);
|
||||
assert.equal(s.typ, 'audio/mpeg');
|
||||
assert.ok(s.datei.endsWith('.mp3'));
|
||||
assert.deepEqual(sb.datei(s.id)?.daten, MP3);
|
||||
|
||||
const nachNeustart = createSoundboard(dir);
|
||||
assert.equal(nachNeustart.liste()[0]?.name, 'Tusch');
|
||||
assert.deepEqual(nachNeustart.datei(s.id)?.daten, MP3);
|
||||
});
|
||||
|
||||
test('aendern nimmt nur, was erlaubt ist', () => {
|
||||
const sb = createSoundboard(ordner());
|
||||
const s = sb.hinzufuegen('Tusch', MP3);
|
||||
const neu = sb.aendern(s.id, {
|
||||
name: ' Ba\ndum tss ', emoji: '🥁', farbe: 'url(javascript:x)', laut: 3,
|
||||
})!;
|
||||
assert.equal(neu.name, 'Ba dum tss');
|
||||
assert.equal(neu.emoji, '🥁');
|
||||
assert.equal(neu.farbe, s.farbe, 'freie Farbwerte waeren CSS-Einschleusung');
|
||||
assert.equal(neu.laut, 1);
|
||||
assert.equal(sb.aendern(s.id, { name: ' ' })?.name, 'Ba dum tss', 'leerer Name behaelt den alten');
|
||||
});
|
||||
|
||||
test('verschieben und loeschen', () => {
|
||||
const dir = ordner();
|
||||
const sb = createSoundboard(dir);
|
||||
const a = sb.hinzufuegen('A', MP3);
|
||||
const b = sb.hinzufuegen('B', WAV);
|
||||
sb.hinzufuegen('C', M4A);
|
||||
assert.equal(sb.verschieben(b.id, -1), true);
|
||||
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'A', 'C']);
|
||||
assert.equal(sb.verschieben(b.id, -1), false, 'vor dem ersten ist nichts');
|
||||
|
||||
assert.equal(sb.entfernen(a.id), true);
|
||||
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'C']);
|
||||
assert.equal(fs.existsSync(path.join(dir, a.datei)), false, 'die Datei geht mit');
|
||||
assert.equal(sb.entfernen(a.id), false);
|
||||
});
|
||||
|
||||
test('Name aus dem Dateinamen', () => {
|
||||
assert.equal(nameAusDatei('ba_dum_tss.mp3'), 'ba dum tss');
|
||||
assert.equal(nameAusDatei('.mp3'), 'Sound');
|
||||
assert.equal(nameAusDatei('Aufnahme 12.m4a'), 'Aufnahme 12');
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// Die Sounds fuer das Pult.
|
||||
//
|
||||
// Hochgeladen wird vom Handy aus, abgespielt in OBS ueber die Browser-Source
|
||||
// /sounds. Dazwischen liegt nur dieser Ordner:
|
||||
//
|
||||
// <db>/sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge
|
||||
// <db>/sounds/<id>.<endung> die Datei selbst
|
||||
//
|
||||
// Bewusst neben der Datenbank und nicht in ihr: ein Ordner mit Dateien laesst
|
||||
// sich von Hand durchsehen und sichern, und die Datenbank bleibt klein. Und
|
||||
// bewusst nicht unter /opt/abdulive: das wird bei jedem Ausrollen ersetzt.
|
||||
//
|
||||
// Der Dateityp wird an den ersten Bytes erkannt, nicht an dem, was der Browser
|
||||
// behauptet. Ausgeliefert wird nur, was hier als Ton erkannt wurde - sonst
|
||||
// waere das eine Ablage fuer beliebige Dateien unter der eigenen Adresse.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type Sound = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
/** Eine der FARBEN unten. Freie Werte gaebe es nur fuer CSS-Einschleusung. */
|
||||
farbe: string;
|
||||
/** 0 bis 1. Multipliziert mit der Lautstaerke der Browser-Source. */
|
||||
laut: number;
|
||||
datei: string;
|
||||
typ: string;
|
||||
bytes: number;
|
||||
seit: string;
|
||||
};
|
||||
|
||||
export const FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'] as const;
|
||||
|
||||
/** Groesser ist kein Soundeffekt mehr, sondern ein Lied. */
|
||||
export const MAX_BYTES = 8 * 1024 * 1024;
|
||||
export const MAX_SOUNDS = 120;
|
||||
|
||||
const ENDUNG: Record<string, string> = {
|
||||
'audio/mpeg': '.mp3',
|
||||
'audio/wav': '.wav',
|
||||
'audio/ogg': '.ogg',
|
||||
'audio/webm': '.webm',
|
||||
'audio/mp4': '.m4a',
|
||||
};
|
||||
|
||||
/**
|
||||
* Tontyp an den ersten Bytes erkennen. `null` heisst: kein Ton, den OBS'
|
||||
* Browser sicher abspielt.
|
||||
*/
|
||||
export function erkenneTyp(b: Buffer): string | null {
|
||||
if (b.length < 12) return null;
|
||||
const ascii = (von: number, bis: number): string => b.subarray(von, bis).toString('latin1');
|
||||
if (ascii(0, 3) === 'ID3') return 'audio/mpeg';
|
||||
// MPEG-Rahmen ohne ID3-Kopf: 11 gesetzte Synchronbits.
|
||||
if (b[0] === 0xff && (b[1]! & 0xe0) === 0xe0) return 'audio/mpeg';
|
||||
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WAVE') return 'audio/wav';
|
||||
if (ascii(0, 4) === 'OggS') return 'audio/ogg';
|
||||
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'audio/webm';
|
||||
// MP4/M4A: das Kaestchen `ftyp` an Stelle 4. So nimmt auch das iPhone auf.
|
||||
if (ascii(4, 8) === 'ftyp') return 'audio/mp4';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Name aus dem Dateinamen: ohne Endung, ohne Unterstriche, nicht zu lang. */
|
||||
export function nameAusDatei(datei: string): string {
|
||||
const ohne = datei.replace(/\.[A-Za-z0-9]{1,5}$/, '').replace(/[_]+/g, ' ').trim();
|
||||
return saeubern(ohne) || 'Sound';
|
||||
}
|
||||
|
||||
function saeubern(s: string, max = 40): string {
|
||||
// Steuerzeichen raus. Angezeigt wird ueber textContent, aber ein
|
||||
// Zeilenumbruch im Namen zerreisst trotzdem jede Kachel.
|
||||
return Array.from(s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim())
|
||||
.slice(0, max).join('');
|
||||
}
|
||||
|
||||
function emojiOk(s: string): string {
|
||||
// Ein Emoji kann aus mehreren Codepoints bestehen (Hautfarbe, Familien).
|
||||
// Zehn reichen fuer alles, was man auf eine Kachel setzt.
|
||||
return Array.from(saeubern(s, 10)).slice(0, 10).join('');
|
||||
}
|
||||
|
||||
export type Soundboard = {
|
||||
liste: () => Sound[];
|
||||
holen: (id: string) => Sound | undefined;
|
||||
datei: (id: string) => { daten: Buffer; typ: string } | undefined;
|
||||
hinzufuegen: (name: string, daten: Buffer) => Sound;
|
||||
aendern: (id: string, was: Partial<Pick<Sound, 'name' | 'emoji' | 'farbe' | 'laut'>>) => Sound | undefined;
|
||||
entfernen: (id: string) => boolean;
|
||||
/** Um eine Stelle nach vorn (-1) oder hinten (+1). */
|
||||
verschieben: (id: string, um: number) => boolean;
|
||||
};
|
||||
|
||||
export function createSoundboard(ordner: string): Soundboard {
|
||||
const index = path.join(ordner, 'sounds.json');
|
||||
const sounds: Sound[] = lesen();
|
||||
|
||||
function lesen(): Sound[] {
|
||||
try {
|
||||
const roh = JSON.parse(fs.readFileSync(index, 'utf8')) as { sounds?: Sound[] };
|
||||
return Array.isArray(roh.sounds) ? roh.sounds : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function schreiben(): void {
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
// Erst daneben schreiben, dann umbenennen: stirbt der Prozess mittendrin,
|
||||
// bleibt die alte Liste ganz statt einer halben neuen.
|
||||
const tmp = `${index}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify({ sounds }, null, 2));
|
||||
fs.renameSync(tmp, index);
|
||||
}
|
||||
|
||||
const holen = (id: string): Sound | undefined => sounds.find((s) => s.id === id);
|
||||
|
||||
return {
|
||||
liste: () => sounds.map((s) => ({ ...s })),
|
||||
holen,
|
||||
|
||||
datei(id) {
|
||||
const s = holen(id);
|
||||
if (!s) return undefined;
|
||||
try {
|
||||
return { daten: fs.readFileSync(path.join(ordner, s.datei)), typ: s.typ };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
hinzufuegen(name, daten) {
|
||||
if (daten.length > MAX_BYTES) {
|
||||
throw new Error(`Zu gross: höchstens ${MAX_BYTES / 1024 / 1024} MB.`);
|
||||
}
|
||||
if (sounds.length >= MAX_SOUNDS) throw new Error(`Höchstens ${MAX_SOUNDS} Sounds.`);
|
||||
const typ = erkenneTyp(daten);
|
||||
if (!typ) throw new Error('Kein Ton, den OBS abspielt. Erlaubt sind MP3, WAV, OGG, WebM und M4A.');
|
||||
|
||||
const id = crypto.randomBytes(6).toString('hex');
|
||||
const datei = `${id}${ENDUNG[typ]}`;
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
fs.writeFileSync(path.join(ordner, datei), daten);
|
||||
const s: Sound = {
|
||||
id, name: saeubern(name) || 'Sound', emoji: '', farbe: FARBEN[sounds.length % FARBEN.length]!,
|
||||
laut: 0.8, datei, typ, bytes: daten.length, seit: new Date().toISOString(),
|
||||
};
|
||||
sounds.push(s);
|
||||
schreiben();
|
||||
return { ...s };
|
||||
},
|
||||
|
||||
aendern(id, was) {
|
||||
const s = holen(id);
|
||||
if (!s) return undefined;
|
||||
if (was.name !== undefined) s.name = saeubern(was.name) || s.name;
|
||||
if (was.emoji !== undefined) s.emoji = emojiOk(was.emoji);
|
||||
if (was.farbe !== undefined && (FARBEN as readonly string[]).includes(was.farbe)) s.farbe = was.farbe;
|
||||
if (was.laut !== undefined && Number.isFinite(was.laut)) {
|
||||
s.laut = Math.round(Math.max(0, Math.min(1, was.laut)) * 100) / 100;
|
||||
}
|
||||
schreiben();
|
||||
return { ...s };
|
||||
},
|
||||
|
||||
entfernen(id) {
|
||||
const i = sounds.findIndex((s) => s.id === id);
|
||||
if (i === -1) return false;
|
||||
const [weg] = sounds.splice(i, 1);
|
||||
schreiben();
|
||||
try { fs.unlinkSync(path.join(ordner, weg!.datei)); } catch { /* schon weg */ }
|
||||
return true;
|
||||
},
|
||||
|
||||
verschieben(id, um) {
|
||||
const i = sounds.findIndex((s) => s.id === id);
|
||||
const ziel = i + Math.sign(um);
|
||||
if (i === -1 || ziel < 0 || ziel >= sounds.length) return false;
|
||||
[sounds[i], sounds[ziel]] = [sounds[ziel]!, sounds[i]!];
|
||||
schreiben();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user