Overlay: fuenf Browser-Sources fuer OBS

Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein
Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein
Ding, das mitten im Stream kaputtgehen kann.

Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md:
Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource`
verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert
nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge:
der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein
SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar),
und ein Zustands-Schnappschuss beim Verbinden.

Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT
herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur
ueber EventSub.

GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten.

  gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif
  PRIVMSG #abdulive_ :[Idk GIF by Respective]

Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn.
Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]"
im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094
Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse
eben nicht im Text steht.

Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind:

- Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der
  Ausblend-Animation aus dem DOM verschwinden, und lief in eine
  Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag
  eingefroren.
- Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim
  Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe
  gueltig aus und wuerde den falschen Textteil ersetzen.

Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und
jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur
pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist
waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht,
sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus.

build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket
404 statt Overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-18 00:13:28 +02:00
1 parent 3b606e36fb
commit bc2c8f6cbe
24 files changed
+3221 -8

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
// Das Sitzungs-Cookie ist der einzige Schutz vor der Einrichtungsseite, auf
// der Twitch-Zugangsdaten eingetragen werden. Hier wird geprueft, dass es
// haelt, was es soll - und dass es einen Neustart übersteht.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { sitzungGueltig, sitzungToken } from './auth.ts';
const KEY = crypto.randomBytes(32);
const HASH = 'ein-passwort-hash';
const JETZT = Date.parse('2026-09-17T18:00:00Z');
const IN_30_TAGEN = JETZT + 30 * 24 * 3_600_000;
test('ein frisch ausgestelltes Cookie gilt', () => {
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), true);
});
test('ein Neustart meldet niemanden ab', () => {
// Der eigentliche Grund für diese Bauform: der Dienst startet bei jedem
// Ausrollen neu. Lag die Sitzungsliste im Arbeitsspeicher, war man danach
// jedes Mal draußen. Derselbe Schlüssel - dasselbe Ergebnis, ohne dass
// irgendwo etwas gemerkt wurde.
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
const nachNeustart = Buffer.from(KEY); // derselbe Wert, neues Objekt
assert.equal(sitzungGueltig(t, nachNeustart, HASH, JETZT), true);
});
test('abgelaufen ist abgelaufen', () => {
const t = sitzungToken(KEY, HASH, JETZT - 1);
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), false);
});
test('das Ablaufdatum laesst sich nicht verlaengern', () => {
// Ohne Signatur ueber das Datum koennte jeder das Cookie umschreiben und
// waere damit dauerhaft angemeldet.
const t = sitzungToken(KEY, HASH, JETZT + 1000);
const [, sig] = t.split('.');
const gefaelscht = `${Buffer.from(String(IN_30_TAGEN)).toString('base64url')}.${sig}`;
assert.equal(sitzungGueltig(gefaelscht, KEY, HASH, JETZT), false);
});
test('ein fremder Schluessel oeffnet nichts', () => {
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
assert.equal(sitzungGueltig(t, crypto.randomBytes(32), HASH, JETZT), false);
});
test('ein neues Passwort macht alle Cookies ungueltig', () => {
// Das ist der Ersatz für eine Abmeldeliste: wer sein Passwort wechselt,
// wirft damit alle offenen Sitzungen raus.
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
assert.equal(sitzungGueltig(t, KEY, 'neuer-hash', JETZT), false);
});
test('Unsinn im Cookie wird abgewiesen, nicht geglaubt', () => {
for (const müll of [undefined, '', 'abc', 'abc.def', '.', 'x.y.z',
`${Buffer.from('keine-zahl').toString('base64url')}.xx`]) {
assert.equal(sitzungGueltig(müll, KEY, HASH, JETZT), false, `"${müll}" haette abgewiesen gehoert`);
}
});
test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
// Bewusst so: ohne Zufallsanteil gibt es nichts, was der Server sich merken
// müsste. Der Preis ist, dass sich eine einzelne Sitzung nicht gezielt
// beenden lässt - dafür gibt es den Passwortwechsel.
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
});
+184
View File
@@ -0,0 +1,184 @@
// Zugangskontrolle. Nur noetig, seit der Dienst zentral laeuft: solange er an
// 127.0.0.1 hing, war "wer am Rechner sitzt, darf" die ganze Regel.
//
// Zwei Wege, weil es zwei Arten von Besuchern gibt:
//
// Mensch -> Passwort, einmal eingeben, danach ein Sitzungs-Cookie.
// Gilt fuer die Uebersicht, die Einrichtung und den Status.
// OBS -> Schluessel in der Adresse (?key=...). Eine Browser-Source
// kann kein Formular ausfuellen und keinen Login durchhalten;
// ein Geheimnis in der URL ist hier der einzige Weg, der ohne
// Handgriff mitten im Stream auskommt.
//
// Der Schluessel steht damit in jedem OBS-Profil und in jedem Screenshot der
// Einstellungen. Deshalb kann er nur lesen, nie schreiben, und er laesst sich
// auf der Uebersichtsseite neu wuerfeln.
import crypto from 'node:crypto';
import type http from 'node:http';
import { logger } from '#core/logger.ts';
import { readSecrets, updateSecrets } from '#twitch/secrets.ts';
const log = logger('overlay:zugang');
/** Wie lange eine Anmeldung gilt. Danach einmal neu eintippen. */
const SESSION_MS = 30 * 24 * 3_600_000;
const COOKIE = 'abdulive_sitzung';
export type Zugang = {
/** Darf diese Anfrage eine geschuetzte Seite sehen? */
istAngemeldet: (req: http.IncomingMessage) => boolean;
/** Stimmt der Schluessel in der Adresse? Angemeldete duerfen auch ohne. */
darfOverlay: (req: http.IncomingMessage, url: URL) => boolean;
/** Prueft das Passwort und gibt bei Erfolg den Cookie-Kopf zurueck. */
anmelden: (passwort: string) => string | null;
abmelden: () => string;
overlayKey: () => string;
neuerOverlayKey: () => string;
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
hatPasswort: () => boolean;
};
/**
* Vergleich in konstanter Zeit. Bei einem einzelnen Passwortfeld ist der
* Zeitunterschied kaum messbar, aber das ist kein Grund, ihn zu verschenken.
*/
function gleich(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
function hash(passwort: string, salt: string): string {
return crypto.scryptSync(passwort, salt, 32).toString('base64');
}
/**
* Das Cookie traegt sein eigenes Ablaufdatum und eine Signatur darueber.
* Der Server merkt sich keine Sitzungen.
*
* Der Grund ist eine gelernte Lektion: eine Liste im Arbeitsspeicher ist
* hinfaellig, sobald der Dienst neu startet - und er startet bei jedem
* Ausrollen neu. Wer sich nach jeder Aenderung neu anmelden muss, waehlt
* irgendwann ein kuerzeres Passwort.
*
* Mit signiert wird auch der Passwort-Hash. Ein neues Passwort macht damit
* alle ausgegebenen Cookies ungueltig, ohne dass es eine Abmeldeliste braucht.
*/
export function sitzungToken(key: Buffer, passwordHash: string, bisMs: number): string {
const bis = String(bisMs);
return `${Buffer.from(bis).toString('base64url')}.${signatur(key, passwordHash, bis)}`;
}
export function sitzungGueltig(
token: string | undefined, key: Buffer, passwordHash: string, jetztMs = Date.now()
): boolean {
if (!token) return false;
const [bisB64, sig] = token.split('.');
if (!bisB64 || !sig) return false;
const bis = Buffer.from(bisB64, 'base64url').toString('utf8');
if (!/^\d+$/.test(bis) || Number(bis) < jetztMs) return false;
return gleich(sig, signatur(key, passwordHash, bis));
}
function signatur(key: Buffer, passwordHash: string, bis: string): string {
return crypto.createHmac('sha256', key)
.update(`${bis}|${passwordHash}`)
.digest('base64url');
}
function cookies(req: http.IncomingMessage): Record<string, string> {
const out: Record<string, string> = {};
for (const teil of (req.headers.cookie ?? '').split(';')) {
const i = teil.indexOf('=');
if (i > 0) out[teil.slice(0, i).trim()] = decodeURIComponent(teil.slice(i + 1).trim());
}
return out;
}
export function createZugang(): Zugang {
function store() {
const s = readSecrets();
return s.web ?? {};
}
// Beim ersten Start einen Overlay-Schluessel wuerfeln. Ohne einen waere
// jede Browser-Source-Adresse zu erraten.
function key(): string {
const vorhanden = store().overlayKey;
if (vorhanden) return vorhanden;
const neu = crypto.randomBytes(18).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
log.info('Neuer Overlay-Schluessel erzeugt - die Adressen stehen auf der Uebersichtsseite');
return neu;
}
/**
* Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird
* einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren,
* sind nur die Anmeldungen weg.
*/
function sitzungsKey(): Buffer {
const vorhanden = store().sessionKey;
if (vorhanden) return Buffer.from(vorhanden, 'base64');
const neu = crypto.randomBytes(32);
updateSecrets((s) => { s.web = { ...s.web, sessionKey: neu.toString('base64') }; });
return neu;
}
function istAngemeldet(req: http.IncomingMessage): boolean {
const hash = store().passwordHash;
if (!hash) return true; // noch nichts eingerichtet
return sitzungGueltig(cookies(req)[COOKIE], sitzungsKey(), hash);
}
return {
hatPasswort: () => Boolean(store().passwordHash),
istAngemeldet,
darfOverlay(req, url) {
const gegeben = url.searchParams.get('key');
if (gegeben && gleich(gegeben, key())) return true;
// Wer angemeldet ist, darf die Seiten auch im Browser ansehen - sonst
// muesste man zum Nachschauen jedes Mal den Schluessel anhaengen.
return istAngemeldet(req);
},
anmelden(passwort) {
const s = store();
if (!s.passwordHash || !s.passwordSalt) return null;
if (!gleich(hash(passwort, s.passwordSalt), s.passwordHash)) {
log.warn('Fehlgeschlagene Anmeldung');
return null;
}
const token = sitzungToken(sitzungsKey(), s.passwordHash, Date.now() + SESSION_MS);
// Kein `Secure`: NPM beendet TLS, und der Dienst selbst spricht HTTP.
// Das Cookie geht trotzdem nur ueber die HTTPS-Adresse raus, weil es
// keine andere gibt, die von aussen erreichbar ist.
return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MS / 1000}`;
},
abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
overlayKey: key,
neuerOverlayKey() {
const neu = crypto.randomBytes(18).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
log.info('Overlay-Schluessel neu gewuerfelt - die alten Adressen sind damit tot');
return neu;
},
};
}
/**
* Setzt das Passwort. Wird von `abdulive web:passwort` aufgerufen, nicht ueber
* das Netz - ein Dienst, bei dem man das Passwort ueber die offene
* Weboberflaeche setzen kann, hat kein Passwort.
*/
export function setzePasswort(passwort: string): void {
if (passwort.length < 8) throw new Error('Mindestens acht Zeichen.');
const salt = crypto.randomBytes(16).toString('base64');
updateSecrets((s) => {
s.web = { ...s.web, passwordSalt: salt, passwordHash: hash(passwort, salt) };
});
}
+110
View File
@@ -0,0 +1,110 @@
// Der Bus entscheidet, was eine frisch verbundene Browser-Source sieht.
// Danach richtet sich, ob das Overlay nach einem Neustart vollstaendig ist
// oder leer im Bild steht - und ob eine geloeschte Nachricht geloescht bleibt.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBus, type ChatEvent, type OverlayEvent } from './bus.ts';
function nachricht(over: Partial<ChatEvent> = {}): ChatEvent {
return {
kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin',
text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false,
sub: false, broadcaster: false, bits: 0,
...over,
};
}
function schnappschuss(bus: ReturnType<typeof createBus>) {
const s = bus.snapshot();
assert.equal(s.kind, 'state');
return s as Extract<OverlayEvent, { kind: 'state' }>;
}
test('ein neuer Zuhoerer bekommt den bisherigen Chat', () => {
const bus = createBus();
bus.publish(nachricht({ id: 'a' }));
bus.publish(nachricht({ id: 'b' }));
const s = schnappschuss(bus);
assert.deepEqual(s.chat.map((m) => m.id), ['a', 'b']);
});
test('der Rueckblick waechst nicht unbegrenzt', () => {
const bus = createBus();
for (let i = 0; i < 100; i++) bus.publish(nachricht({ id: `m${i}` }));
const s = schnappschuss(bus);
assert.equal(s.chat.length, 30);
assert.equal(s.chat[s.chat.length - 1]?.id, 'm99', 'die neuesten bleiben');
});
test('der zuletzt gemeldete Stream-Zustand steht im Schnappschuss', () => {
const bus = createBus();
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
viewers: 12, startedAt: '2026-09-17T18:00:00Z' });
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
viewers: 19, startedAt: '2026-09-17T18:00:00Z' });
assert.equal(schnappschuss(bus).stream.viewers, 19);
});
test('eine geloeschte Nachricht verschwindet auch aus dem Rueckblick', () => {
// Sonst holt der naechste Neustart der Browser-Source sie zurueck ins Bild -
// und zwar genau die eine, die ein Mod entfernt hat.
const bus = createBus();
bus.publish(nachricht({ id: 'a' }));
bus.publish(nachricht({ id: 'b' }));
bus.publish({ kind: 'clear', messageId: 'a' });
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
});
test('ein gesperrter Chatter verschwindet mit allen Nachrichten', () => {
const bus = createBus();
bus.publish(nachricht({ id: 'a', userId: 'u1' }));
bus.publish(nachricht({ id: 'b', userId: 'u2' }));
bus.publish(nachricht({ id: 'c', userId: 'u1' }));
bus.publish({ kind: 'clear', userId: 'u1' });
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
});
test('ein geleerter Chat leert auch den Rueckblick', () => {
const bus = createBus();
bus.publish(nachricht({ id: 'a' }));
bus.publish({ kind: 'clear' });
assert.deepEqual(schnappschuss(bus).chat, []);
});
test('alte Einblendungen werden nicht endlos aufbewahrt', () => {
const bus = createBus();
for (let i = 0; i < 20; i++) {
bus.publish({ kind: 'alert', type: 'sub', id: `a${i}`, name: 'x' });
}
assert.equal(schnappschuss(bus).alerts.length, 5);
});
test('ein Zuhoerer, der wirft, reisst die anderen nicht mit', () => {
// Eine Browser-Source, deren Verbindung gerade zusammengebrochen ist, darf
// den Chat nicht fuer alle anderen anhalten.
const bus = createBus();
const gesehen: string[] = [];
bus.subscribe(() => { throw new Error('Verbindung weg'); });
bus.subscribe((ev) => { gesehen.push(ev.kind); });
bus.publish(nachricht());
assert.deepEqual(gesehen, ['chat']);
});
test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
const bus = createBus();
let n = 0;
const ab = bus.subscribe(() => { n++; });
bus.publish(nachricht());
ab();
bus.publish(nachricht());
assert.equal(n, 1);
assert.equal(bus.clients(), 0);
});
+155
View File
@@ -0,0 +1,155 @@
// Der Ereignisbus zwischen Daemon und Browser-Sources.
//
// Warum Server-Sent Events und nicht WebSocket, wie in chat-control.md
// skizziert: Node bringt einen WebSocket-*Client* mit, aber keinen Server -
// den muesste man selbst schreiben, inklusive Handshake und Rahmenformat.
// SSE ist einfacher HTTP, `EventSource` verbindet sich von allein neu, und
// die Richtung stimmt: das Overlay hoert nur zu, es sendet nie.
//
// Der haeufigste Fehler bei solchen Overlays ist die Browser-Source, die nach
// einem Serverneustart tot im Bild haengt. Dagegen stehen hier drei Dinge:
// der eingebaute Reconnect von EventSource, ein Herzschlag alle paar
// Sekunden, und der Zustands-Schnappschuss, den jeder neue Client als erstes
// bekommt - damit ist das Overlay nach dem Neuverbinden sofort wieder
// vollstaendig und nicht erst nach der naechsten Chatnachricht.
export type ChatEvent = {
kind: 'chat';
/** msg-id aus IRC - dieselbe ID, mit der CLEARMSG spaeter loeschen kann. */
id: string;
userId: string;
login: string;
name: string;
text: string;
color: string | null;
emotes: Array<{ id: string; name: string; count: number }>;
/** GIFs aus Twitchs Auswahlfenster, mit dem Zeichenbereich im Text. */
gifs: Array<{ id: string; url: string; from: number; to: number }>;
first: boolean;
mod: boolean;
sub: boolean;
broadcaster: boolean;
bits: number;
};
export type AlertKind =
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
/**
* Kanalpunkt-Einloesung. Bewusst eine eigene Art und nicht die des Abos:
* eine eingeloeste Belohnung als "Neues Abo" einzublenden ist eine
* Falschmeldung im Bild, und zwar eine, die in der Aufzeichnung bleibt.
*/
| 'reward';
export type AlertEvent = {
kind: 'alert';
type: AlertKind;
id: string;
name: string;
/** Bits, Zuschauer beim Raid, Anzahl verschenkter Abos. */
amount?: number;
months?: number;
tier?: string;
recipient?: string;
message?: string;
/** Titel der eingeloesten Belohnung. Nur bei `reward`. */
reward?: string;
};
export type StreamEvent = {
kind: 'stream';
live: boolean;
title: string;
category: string;
viewers: number;
startedAt: string | null;
};
export type OverlayEvent =
| ChatEvent
| AlertEvent
| StreamEvent
| { kind: 'hype'; score: number; rate: number }
/**
* Konfetti als eigenes Ereignis, nicht als Nebenwirkung einer Einblendung.
* So muss die Effektseite nicht wissen, welche Belohnung welche Aktion
* ausloest - das entscheidet der Hub.
*/
| { kind: 'konfetti'; staerke: number }
/**
* Vorzulesender Text. Geht nur raus, wenn er freigegeben wurde oder die
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
* (apps/hub/src/rewards.ts), nicht hier.
*/
| { kind: 'tts'; id: string; name: string; text: string }
/** Alles sofort still. Fuer den Panikschalter. */
| { kind: 'still' }
| { kind: 'scene'; scene: string }
| { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number }
| { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] };
/**
* So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
* braucht keine Seite: der Chat zeigt ein gutes Dutzend, und ein Alarm von
* vor zehn Minuten soll nach einem Neustart gerade nicht noch einmal
* durchlaufen.
*/
const KEEP_CHAT = 30;
const KEEP_ALERTS = 5;
export type Bus = {
publish: (ev: OverlayEvent) => void;
/** Meldet einen Zuhoerer an. Der Rueckgabewert meldet ihn wieder ab. */
subscribe: (fn: (ev: OverlayEvent) => void) => () => void;
/** Der Zustand, den ein neuer Client als erstes bekommt. */
snapshot: () => OverlayEvent;
clients: () => number;
};
export function createBus(): Bus {
const listeners = new Set<(ev: OverlayEvent) => void>();
const chat: ChatEvent[] = [];
const alerts: AlertEvent[] = [];
let stream: StreamEvent = {
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
};
return {
publish(ev) {
// Mitschreiben, bevor verteilt wird: ein Client, der genau jetzt
// verbindet, soll dasselbe sehen wie einer, der schon verbunden war.
if (ev.kind === 'chat') {
chat.push(ev);
if (chat.length > KEEP_CHAT) chat.shift();
} else if (ev.kind === 'alert') {
alerts.push(ev);
if (alerts.length > KEEP_ALERTS) alerts.shift();
} else if (ev.kind === 'stream') {
stream = ev;
} else if (ev.kind === 'clear') {
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
// naechste Neustart der Browser-Source es zurueck ins Bild.
for (let i = chat.length - 1; i >= 0; i--) {
const m = chat[i]!;
if ((ev.messageId && m.id === ev.messageId) ||
(ev.userId && m.userId === ev.userId)) chat.splice(i, 1);
}
if (!ev.messageId && !ev.userId) chat.length = 0;
}
for (const fn of listeners) {
// Ein kaputter Zuhoerer darf die anderen nicht mitreissen.
try { fn(ev); } catch { /* der Transport meldet es selbst */ }
}
},
subscribe(fn) {
listeners.add(fn);
return () => { listeners.delete(fn); };
},
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }),
clients: () => listeners.size,
};
}
+56
View File
@@ -0,0 +1,56 @@
// Dauerbetrieb mit erfundenen Ereignissen: erfundener Chat und alle paar
// Sekunden eine Einblendung.
//
// Fuer `abdulive overlay --demo` und fuer den Knopf "Vorfuehrung" auf der
// Uebersichtsseite. Die einzelnen Ereignisse kommen aus test-events.ts - die
// Knoepfe und der Dauerbetrieb sollen dasselbe zeigen.
import { logger } from '#core/logger.ts';
import type { Bus } from './bus.ts';
import { testEvent, zufall, type TestArt } from './test-events.ts';
const log = logger('overlay:demo');
const ALARME: TestArt[] = ['sub', 'resub', 'subgift', 'massgift', 'raid', 'cheer', 'first'];
const NACHRICHTEN: TestArt[] = ['chat', 'chat', 'chat', 'chat-emote', 'chat-emote', 'chat-erstmals'];
/**
* Speist den Bus, bis `signal` abbricht.
*
* Der Stream-Zustand wird mitgesetzt, damit Statusleiste und Pausenbild etwas
* zu zeigen haben. Genau deshalb darf das nicht laufen, waehrend ein echter
* Stream laeuft - der Aufrufer prueft das.
*/
export function startDemo(bus: Bus, signal: AbortSignal): void {
log.info('Vorfuehrmodus: erfundener Chat und alle paar Sekunden eine Einblendung');
let viewers = 17;
const seit = new Date(Date.now() - 74 * 60_000).toISOString();
const stand = (): void => {
viewers = Math.max(3, viewers + Math.round((Math.random() - 0.45) * 4));
bus.publish({
kind: 'stream', live: true,
title: 'Elden Ring, aber nur mit Fäusten - Tag 3',
category: 'Elden Ring',
viewers, startedAt: seit,
});
};
stand();
const sende = (art: TestArt): void => {
const ev = testEvent(art);
if (ev) bus.publish(ev);
};
const chat = setInterval(() => sende(zufall(NACHRICHTEN)), 1400);
const alarm = setInterval(() => sende(zufall(ALARME)), 9000);
const puls = setInterval(stand, 20_000);
// Ab und zu eine Chat-Spitze, damit der Emote-Regen auch mal laeuft.
const spitze = setInterval(() => sende('hype'), 31_000);
signal.addEventListener('abort', () => {
clearInterval(chat); clearInterval(alarm); clearInterval(puls); clearInterval(spitze);
log.info('Vorfuehrmodus beendet');
}, { once: true });
}
+627
View File
@@ -0,0 +1,627 @@
// Der einzige Server, der von aussen erreichbar ist.
//
// Er macht drei Dinge unter einer Adresse, weil ein zweiter oeffentlicher
// Port ein zweiter Eintrag im Reverse-Proxy und ein zweites Zertifikat waere:
//
// / Uebersicht mit den Adressen fuer OBS (Passwort)
// /setup Einrichtung: Twitch-Apps, Tokens, Discord (Passwort)
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
// /alerts ... die Browser-Sources (Schluessel)
// /events der SSE-Strom dahinter (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
// ADR-0005, und der Schutz wandert dafuer von "nur localhost" auf Passwort
// und Schluessel. Ohne PUBLIC_URL bleibt es bei 127.0.0.1.
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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 { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
const log = logger('overlay');
const WEB_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'overlay-web', 'public');
/**
* Herzschlag. OBS' Browser-Source haelt eine tote Verbindung sonst fuer
* lebendig, bis die naechste Chatnachricht kommt - und wenn der Chat still
* ist, ist das eine Stunde.
*/
const HEARTBEAT_MS = 15_000;
const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.woff2': 'font/woff2',
};
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
export const PAGES = [
{ path: '/alerts', file: 'alerts.html', w: 1920, h: 1080,
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
{ path: '/status', file: 'status.html', w: 1920, h: 160,
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
title: 'Effekte', hint: 'Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids.' },
{ path: '/starting', file: 'starting.html', w: 1920, h: 1080,
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.' },
] as const;
export type OverlayServer = {
bus: Bus;
zugang: Zugang;
close: () => void;
port: number;
};
export type OverlayOptions = {
port: number;
channel: string;
/** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */
publicUrl: string;
bus?: Bus;
/** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */
setup?: ServerDeps;
/**
* Die Freigabe wartender Vorlese-Nachrichten. Der Hub reicht sie herein;
* ohne sie zeigt die Seite den Abschnitt gar nicht erst an.
*/
tts?: {
wartend: () => Array<{ id: string; name: string; text: string; seit: string }>;
freigeben: (id: string) => boolean;
ablehnen: (id: string) => boolean;
alleAblehnen: () => number;
still: () => void;
};
};
export function startOverlayServer(opts: OverlayOptions): OverlayServer {
const bus = opts.bus ?? createBus();
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 };
// 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) => {
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Interner Fehler');
});
});
server.listen(opts.port, host, () => {
log.info(`Overlay-Server laeuft auf ${host}:${opts.port}` +
(opts.publicUrl ? ` (oeffentlich: ${opts.publicUrl})` : ''));
if (!zugang.hatPasswort() && opts.publicUrl) {
log.warn('Es ist kein Passwort gesetzt - die Einrichtungsseite steht offen. ' +
'Setzen mit: abdulive web:passwort');
}
});
server.on('error', (err) => {
log.error(`Overlay-Server nicht gestartet: ${err.message}` +
((err as NodeJS.ErrnoException).code === 'EADDRINUSE'
? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : ''));
});
return { bus, zugang, close: () => server.close(), port: opts.port };
}
type Vorfuehrung = { abbruch: AbortController | null };
async function handle(
req: http.IncomingMessage, res: http.ServerResponse,
bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung
): Promise<void> {
const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`);
const p = url.pathname;
// ---- 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 });
res.end();
return;
}
if (p === '/logout') {
res.writeHead(303, { location: '/', 'set-cookie': zugang.abmelden() });
res.end();
return;
}
// ---- Der Rueckruf von Twitch. Kein Passwort: Twitch schickt den Browser
// hierher zurueck, und der `state`-Wert ist der Schutz.
if (p === '/callback' && opts.setup) {
if (await setupRoutes(req, res, opts.setup, url)) return;
}
// ---- 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(/^\/+/, ''));
// ---- Die Browser-Sources und ihr Datenstrom.
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
if (page || p === '/events') {
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);
return sendFile(res, page!.file);
}
// ---- Ab hier nur mit Anmeldung.
if (!zugang.istAngemeldet(req)) {
return send(res, 401, 'text/html; charset=utf-8',
loginPage(opts.channel, url.searchParams.has('fehler')));
}
// ---- 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') {
const form = await readForm(req);
const art = (form.get('art') ?? '') as TestArt | 'demo-an' | 'demo-aus';
const laeuftStream = zustand(bus).live;
const antwort = (text: string, code = 200): void =>
send(res, code, 'application/json; charset=utf-8',
JSON.stringify({ text, demo: vorfuehrung.abbruch !== null }));
if (art === 'demo-aus') {
vorfuehrung.abbruch?.abort();
vorfuehrung.abbruch = null;
// Der letzte erfundene Stand bleibt sonst im Bild stehen.
bus.publish({ kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null });
return antwort('Vorführung gestoppt.');
}
if (art === 'demo-an') {
if (laeuftStream) {
return antwort('Nicht während eines Streams: die Vorführung überschreibt ' +
'Titel, Kategorie und Zuschauerzahl in der Statusleiste.', 409);
}
vorfuehrung.abbruch?.abort();
vorfuehrung.abbruch = new AbortController();
startDemo(bus, vorfuehrung.abbruch.signal);
return antwort('Vorführung läuft.');
}
const knopf = TEST_KNOEPFE.find((k) => k.art === art);
if (!knopf) return antwort('Unbekannter Knopf.', 400);
if (knopf.nurOffline && laeuftStream) {
return antwort('Gesperrt, solange der Stream läuft - das würde in der ' +
'Statusleiste stehen bleiben.', 409);
}
// Die neueste Nachricht aus dem Rueckblick, damit "letzte löschen" etwas
// trifft, das auch wirklich im Bild steht.
const chat = zustand(bus).chat;
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
bus.publish(ev);
log.info(`Testereignis ausgeloest: ${knopf.art}`);
return antwort(`${knopf.label} ausgelöst.`);
}
// ---- Vorlese-Warteschlange
if (p === '/tts' && req.method === 'POST' && opts.tts) {
const form = await readForm(req);
const was = form.get('was') ?? '';
const id = form.get('id') ?? '';
const antwort = (text: string, code = 200): void =>
send(res, code, 'application/json; charset=utf-8',
JSON.stringify({ text, wartend: opts.tts!.wartend() }));
if (was === 'still') { opts.tts.still(); return antwort('Alles still.'); }
if (was === 'alle-ablehnen') {
const n = opts.tts.alleAblehnen();
return antwort(`${n} Nachricht(en) abgelehnt.`);
}
if (was === 'freigeben') {
return antwort(opts.tts.freigeben(id) ? 'Wird vorgelesen.' : 'Nicht mehr da.');
}
if (was === 'ablehnen') {
return antwort(opts.tts.ablehnen(id) ? 'Abgelehnt.' : 'Nicht mehr da.');
}
return antwort('Unbekannt.', 400);
}
if (p === '/tts/wartend' && opts.tts) {
return send(res, 200, 'application/json; charset=utf-8',
JSON.stringify({ wartend: opts.tts.wartend() }));
}
if (p === '/schluessel/neu' && req.method === 'POST') {
zugang.neuerOverlayKey();
return redirect(res, '/');
}
if (p === '/' || p === '/index.html') {
return send(res, 200, 'text/html; charset=utf-8',
indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null));
}
if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return;
send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden. Die Uebersicht steht auf /');
}
// ---------------------------------------------------------------- SSE
function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void {
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
// Ohne das puffert so mancher Proxy den Strom, und die Einblendung kommt
// gesammelt nach zwei Minuten. Nginx beachtet genau diesen Kopf.
'x-accel-buffering': 'no',
});
const write = (ev: OverlayEvent): void => {
// `retry` sagt dem Browser, wie schnell er neu verbinden soll. Vorgabe
// waeren drei Sekunden; nach einem Neustart mitten im Stream ist eine
// Sekunde besser, und mehr kostet es hier nicht.
res.write(`retry: 1000\ndata: ${JSON.stringify(ev)}\n\n`);
};
// Erst anmelden, dann den Schnappschuss: andersherum wuerde ein Ereignis,
// das genau dazwischen faellt, verloren gehen.
const unsubscribe = bus.subscribe(write);
write(bus.snapshot());
// Als echtes Ereignis, nicht als SSE-Kommentar: ein Kommentar haelt die
// Verbindung zwar offen, ist im Browser aber unsichtbar. Die Seite soll
// merken koennen, dass laengst nichts mehr kam, und selbst neu verbinden.
const beat = setInterval(
() => res.write(`data: ${JSON.stringify({ kind: 'beat', t: Date.now() })}\n\n`),
HEARTBEAT_MS);
beat.unref();
const end = (): void => { clearInterval(beat); unsubscribe(); };
req.on('close', end);
res.on('error', end);
}
/** Der aktuelle Stand aus dem Bus, ohne den Typ an jeder Stelle aufzudroeseln. */
function zustand(bus: Bus) {
const s = bus.snapshot();
if (s.kind !== 'state') throw new Error('Schnappschuss hat die falsche Form');
return { live: s.stream.live, chat: s.chat };
}
// ---------------------------------------------------------------- Werkzeug
function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on('data', (c: Buffer) => {
size += c.length;
if (size > 16 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))));
req.on('error', reject);
});
}
function redirect(res: http.ServerResponse, to: string): void {
res.writeHead(303, { location: to });
res.end();
}
function send(res: http.ServerResponse, code: number, type: string, body: string | Buffer): void {
res.writeHead(code, {
'content-type': type,
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
// Der Schluessel steht in der Adresse. Ohne diesen Kopf traegt ihn jeder
// Bildaufruf zu Twitchs Emote-CDN im Referer mit hinaus.
});
res.end(body);
}
function sendFile(res: http.ServerResponse, rel: string): void {
// Aufloesen und danach pruefen, ob das Ergebnis noch unter WEB_ROOT liegt.
// `..` im Pfad ist der Klassiker, und ein Server, der beliebige Dateien
// ausliefert, liefert irgendwann secrets.json aus.
const file = path.resolve(WEB_ROOT, rel);
if (!file.startsWith(WEB_ROOT + path.sep)) {
return send(res, 403, 'text/plain; charset=utf-8', 'Nicht erlaubt');
}
let data: Buffer;
try {
data = fs.readFileSync(file);
} catch {
return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden');
}
send(res, 200, TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream', data);
}
// ---------------------------------------------------------------- Seiten
const SEITEN_CSS = `
:root { color-scheme: dark; }
body { margin: 0; padding: 2.5rem clamp(1rem, 5vw, 4rem); background: #0d0b14; color: #e9e6f3;
font: 16px/1.6 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; } h1 span { color: #a970ff; }
p.lead { color: #9b94b3; margin: 0 0 2rem; max-width: 62ch; }
table { border-collapse: collapse; width: 100%; max-width: 74rem; }
td { padding: .9rem 1rem; border-top: 1px solid #241f35; vertical-align: top; }
tr:first-child td { border-top: 0; }
.hint { color: #8b84a3; font-size: .875rem; }
.size { color: #8b84a3; white-space: nowrap; font-variant-numeric: tabular-nums; }
code { background: #171327; padding: .25rem .5rem; border-radius: .4rem; word-break: break-all;
font-family: "Cascadia Mono", Consolas, monospace; font-size: .8rem; }
button, a.knopf { font: inherit; font-size: .85rem; margin-left: .5rem; background: none;
border: 1px solid #3a3352; color: #c9c2e0; border-radius: .4rem;
padding: .2rem .6rem; cursor: pointer; text-decoration: none; }
button:hover, a.knopf:hover { border-color: #a970ff; color: #fff; }
input { font: inherit; background: #171327; border: 1px solid #3a3352; color: #e9e6f3;
border-radius: .4rem; padding: .5rem .7rem; }
.status { margin-top: 2rem; color: #8b84a3; font-size: .9rem; }
.dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%;
background: #ef4444; margin-right: .4rem; }
.dot.on { background: #22c55e; }
ol { max-width: 62ch; color: #9b94b3; }
.warn { color: #ffc53d; }
h2 { font-size: 1.1rem; margin: 2.5rem 0 .4rem; }
.gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
margin-bottom: .6rem; }
.gruppenname { display: inline-block; min-width: 9rem; color: #8b84a3;
font-size: .85rem; }
button.test { margin-left: 0; }
button.test:disabled { opacity: .35; cursor: not-allowed; border-color: #241f35; }
.warnbox { border: 1px solid #ffc53d; color: #ffc53d; border-radius: .5rem;
padding: .6rem .9rem; margin: .8rem 0; max-width: 62ch; }
.meldung { min-height: 1.5rem; font-size: .9rem; color: #8b84a3; margin-top: .8rem; }
.meldung.ok { color: #4ade80; }
.meldung.fehler { color: #ffc53d; }
.wartend { display: flex; justify-content: space-between; align-items: flex-start;
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
.wartend:first-child { border-top: 0; }
.wartend .hint { max-width: 60ch; word-break: break-word; }
button.tts { margin-left: .4rem; }
`;
export function loginPage(channel: string, fehler: boolean): string {
return `<!doctype html><html lang="de"><meta charset="utf-8">
<title>Anmelden</title><style>${SEITEN_CSS}
body { display: grid; place-items: center; height: 100vh; padding: 0; }
form { text-align: left; }
</style>
<form method="post" action="/login">
<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">
<button type="submit">Anmelden</button>
</form>
</html>`;
}
/**
* Die Seite, die man aufmacht, bevor man OBS einrichtet. Sie ist bewusst hier
* und nicht in public/: sie kennt die oeffentliche Adresse, den Schluessel und
* die Seitenliste, und das soll nicht an zwei Stellen stehen.
*/
export function indexPage(
opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false
): string {
// Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt -
// im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben.
const ttsFeld = opts.tts ? `
<h2>Vorlesen: wartet auf Freigabe</h2>
<p class="lead">Nichts wird vorgelesen, bevor du hier klickst. Ablehnen gibt die
Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
<div id="wartend"><span class="hint">wird geladen …</span></div>
<div class="gruppe" style="margin-top:.8rem">
<button class="tts" data-was="alle-ablehnen">Alle ablehnen</button>
<button class="tts" data-was="still">Sofort still</button>
<span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span>
</div>
<p id="tts-meldung" class="meldung">&nbsp;</p>` : '';
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
const rows = PAGES.map((p) => {
const id = `u${p.path.slice(1)}`;
return `
<tr>
<td><strong>${p.title}</strong><div class="hint">${p.hint}</div></td>
<td class="size">${p.w} &times; ${p.h}</td>
<td>
<code id="${id}">${base}${p.path}?key=${key}</code>
<button data-copy="${id}">kopieren</button>
<a class="knopf" href="${p.path}?probe=1" target="_blank" rel="noreferrer">ansehen</a>
</td>
</tr>`;
}).join('');
const gruppen = ['Einblendungen', 'Kanalpunkte', 'Chat', 'Effekte', 'Stand'] as const;
const testfeld = gruppen.map((g) => `
<div class="gruppe">
<span class="gruppenname">${g}</span>
${TEST_KNOEPFE.filter((k) => k.gruppe === g).map((k) =>
`<button class="test" data-art="${k.art}"${k.nurOffline ? ' data-offline="1"' : ''}>${k.label}</button>`
).join('')}
</div>`).join('');
return `<!doctype html><html lang="de"><meta charset="utf-8">
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}</style>
<h1>Overlay <span>${escape(opts.channel)}</span></h1>
<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>
<table>${rows}</table>
<p class="status"><span class="dot" id="dot"></span><span id="txt">verbinde ...</span>
&nbsp;·&nbsp; ${bus.clients()} verbundene Quelle(n)
${opts.setup ? '&nbsp;·&nbsp; <a class="knopf" href="/setup">Einrichtung</a>' +
'<a class="knopf" href="/health">Status</a>' : ''}
<a class="knopf" href="/logout">Abmelden</a>
</p>
<h2>Ausprobieren</h2>
<p class="lead">Löst ein erfundenes Ereignis aus. Es geht nur an die
Browser-Sources - nichts davon landet in der Datenbank, auf Discord oder bei
Twitch. Am besten die Seiten in einem zweiten Fenster offen haben
(&bdquo;ansehen&ldquo; oben).</p>
<div id="live-warnung" class="warnbox" hidden>
Der Stream läuft gerade. Was du hier auslöst, sehen die Zuschauer.
</div>
${testfeld}
<div class="gruppe">
<span class="gruppenname">Dauerbetrieb</span>
<button class="test" id="demo" data-art="${demoLaeuft ? 'demo-aus' : 'demo-an'}">
${demoLaeuft ? 'Vorführung stoppen' : 'Vorführung starten'}
</button>
<span class="hint">Erfundener Chat und alle paar Sekunden eine Einblendung,
bis du wieder stoppst.</span>
</div>
<p id="meldung" class="meldung">&nbsp;</p>
${ttsFeld}
<h2>Schlüssel</h2>
<form method="post" action="/schluessel/neu">
<button type="submit">Schlüssel neu würfeln</button>
<span class="hint">Danach müssen alle Adressen in OBS neu eingetragen werden.</span>
</form>
<ol>
<li>Alle Seiten haben einen durchsichtigen Hintergrund. In OBS nichts weiter einstellen.</li>
<li>Jede Seite verbindet sich von allein neu - ein Neustart des Dienstes kostet eine Sekunde.</li>
<li>Der Schlüssel in der Adresse darf nur lesen. Er landet in jedem OBS-Profil, also nicht weitergeben.</li>
</ol>
<script>
for (const b of document.querySelectorAll('button[data-copy]')) {
b.addEventListener('click', async () => {
await navigator.clipboard.writeText(document.getElementById(b.dataset.copy).textContent);
b.textContent = 'kopiert';
setTimeout(() => { b.textContent = 'kopieren'; }, 1200);
});
}
const es = new EventSource('/events');
const dot = document.getElementById('dot'), txt = document.getElementById('txt');
es.onopen = () => { dot.classList.add('on'); txt.textContent = 'verbunden'; };
es.onerror = () => { dot.classList.remove('on'); txt.textContent = 'getrennt, versuche es erneut'; };
const warnung = document.getElementById('live-warnung');
const meldung = document.getElementById('meldung');
function setzeLive(live) {
warnung.hidden = !live;
// Knöpfe, die einen Zustand setzen, bleiben während eines Streams aus:
// sie würden in der Statusleiste stehen bleiben.
for (const b of document.querySelectorAll('button.test[data-offline]')) b.disabled = live;
const demo = document.getElementById('demo');
if (demo.dataset.art === 'demo-an') demo.disabled = live;
}
es.onmessage = (e) => {
const ev = JSON.parse(e.data);
if (ev.kind === 'state') {
txt.textContent = ev.stream.live
? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel')
: 'verbunden - kein Stream';
setzeLive(ev.stream.live);
} else if (ev.kind === 'stream') {
setzeLive(ev.live);
}
};
let sagAb;
async function loese(art) {
const res = await fetch('/test', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ art }),
});
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
meldung.textContent = body.text;
meldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
clearTimeout(sagAb);
sagAb = setTimeout(() => { meldung.innerHTML = '&nbsp;'; meldung.className = 'meldung'; }, 4000);
const demo = document.getElementById('demo');
demo.dataset.art = body.demo ? 'demo-aus' : 'demo-an';
demo.textContent = body.demo ? 'Vorführung stoppen' : 'Vorführung starten';
}
for (const b of document.querySelectorAll('button.test')) {
b.addEventListener('click', () => loese(b.dataset.art));
}
const wartendEl = document.getElementById('wartend');
if (wartendEl) {
const ttsMeldung = document.getElementById('tts-meldung');
function zeichne(liste) {
if (!liste.length) {
wartendEl.innerHTML = '<span class="hint">Nichts wartet.</span>';
return;
}
wartendEl.innerHTML = liste.map((w) => \`
<div class="wartend">
<div>
<strong>\${w.name}</strong>
<div class="hint">\${w.text}</div>
</div>
<div style="white-space:nowrap">
<button class="tts" data-was="freigeben" data-id="\${w.id}">Vorlesen</button>
<button class="tts" data-was="ablehnen" data-id="\${w.id}">Ablehnen</button>
</div>
</div>\`).join('');
}
async function ttsRuf(was, id) {
const res = await fetch('/tts', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ was, id: id || '' }),
});
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status, wartend: [] }));
ttsMeldung.textContent = body.text || '';
zeichne(body.wartend || []);
setTimeout(() => { ttsMeldung.innerHTML = '&nbsp;'; }, 4000);
}
wartendEl.addEventListener('click', (e) => {
const b = e.target.closest('button.tts');
if (b) ttsRuf(b.dataset.was, b.dataset.id);
});
for (const b of document.querySelectorAll('.gruppe button.tts')) {
b.addEventListener('click', () => ttsRuf(b.dataset.was));
}
// Kurzer Takt: eine Einlösung soll nicht erst nach einem Neuladen
// auftauchen. Kostet nichts, die Liste ist winzig.
const laden = () => fetch('/tts/wartend')
.then((r) => r.json()).then((b) => zeichne(b.wartend || [])).catch(() => {});
laden();
setInterval(laden, 3000);
}
</script>
</html>`;
}
function escape(s: string): string {
return s.replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]!));
}
+117
View File
@@ -0,0 +1,117 @@
// Die Testknoepfe sind das einzige Werkzeug, mit dem sich das Overlay
// ausserhalb eines Streams pruefen laesst. Geht hier etwas kaputt, merkt man
// es ausgerechnet dann nicht, wenn man es am noetigsten braucht.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
test('zu jedem Knopf gibt es ein Ereignis', () => {
// Ein Knopf ohne Ereignis tut beim Druecken nichts und sieht dabei aus wie
// ein kaputtes Overlay.
for (const k of TEST_KNOEPFE) {
if (k.art === 'chat-loeschen') continue; // braucht eine Nachricht, siehe unten
assert.ok(testEvent(k.art), `${k.art} liefert kein Ereignis`);
}
});
test('jeder Knopf kommt nur einmal vor', () => {
const arten = TEST_KNOEPFE.map((k) => k.art);
assert.equal(new Set(arten).size, arten.length);
});
test('die Einblendungen tragen die Felder, die die Seite anzeigt', () => {
const raid = testEvent('raid') as { kind: string; type: string; amount: number };
assert.equal(raid.kind, 'alert');
assert.equal(raid.type, 'raid');
assert.ok(raid.amount > 0, 'ohne Zuschauerzahl steht "mit undefined Zuschauern" im Bild');
const gift = testEvent('subgift') as { recipient?: string };
assert.ok(gift.recipient, 'ohne Beschenkten fehlt die zweite Zeile');
const resub = testEvent('resub') as { months?: number };
assert.ok(resub.months && resub.months > 0);
});
test('"letzte löschen" braucht eine Nachricht und trifft genau die', () => {
assert.equal(testEvent('chat-loeschen'), null, 'ohne Chat gibt es nichts zu loeschen');
assert.deepEqual(testEvent('chat-loeschen', 'abc'), { kind: 'clear', messageId: 'abc' });
});
test('"Chat leeren" loescht alles, nicht nur eine Nachricht', () => {
assert.deepEqual(testEvent('chat-leeren'), { kind: 'clear' });
});
test('Chatnachrichten haben eine eigene ID', () => {
// Zwei Nachrichten mit derselben ID wuerden sich beim Loeschen gegenseitig
// mitnehmen.
const a = testEvent('chat') as { id: string };
const b = testEvent('chat') as { id: string };
assert.notEqual(a.id, b.id);
});
test('das Emote im Test ist im Text auch enthalten', () => {
// Sonst wird nichts ersetzt und der Knopf prueft die Emote-Anzeige nicht.
const ev = testEvent('chat-emote') as { text: string; emotes: Array<{ name: string }> };
assert.equal(ev.emotes.length, 1);
assert.ok(ev.text.includes(ev.emotes[0]!.name));
});
test('gesperrt ist nur, was einen falschen Stand setzt - nicht der Weg zurueck', () => {
// Eine Einblendung blitzt auf und ist weg. Ein erfundener Stream-Zustand
// bleibt in der Statusleiste stehen, bis die naechste Abfrage ihn
// geraderueckt - und das dauert bis zu einer Minute.
//
// "Zuruecksetzen" darf dabei nie gesperrt sein: sonst kommt man aus einem
// erfundenen Zustand ohne Neustart nicht mehr heraus, und auf einem Hub ohne
// Broadcaster-Token raeumt ueberhaupt niemand hinterher auf.
const gesperrt = TEST_KNOEPFE.filter((k) => k.nurOffline).map((k) => k.art);
assert.deepEqual(gesperrt, ['live-an']);
const zurueck = TEST_KNOEPFE.find((k) => k.art === 'live-aus');
assert.equal(zurueck?.nurOffline, undefined, 'der Weg zurueck bleibt immer offen');
});
test('der Stand-Knopf setzt einen anzeigbaren Stream', () => {
const an = testEvent('live-an') as
{ kind: string; live: boolean; title: string; startedAt: string | null };
assert.equal(an.kind, 'stream');
assert.equal(an.live, true);
assert.ok(an.title.length > 0);
assert.ok(an.startedAt && Date.parse(an.startedAt) < Date.now(), 'Laufzeit muss positiv sein');
const aus = testEvent('live-aus') as { live: boolean; startedAt: string | null };
assert.equal(aus.live, false);
assert.equal(aus.startedAt, null);
});
test('unbekannte Knoepfe liefern nichts, statt etwas zu erfinden', () => {
assert.equal(testEvent('gibtsnicht' as TestArt), null);
});
// ---------------------------------------------------------------- Kanalpunkte
test('eine Belohnung wird nie als Abo eingeblendet', () => {
// Genau das stand hier einmal: die Konfetti-Belohnung hat die Abo-
// Einblendung wiederverwendet und "Neues Abo" ins Bild geschrieben. Eine
// Falschmeldung im Stream, die in der Aufzeichnung bleibt.
const e = testEvent('reward') as { kind: string; type: string; reward?: string };
assert.equal(e.kind, 'alert');
assert.equal(e.type, 'reward');
assert.notEqual(e.type, 'sub');
assert.ok(e.reward, 'ohne Titel weiss niemand, welche Belohnung gemeint ist');
});
test('Konfetti ist ein eigenes Ereignis, keine Nebenwirkung einer Einblendung', () => {
// So muss die Effektseite nicht wissen, welche Belohnung was ausloest.
const e = testEvent('reward-konfetti') as { kind: string; staerke: number };
assert.equal(e.kind, 'konfetti');
assert.ok(e.staerke > 0);
});
test('der Vorlese-Knopf schickt Text, den die Seite auch sprechen kann', () => {
const e = testEvent('tts') as { kind: string; text: string; name: string };
assert.equal(e.kind, 'tts');
assert.ok(e.text.length > 0);
assert.ok(e.name.length > 0);
});
+203
View File
@@ -0,0 +1,203 @@
// Erfundene Ereignisse zum Ausprobieren, für die Knöpfe auf der
// Übersichtsseite und für den Vorführmodus.
//
// Warum das gebraucht wird: ein Raid kommt vielleicht einmal die Woche vor.
// Darauf zu warten, um zu sehen, ob die Einblendung passt, ist keine
// Arbeitsweise - und mitten im Stream herauszufinden, dass der Text abgeschnitten
// wird, ist die schlechteste aller Möglichkeiten.
//
// Die Knopfliste und die Ereignisse stehen hier zusammen, damit die Seite die
// Knöpfe daraus bauen kann. Ein Knopf, den es gibt, hat damit immer auch ein
// Ereignis dahinter.
import { ulid } from '#shared/events.ts';
import type { ChatEvent, OverlayEvent } from './bus.ts';
export const NAMEN = [
'kevin_ohne_c', 'Melli', 'HansWurst42', 'dieNina', 'Tobi', 'Schnitzelfan',
'pixelpudel', 'Rewe_Kassiererin', 'Olaf', 'zwiebeljule',
];
export const SPRUECHE = [
'moin', 'was geht', 'der Boss hat 3 Phasen oder?', 'hahaha',
'rechts! RECHTS!', 'ich guck seit ner Stunde zu, richtig gut',
'kann jemand den Titel lesen', 'das war knapp', 'gg',
'mein Internet ist heute wieder Müll', 'hast du den Trank noch?',
'erster', 'wie lange streamst du heute noch',
];
/**
* Globale Twitch-Emotes mit ihren echten IDs - nachgeschlagen, nicht geraten,
* damit die Bilder wirklich laden. KEKW fehlt hier absichtlich: das ist ein
* BTTV-Emote und hat bei Twitch keine ID.
*/
export const EMOTES = [
{ id: '425618', name: 'LUL', count: 1 },
{ id: '25', name: 'Kappa', count: 1 },
{ id: '354', name: '4Head', count: 1 },
];
export const FARBEN = ['#ff4fa3', '#4fd1c5', '#a970ff', '#ffc53d', '#4ade80', null];
export function zufall<T>(list: readonly T[]): T {
return list[Math.floor(Math.random() * list.length)]!;
}
export type TestArt =
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
| 'reward' | 'reward-konfetti' | 'tts'
| 'chat' | 'chat-emote' | 'chat-gif' | 'chat-erstmals' | 'chat-bits' | 'chat-lang'
| 'chat-loeschen' | 'chat-leeren'
| 'hype' | 'live-an' | 'live-aus';
export type TestKnopf = {
art: TestArt;
label: string;
gruppe: 'Einblendungen' | 'Kanalpunkte' | 'Chat' | 'Effekte' | 'Stand';
/**
* Verändert den Zustand, den das Overlay dauerhaft anzeigt, statt nur einmal
* aufzublitzen. Läuft gerade ein Stream, steht danach etwas Falsches im Bild,
* bis die nächste Abfrage es geraderückt - solche Knöpfe sind gesperrt.
*/
nurOffline?: boolean;
};
export const TEST_KNOEPFE: TestKnopf[] = [
{ art: 'sub', label: 'Abo', gruppe: 'Einblendungen' },
{ art: 'resub', label: 'Abo verlängert', gruppe: 'Einblendungen' },
{ art: 'subgift', label: 'Abo verschenkt', gruppe: 'Einblendungen' },
{ art: 'massgift', label: '5 Abos verschenkt', gruppe: 'Einblendungen' },
{ art: 'raid', label: 'Raid', gruppe: 'Einblendungen' },
{ art: 'cheer', label: 'Bits', gruppe: 'Einblendungen' },
{ art: 'first', label: 'Erstchatter', gruppe: 'Einblendungen' },
{ art: 'clip', label: 'Clip gespeichert', gruppe: 'Einblendungen' },
{ art: 'follow', label: 'Follow', gruppe: 'Einblendungen' },
{ art: 'reward', label: 'Belohnung eingelöst', gruppe: 'Kanalpunkte' },
{ art: 'reward-konfetti', label: 'Belohnung mit Konfetti', gruppe: 'Kanalpunkte' },
{ art: 'tts', label: 'Vorlesen', gruppe: 'Kanalpunkte' },
{ art: 'chat', label: 'Nachricht', gruppe: 'Chat' },
{ art: 'chat-emote', label: 'mit Emote', gruppe: 'Chat' },
{ art: 'chat-gif', label: 'mit GIF', gruppe: 'Chat' },
{ art: 'chat-erstmals', label: 'von einem Erstchatter', gruppe: 'Chat' },
{ art: 'chat-bits', label: 'mit Bits', gruppe: 'Chat' },
{ art: 'chat-lang', label: 'sehr lang', gruppe: 'Chat' },
{ art: 'chat-loeschen', label: 'letzte löschen', gruppe: 'Chat' },
{ art: 'chat-leeren', label: 'Chat leeren', gruppe: 'Chat' },
{ art: 'hype', label: 'Emote-Regen', gruppe: 'Effekte' },
{ art: 'live-an', label: 'Stream läuft', gruppe: 'Stand', nurOffline: true },
// Absichtlich NICHT gesperrt: das ist der Weg aus einem erfundenen Zustand
// wieder heraus. Wäre der Knopf gesperrt, sobald "Stream läuft" gedrückt
// wurde, käme man ohne Neustart nicht mehr zurück - und auf einem Hub ohne
// Broadcaster-Token räumt auch keine Abfrage hinterher auf.
{ art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' },
];
function chat(over: Partial<ChatEvent> = {}): ChatEvent {
const name = zufall(NAMEN);
return {
kind: 'chat',
id: ulid(),
userId: `test-${NAMEN.indexOf(name)}`,
login: name.toLowerCase(),
name,
text: zufall(SPRUECHE),
color: zufall(FARBEN),
emotes: [],
gifs: [],
first: false,
mod: Math.random() < 0.15,
sub: Math.random() < 0.3,
broadcaster: false,
bits: 0,
...over,
};
}
/**
* Baut das Ereignis zu einem Knopf.
*
* `letzteChatId` ist die neueste Nachricht im Rückblick des Busses - nur so
* kann "letzte löschen" etwas treffen, das auch wirklich im Bild steht.
*/
export function testEvent(art: TestArt, letzteChatId?: string): OverlayEvent | null {
const name = zufall(NAMEN);
const alarm = (rest: Record<string, unknown>): OverlayEvent =>
({ kind: 'alert', id: ulid(), name, ...rest } as OverlayEvent);
switch (art) {
case 'sub': return alarm({ type: 'sub', tier: '1000' });
case 'resub': return alarm({
type: 'resub', months: 1 + Math.floor(Math.random() * 30),
message: 'endlich wieder dabei, zwei Monate Pause waren genug',
});
case 'subgift': return alarm({ type: 'subgift', recipient: zufall(NAMEN) });
case 'massgift': return alarm({ type: 'massgift', amount: 5 });
case 'raid': return alarm({ type: 'raid', amount: 5 + Math.floor(Math.random() * 60) });
case 'cheer': return alarm({
type: 'cheer', amount: 100 * (1 + Math.floor(Math.random() * 10)),
message: 'das musste sein',
});
case 'first': return alarm({ type: 'first' });
case 'clip': return alarm({ type: 'clip' });
case 'follow': return alarm({ type: 'follow' });
case 'reward': return alarm({
type: 'reward', reward: 'Nachricht anheften', amount: 500,
message: 'guck mal links oben',
});
case 'reward-konfetti': return { kind: 'konfetti', staerke: 1 };
case 'tts': return {
kind: 'tts', id: ulid(), name,
text: 'Das hier wird vorgelesen, sobald du es freigibst.',
};
case 'chat': return chat();
case 'chat-emote': {
const e = zufall(EMOTES);
return chat({ text: `${zufall(SPRUECHE)} ${e.name}`, emotes: [e] });
}
case 'chat-gif': {
// Genau so, wie Twitch es schickt: der Text ist nur ein Platzhalter,
// das Bild steckt im Tag. Echte Adresse, damit der Knopf auch wirklich
// etwas anzeigt.
const text = '[Idk GIF by Respective]';
return chat({
text,
gifs: [{
id: 'Pnd7qLFuGCOntZcNKB',
url: 'https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif',
from: 0, to: [...text].length - 1,
}],
});
}
case 'chat-erstmals': return chat({ first: true, text: 'moin, bin neu hier' });
case 'chat-bits': return chat({ bits: 500, text: 'nimm die 500 Bits' });
case 'chat-lang': return chat({
// Der Fall, den man sonst erst im Stream sieht: eine Nachricht, die
// den Kasten sprengt.
text: 'also ich hab das gestern auch probiert und da ist mir aufgefallen ' +
'dass der Boss in Phase zwei immer nach links ausweicht, aber nur wenn ' +
'man vorher nicht geblockt hat, und das steht in keinem Guide drin',
});
case 'chat-loeschen':
return letzteChatId ? { kind: 'clear', messageId: letzteChatId } : null;
case 'chat-leeren': return { kind: 'clear' };
case 'hype': return { kind: 'hype', score: 4 + Math.random() * 4, rate: 45 };
case 'live-an': return {
kind: 'stream', live: true,
title: 'Probelauf des Overlays', category: 'Just Chatting',
viewers: 12 + Math.floor(Math.random() * 30),
startedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
};
case 'live-aus': return {
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
};
default: return null;
}
}