Hub: der Dienst laeuft zentral statt auf dem Stream-PC

Zwei Dinge, die Reichweite bringen sollen, hingen bisher daran, dass jemand
vor seinem Rechner sitzt: der Discord-Rollen-Knopf und die Go-Live-
Ankuendigung. Beide brauchen nur Internet.

Der Schnitt verlaeuft entlang einer Frage: braucht die Aufgabe eine Datei
oder ein Programm, das nur auf dem Stream-PC existiert?

  Hub (LXC)                    Ingest (Abdus PC)
  ---------                    -----------------
  Chat, Zuschauerzahlen        OBS-Wiedergabepuffer
  Discord: Knopf und Go-Live   !clip, Rendern, Untertitel
  Overlay-Seiten fuer OBS

Beide schreiben in ihre eigene Datenbank. Eine gemeinsame waere eine
Netzwerkfreigabe fuer SQLite - der zuverlaessigste bekannte Weg zu einer
beschaedigten Datei.

Das kostet ADR-0005 seine Hauptbegruendung: "bindet nur auf 127.0.0.1" gilt
fuer den Hub nicht mehr. An die Stelle treten ein Passwort mit signiertem
Sitzungs-Cookie fuer alles, was ein Mensch bedient, und ein zufaelliger
Schluessel in der Adresse fuer die Browser-Sources. Der Schluessel ist kein
schoenes Verfahren - er steht in jedem OBS-Profil -, aber eine Browser-Source
kann kein Formular ausfuellen und keinen Login durchhalten.

`publicUrl` haengt der OAuth-Rueckruf daran. Twitch vergleicht die
Redirect-URI zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext
aus wie ein falsches Client-Secret.

Die Routen der Einrichtungsseite sind aus ingest/server.ts herausgeloest,
weil sie jetzt an zwei Servern haengen.

Begruendung und Konsequenzen in ADR-0008.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-18 00:12:58 +02:00
1 parent e18336c57f
commit 3b606e36fb
8 files changed
+985 -10

No files matched your search

+71 -1
View File
@@ -45,6 +45,37 @@ export type Config = {
ffmpeg: { encoder: string; preset: string; crf: number; niceness: number };
whisper: { model: string; language: string; threads: number };
};
broadcaster: {
enabled: boolean;
/** `live` postet in die Ankuendigungen, `draft` in den internen Kanal. */
mode: 'live' | 'draft';
delaySecondsAfterOnline: number;
cooldownHours: number;
/** Rollenname aus dem Discord-Bauplan. Leer heisst: Post ohne Ping. */
pingRole: string;
obsScreenshot: boolean;
};
/** Kanalpunkt-Belohnungen und was sie ausloesen. Siehe apps/hub/src/rewards.ts. */
rewards: {
enabled: boolean;
belohnungen: Array<{
titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
freigabe?: boolean; maxZeichen?: number;
}>;
tts: {
freigabe: boolean; maxZeichen: number;
blockliste: string[]; proPersonProStunde: number;
};
};
overlay: { port: number; enabled: boolean };
/**
* Oeffentliche Adresse des Dienstes, ohne Schraegstrich am Ende. Nur noetig,
* wenn er nicht auf demselben Rechner wie der Browser laeuft - dann haengt
* daran der OAuth-Rueckruf von Twitch, der exakt zur eingetragenen
* Redirect-URI passen muss.
*/
publicUrl: string;
ingestServer: { port: number };
dashboard: { port: number };
raw: YamlMap;
@@ -81,6 +112,40 @@ const DEFAULTS = {
ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 },
whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 },
},
broadcaster: {
enabled: true,
// Vorgabe ist `live`: der Sinn der Sache ist der Ping. Wer erst zusehen
// will, stellt `draft` ein - dann landet dieselbe Nachricht ohne Ping im
// internen Kanal. Zum Ausprobieren gibt es `abdulive golive:test`.
mode: 'live' as const,
// Twitch setzt Titel und Kategorie erst kurz nach dem Start. Wer sofort
// postet, kuendigt regelmaessig den Titel des letzten Streams an.
delaySecondsAfterOnline: 60,
cooldownHours: 6,
pingRole: 'Live-Ping',
obsScreenshot: true,
},
rewards: {
// Aus, bis jemand bewusst Belohnungen eintraegt. Eine Aktion, die
// niemand erwartet, ist im Stream schlimmer als gar keine.
enabled: false,
belohnungen: [] as Array<{
titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
freigabe?: boolean; maxZeichen?: number;
}>,
tts: {
// Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim
// Zugucken von allein an und sind damit kaum eine Huerde; ohne diesen
// Schalter hat man eine Vorlesefunktion fuer Fremde im eigenen Stream.
freigabe: true,
maxZeichen: 200,
blockliste: [] as string[],
proPersonProStunde: 3,
},
},
overlay: { port: 4791, enabled: true },
publicUrl: '',
ingestServer: { port: 4790 },
dashboard: { port: 4792 },
};
@@ -105,7 +170,12 @@ export function loadConfig(): Config {
if (fs.existsSync(file)) {
raw = parseYaml(fs.readFileSync(file, 'utf8'));
}
cached = { ...merge(DEFAULTS, raw), raw } as Config;
const merged = merge(DEFAULTS, raw) as Omit<Config, 'raw'>;
// Die Umgebung gewinnt: im Container steht die oeffentliche Adresse in der
// Unit-Datei, nicht in einer YAML, die mitversioniert wird.
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
cached = { ...merged, raw } as Config;
return cached;
}
+64 -3
View File
@@ -1,7 +1,8 @@
// OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben
// Server wie die Einrichtungsseite (Port 4792) - siehe ADR-0005.
// Server wie die Einrichtungsseite - siehe ADR-0005.
import crypto from 'node:crypto';
import { loadConfig } from '#core/config.ts';
import { logger } from '#core/logger.ts';
import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts';
@@ -19,6 +20,10 @@ export const SCOPES: Record<Role, string[]> = {
'moderator:read:chatters',
'channel:manage:raids',
'channel:read:redemptions',
// Zum Zurueckgeben der Punkte, wenn eine Aktion scheitert oder abgelehnt
// wird. Wer Punkte nimmt und nichts liefert, verliert schneller Vertrauen
// als durch jeden Fehler im Bild.
'channel:manage:redemptions',
'bits:read',
],
bot: ['chat:read', 'chat:edit'],
@@ -27,9 +32,19 @@ export const SCOPES: Record<Role, string[]> = {
/** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */
const pending = new Map<string, { role: Role; created: number }>();
/**
* Muss exakt so in der Twitch-App hinterlegt sein - Twitch vergleicht
* zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext aus wie
* ein falsches Client-Secret.
*
* Laeuft der Dienst zentral, steht in `publicUrl` die oeffentliche Adresse.
* Dann ist der Rueckruf `https://.../callback`, und Twitch verlangt dort
* ohnehin HTTPS: `http://localhost` ist die einzige Ausnahme, die Twitch
* ohne Zertifikat akzeptiert.
*/
export function redirectUri(port: number): string {
// Muss exakt so in der Twitch-App hinterlegt sein.
return `http://localhost:${port}/callback`;
const base = loadConfig().publicUrl;
return base ? `${base}/callback` : `http://localhost:${port}/callback`;
}
export function authorizeUrl(role: Role, port: number): string {
@@ -86,6 +101,52 @@ export async function handleCallback(code: string, state: string, port: number):
return role;
}
/**
* App-Token (client_credentials). Gehoert keinem Nutzer und braucht keine
* Freigabe - es beweist nur, dass die Anfrage von dieser App kommt.
*
* Damit laufen die oeffentlichen Endpunkte: `GET /users` und `GET /streams`.
* Genau die beiden traegt die Stream-Erkennung, und deshalb muss sie nicht
* darauf warten, dass der Broadcaster sein Konto verbindet. Alles, was ueber
* einen Kanal etwas *tut* oder Privates liest - Clips anlegen, Chatter
* auflisten, Abos sehen - verlangt weiterhin ein Nutzer-Token.
*
* Gemerkt wird nur im Arbeitsspeicher: das Token gilt rund zwei Monate, und
* nach einem Neustart ist es eine einzige Anfrage.
*/
const appTokens = new Map<Role, { token: string; bis: number }>();
export async function appToken(role: Role): Promise<string> {
const gemerkt = appTokens.get(role);
// Fuenf Minuten Puffer, damit kein Aufruf mitten im Ablauf landet
if (gemerkt && gemerkt.bis - Date.now() > 300_000) return gemerkt.token;
const app = readSecrets().apps[role];
if (!app?.clientId || !app.clientSecret) {
throw new Error(`Fuer "${role}" fehlen Client-ID und Secret - Einrichtung unter /setup.`);
}
const res = await fetch(TOKEN, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: app.clientId,
client_secret: app.clientSecret,
grant_type: 'client_credentials',
}),
});
if (!res.ok) {
throw new Error(`App-Token fuer "${role}" nicht erhalten (HTTP ${res.status}). ` +
'Stimmen Client-ID und Secret?');
}
const body = await res.json() as { access_token: string; expires_in: number };
appTokens.set(role, { token: body.access_token, bis: Date.now() + body.expires_in * 1000 });
log.info(`App-Token fuer ${role} geholt (gilt ${Math.round(body.expires_in / 86400)} Tage)`);
return body.access_token;
}
/** Vergisst gemerkte App-Tokens. Fuer Tests und nach einem App-Wechsel. */
export function forgetAppTokens(): void { appTokens.clear(); }
export async function validate(accessToken: string) {
const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } });
if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`);