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

+185 -1
View File
@@ -646,6 +646,177 @@ Was jetzt noch von Hand gehoert:
verbinden, dann bekommen Subs automatisch eine Rolle.`);
}
// ---------------------------------------------------------------- overlay
/**
* Overlay-Server allein starten. Zum Ansehen und Einrichten, ohne dass der
* ganze Hub laeuft. Mit --demo laeuft erfundener Betrieb: anders koennte man
* eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet.
*/
async function overlayOnly(args: string[]): Promise<void> {
const cfg = loadConfig();
const { startOverlayServer } = await import('../overlay-server/src/server.ts');
const { createBus } = await import('../overlay-server/src/bus.ts');
const bus = createBus();
const abort = new AbortController();
process.on('SIGINT', () => { abort.abort(); process.exit(0); });
startOverlayServer({
port: cfg.overlay.port,
channel: cfg.channel.login,
publicUrl: cfg.publicUrl,
bus,
});
if (args.includes('--demo')) {
const { startDemo } = await import('../overlay-server/src/demo.ts');
startDemo(bus, abort.signal);
}
const wo = cfg.publicUrl || `http://127.0.0.1:${cfg.overlay.port}`;
console.log(`
Uebersicht mit allen Adressen fuer OBS: ${wo}/`);
console.log(`${C.dim}Beenden mit Strg+C.${C.off}`);
await new Promise(() => { /* laeuft bis Strg+C */ });
}
/** Passwort fuer die Weboberflaeche setzen. Nur hier, nie ueber das Netz. */
async function webPasswort(args: string[]): Promise<void> {
const { setzePasswort } = await import('../overlay-server/src/auth.ts');
const pw = args[0] || process.env.ABDULIVE_WEB_PASSWORT ||
await promptHidden('Neues Passwort (Eingabe wird nicht angezeigt): ');
setzePasswort(pw.trim());
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
}
// ---------------------------------------------------------------- golive
/**
* Probelauf der Go-Live-Ankuendigung.
*
* Ohne Argument geht der Post in den internen Kanal und pingt niemanden -
* damit laesst sich das Aussehen pruefen, ohne den Server aufzuwecken. Mit
* `--echt` geht er dorthin, wo er im Betrieb hinginge, mit Ping. Das ist der
* einzige Weg, vor dem ersten Stream zu sehen, ob der Ping wirklich ankommt;
* eine Erwaehnung ohne `allowed_mentions` sieht naemlich genauso aus wie eine
* mit, benachrichtigt aber niemanden.
*/
async function goLiveTest(args: string[]): Promise<void> {
const echt = args.includes('--echt');
const cfg = loadConfig();
const { createAnnouncer } = await import('../broadcaster/src/golive.ts');
const { repos } = await import('#db/repos.ts');
const { helix } = await import('#twitch/helix.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const discord = readSecrets().discord;
const ziel = echt ? discord?.goLiveWebhook : discord?.technikWebhook;
if (!ziel) {
throw new Error(echt
? 'Kein Go-Live-Webhook hinterlegt. Einmal `abdulive discord:setup` laufen lassen.'
: 'Kein Webhook fuer den internen Kanal. Einmal `abdulive discord:setup` laufen lassen ' +
'- der Technik-Webhook ist seit Fassung 0.6 im Bauplan.');
}
// Wenn Abdu gerade live ist, wird der echte Stream angekuendigt. Sonst ein
// erfundener - das prueft denselben Weg, nur mit anderem Text.
let stream = {
id: `probe-${Date.now()}`,
title: 'Probelauf der Go-Live-Ankuendigung',
category: 'Just Chatting',
login: cfg.channel.login,
startedAt: new Date().toISOString(),
} as import('../broadcaster/src/golive.ts').StreamInfo;
try {
const user = await helix.userByLogin(cfg.channel.login);
const live = user ? await helix.streamByUserId(user.id) : null;
if (live) {
stream = {
id: `probe-${live.id}-${Date.now()}`,
title: live.title,
category: live.game_name,
login: cfg.channel.login,
startedAt: live.started_at,
thumbnailUrl: live.thumbnail_url,
};
console.log(`${C.dim}Stream laeuft gerade - der Probelauf nimmt die echten Daten.${C.off}`);
} else {
console.log(`${C.dim}Kein laufender Stream - der Probelauf nimmt erfundene Daten.${C.off}`);
}
} catch (err) {
// Haeufigster Fall ist nicht "Netz weg", sondern "noch keine Twitch-App
// hinterlegt". Der Satz soll das sagen, sonst sucht man am falschen Ende.
console.log(`${C.warn}Twitch nicht abgefragt:${C.off} ${(err as Error).message}`);
console.log(`${C.dim}Der Probelauf nimmt erfundene Daten - der Weg zum Webhook ` +
`wird trotzdem vollstaendig geprueft.${C.off}`);
}
// OBS nur dazunehmen, wenn es laeuft: sonst wartet der Screenshot ins Leere.
let obs: Awaited<ReturnType<typeof withObs>> | null = null;
if (cfg.broadcaster.obsScreenshot && await tcpProbe(OBS_HOST, OBS_PORT, 1500)) {
obs = await withObs();
}
const announcer = createAnnouncer({
cfg: {
enabled: true,
mode: echt ? 'live' : 'draft',
delaySeconds: 0,
cooldownHours: cfg.broadcaster.cooldownHours,
pingRole: cfg.broadcaster.pingRole,
obsScreenshot: Boolean(obs),
},
repo: repos(openDb()),
...(obs ? { obs: obs.client } : {}),
// Ein Probelauf geht durch die Abklingzeit hindurch, sonst koennte man ihn
// nach einem echten Stream sechs Stunden lang nicht wiederholen.
refresh: async (s) => s,
});
// Als eigene Art protokollieren: sonst sperrt der Probelauf ueber die
// Abklingzeit die naechste echte Ankuendigung.
const result = await announcer.announceNow(stream, { force: true, art: 'golive-test' });
obs?.stop();
if (result.status === 'gesendet') {
const wohin = result.mode === 'draft' ? 'interner Kanal' : 'Ankuendigungen';
const ping = result.pinged ? `mit Ping an "${cfg.broadcaster.pingRole}"` : 'ohne Ping';
console.log(`
${C.ok}Post ist raus${C.off} (${wohin}, ${ping}).`);
if (result.url) console.log(` ${result.url}`);
if (echt && !result.pinged) {
console.log(`
${C.warn}Kein Ping gesetzt.${C.off} Pruefen: gibt es die Rolle ` +
`"${cfg.broadcaster.pingRole}" auf dem Server, und ist ein Bot-Token hinterlegt?`);
}
if (!echt) console.log(`
${C.dim}Mit --echt geht derselbe Post in die Ankuendigungen, ` +
`mit Ping.${C.off}`);
} else {
console.log(`
${C.bad}${result.status}${C.off}: ${result.reason}`);
process.exitCode = 1;
}
}
/** OBS kurz verbinden, damit ein Screenshot moeglich ist. */
async function withObs() {
const { createObs } = await import('#obs/client.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const abort = new AbortController();
const client = createObs(`ws://${OBS_HOST}:${OBS_PORT}`,
() => readSecrets().obs?.password, {});
void client.run(abort.signal);
// Kurz auf die Identifikation warten; ohne sie gibt es keinen Screenshot,
// und das ist kein Grund zum Abbruch.
for (let i = 0; i < 30 && !client.isConnected(); i++) {
await new Promise((r) => setTimeout(r, 100));
}
return { client, stop: () => abort.abort() };
}
async function discordBot(): Promise<void> {
const { createBot } = await import('#discord/bot.ts');
const bot = createBot();
@@ -673,7 +844,10 @@ function help(): void {
console.log(`
abdulive - Werkzeugkasten fuer Abdus Stream
ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite)
hub Zentraler Dienst: Chat, Discord, Go-Live, Overlay
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
web:passwort [wort] Passwort fuer die Weboberflaeche setzen
check-env Umgebung pruefen
db:migrate Migrationen anwenden
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
@@ -684,6 +858,8 @@ abdulive - Werkzeugkasten fuer Abdus Stream
discord:setup [--server ID] [--vorschau]
Discord-Server nach docs/projects/discord-community.md einrichten
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
Kanal ohne Ping, mit --echt in die Ankuendigungen
help Diese Uebersicht
Einrichtung nach dem Start: http://localhost:4792/setup
@@ -715,6 +891,14 @@ try {
}
case 'render': await renderOne(args[0], args[1]); break;
case 'discord:setup': await discordSetup(args); break;
case 'golive:test': await goLiveTest(args); break;
case 'hub': {
const { runHub } = await import('../hub/src/index.ts');
await runHub();
break;
}
case 'overlay': await overlayOnly(args); break;
case 'web:passwort': await webPasswort(args); break;
case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break;
default: