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:
1 parent
e18336c57f
commit
3b606e36fb
8 files changed
+985
-10
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in new issue
Block a user