From 3b606e36fb2e8433f518408c4de7cd88b7b2d5a6 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Fri, 18 Sep 2026 00:12:58 +0200 Subject: [PATCH] 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) --- apps/cli/main.ts | 186 ++++++- apps/hub/src/index.ts | 481 +++++++++++++++++++ apps/ingest/src/server.ts | 36 +- docs/decisions/ADR-0008-geteilter-betrieb.md | 100 ++++ docs/decisions/README.md | 1 + packages/core/src/config.ts | 72 ++- packages/twitch/src/oauth.ts | 67 ++- scripts/deploy-hub.sh | 52 ++ 8 files changed, 985 insertions(+), 10 deletions(-) create mode 100644 apps/hub/src/index.ts create mode 100644 docs/decisions/ADR-0008-geteilter-betrieb.md create mode 100644 scripts/deploy-hub.sh diff --git a/apps/cli/main.ts b/apps/cli/main.ts index d43b25e..425a09c 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -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 { + 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 { + 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 { + 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> | 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 { 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: diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts new file mode 100644 index 0000000..1fdf044 --- /dev/null +++ b/apps/hub/src/index.ts @@ -0,0 +1,481 @@ +// Der zentrale Dienst. Laeuft in einem LXC, nicht auf Abdus Rechner. +// +// Was hier drin ist, braucht nur Internet: Twitch-Chat, Twitch-API, Discord +// und die Overlay-Seiten. Das laeuft damit auch dann, wenn der Stream-PC aus +// ist - der Rollen-Knopf auf Discord antwortet nachts um drei, und die +// Go-Live-Ankuendigung haengt nicht daran, dass jemand den Daemon gestartet +// hat. +// +// Was NICHT hier drin ist und auch nicht hierher kann: OBS, der +// Wiedergabepuffer, ffmpeg und whisper. Die haengen an den Dateien auf dem +// Stream-PC und bleiben in `abdulive ingest`. +// +// Hub (LXC) Stream-PC (abdulive ingest) +// --------- -------------------------- +// Chat lesen und senden OBS-Wiedergabepuffer +// Zuschauerzahlen, Sitzungen !clip, Rendern, Untertitel +// Discord: Knopf und Go-Live +// Overlay-Seiten fuer OBS +// +// Beide schreiben in ihre eigene Datenbank. Das ist bewusst: eine gemeinsame +// waere eine Netzwerkfreigabe fuer SQLite, und das ist der zuverlaessigste +// Weg zu einer beschaedigten Datei. + +import { loadConfig } from '#core/config.ts'; +import { logger } from '#core/logger.ts'; +import { sleep } from '#core/retry.ts'; +import { openDb, closeDb } from '#db/index.ts'; +import { repos } from '#db/repos.ts'; +import { createBot } from '#discord/bot.ts'; +import { helix } from '#twitch/helix.ts'; +import { readSecrets } from '#twitch/secrets.ts'; +import { createEventSub } from '#twitch/eventsub.ts'; +import { createIrc, type UserNotice } from '#twitch/irc.ts'; +import { streamWechsel } from '#twitch/stream-state.ts'; +import { + ausEventSub, createDrossel, createWarteschlange, entscheiden, +} from './rewards.ts'; +import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts'; +import { createSpikeDetector } from '../../ingest/src/spike.ts'; +import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts'; +import { startOverlayServer } from '../../overlay-server/src/server.ts'; +import { ulid } from '#shared/events.ts'; + +const log = logger('hub'); + +export async function runHub(): Promise { + const cfg = loadConfig(); + const db = openDb(); + const repo = repos(db); + const spikes = createSpikeDetector(cfg.ingest.spike); + const bus = createBus(); + + const state = { + startedAt: new Date().toISOString(), + streamId: null as string | null, + broadcasterId: null as string | null, + lastSeenLive: 0, + viewers: 0, + title: '', + category: '', + startedLiveAt: null as string | null, + chatConnected: false, + chatAnonymous: true, + messages: 0, + spikeCandidates: 0, + eventsub: false, + lastError: null as string | null, + }; + + const abort = new AbortController(); + const stop = (signal: string): void => { + log.info(`${signal} empfangen, fahre herunter`); + abort.abort(); + setTimeout(() => { closeDb(); process.exit(0); }, 500); + }; + process.on('SIGINT', () => stop('SIGINT')); + process.on('SIGTERM', () => stop('SIGTERM')); + + // ---- Go-Live-Ankuendigung. Ohne OBS gibt es kein eigenes Vorschaubild; + // dann nimmt der Post Twitchs Vorschau, und die ist meist gut genug. + const announcer = createAnnouncer({ + cfg: { + enabled: cfg.broadcaster.enabled, + mode: cfg.broadcaster.mode, + delaySeconds: cfg.broadcaster.delaySecondsAfterOnline, + cooldownHours: cfg.broadcaster.cooldownHours, + pingRole: cfg.broadcaster.pingRole, + obsScreenshot: false, + }, + repo, + }); + + // ---- Kanalpunkte + const warteschlange = createWarteschlange(); + const drossel = createDrossel(); + + /** Punkte zurueckgeben. Ohne den Scope nicht moeglich - dann nur melden. */ + const erstatten = async (rewardId: string, redemptionId: string): Promise => { + if (!state.broadcasterId) return; + if (!helix.hasScope('channel:manage:redemptions')) { + log.warn('Punkte nicht erstattet: dem verbundenen Konto fehlt ' + + 'channel:manage:redemptions. Einmal neu verbinden unter /setup.'); + return; + } + try { + await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, 'CANCELED'); + } catch (err) { + log.warn(`Punkte nicht erstattet: ${(err as Error).message}`); + } + }; + + const vorlesen = (id: string, name: string, text: string): void => { + bus.publish({ kind: 'tts', id, name, text }); + log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`); + }; + + const behandleEinloesung = (roh: Record): void => { + const e = ausEventSub(roh); + if (!e) { log.warn('Einloesung ohne verwertbare Felder - ignoriert'); return; } + + repo.event('points.redeemed', { + reward: e.rewardTitle, kosten: e.kosten, login: e.userName, text: e.text, + }, state.streamId, e.userId, e.ts); + + const ent = entscheiden(e, cfg.rewards, drossel.zaehlen(e.userId)); + drossel.merken(e.userId); + + switch (ent.art) { + case 'unbekannt': + log.debug(`Belohnung "${e.rewardTitle}" hat keine hinterlegte Aktion`); + break; + + case 'abgelehnt': + log.info(`Einloesung von ${e.userName} abgelehnt: ${ent.grund}`); + void erstatten(e.rewardId, e.id); + break; + + case 'ausfuehren': + // Immer dieselbe Einblendung: sie sagt, wer was eingeloest hat. Die + // Aktion entscheidet nur, was zusaetzlich passiert. Frueher stand + // hier die Abo-Einblendung - eine Belohnung als "Neues Abo" ins Bild + // zu schreiben ist eine Falschmeldung, die in der Aufzeichnung bleibt. + bus.publish({ + kind: 'alert', type: 'reward', id: ulid(), name: e.userName, + reward: e.rewardTitle, + ...(e.kosten ? { amount: e.kosten } : {}), + ...(e.text ? { message: e.text } : {}), + }); + if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 }); + if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 }); + log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`); + break; + + case 'vorlesen': + vorlesen(ulid(), e.userName, ent.text); + break; + + case 'warteschlange': + warteschlange.einreihen({ + id: ulid(), name: e.userName, text: ent.text, + rewardId: e.rewardId, redemptionId: e.id, seit: new Date().toISOString(), + }); + log.info(`${e.userName} wartet auf Freigabe zum Vorlesen`); + break; + } + }; + + // ---- EventSub. Nur sinnvoll mit verbundenem Konto: der WebSocket-Weg + // verlangt fuer das Abonnement dasselbe Nutzer-Token. + const eventsub = createEventSub({ + onSession: async (sessionId) => { + const id = state.broadcasterId || (await helix.userByLogin(cfg.channel.login))?.id; + if (!id) throw new Error('Broadcaster-ID unbekannt'); + state.broadcasterId = id; + await helix.subscribe({ + type: 'channel.channel_points_custom_reward_redemption.add', + version: '1', + condition: { broadcaster_user_id: id }, + }, sessionId); + log.info('Abonniert: Kanalpunkt-Einloesungen'); + }, + onNotification: (typ, event) => { + if (typ === 'channel.channel_points_custom_reward_redemption.add') { + behandleEinloesung(event); + } + }, + onRevocation: (typ) => log.error( + `Twitch hat "${typ}" entzogen - meist zurueckgezogene Freigabe. Neu verbinden unter /setup.`), + onConnected: () => { state.eventsub = true; }, + onClosed: () => { state.eventsub = false; }, + }); + + // ---- Discord (Rollen-Knoepfe) + const bot = createBot(); + if (bot) void bot.run(abort.signal); + + // ---- Chat + const irc = createIrc(cfg.channel.login, { + onConnected: (anonymous) => { + state.chatConnected = true; + state.chatAnonymous = anonymous; + }, + onMessage: (msg) => { + try { + repo.recordMessage(msg, state.streamId); + state.messages++; + repo.event('chat.message', { + text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits, + }, state.streamId, msg.userId, msg.ts); + + bus.publish({ + kind: 'chat', + id: msg.id, + userId: msg.userId, + login: msg.login, + name: msg.displayName, + text: msg.text, + color: msg.color, + emotes: msg.emotes, + gifs: msg.gifs, + first: msg.isFirst, + mod: msg.isMod, + sub: msg.isSub, + broadcaster: msg.isBroadcaster, + bits: msg.bits, + }); + + if (msg.isFirst) { + repo.event('chat.first_message', { login: msg.login }, state.streamId, msg.userId, msg.ts); + bus.publish({ kind: 'alert', type: 'first', id: ulid(), name: msg.displayName }); + log.info(`Erstchatter: ${msg.displayName}`); + } + if (msg.bits > 0) { + repo.event('cheer', { bits: msg.bits, login: msg.login }, state.streamId, msg.userId, msg.ts); + bus.publish({ + kind: 'alert', type: 'cheer', id: ulid(), + name: msg.displayName, amount: msg.bits, message: msg.text, + }); + } + + const spike = spikes.push(msg); + if (spike) { + state.spikeCandidates++; + repo.event('chat.spike', spike, state.streamId, null, msg.ts); + bus.publish({ kind: 'hype', score: spike.score, rate: spike.rate }); + log.info(`Spitze im Chat: Punktzahl ${spike.score} (${spike.rate} Nachrichten ` + + `von ${spike.chatters} Leuten, Basislinie ${spike.baseline})`); + } + } catch (err) { + state.lastError = (err as Error).message; + log.error(`Nachricht nicht verarbeitet: ${(err as Error).message}`); + } + }, + onUserNotice: (notice) => { + const alert = toAlert(notice); + if (!alert) return; + repo.event(eventTypeFor(notice), notice, state.streamId); + bus.publish(alert); + log.info(notice.systemMessage || `${notice.kind} von ${notice.displayName}`); + }, + onClear: (what) => bus.publish({ kind: 'clear', ...what }), + }); + void irc.run(abort.signal); + + // ---- Die eine oeffentliche Tuer + const server = startOverlayServer({ + port: cfg.overlay.port, + channel: cfg.channel.login, + publicUrl: cfg.publicUrl, + bus, + setup: { + channel: cfg.channel.login, + port: cfg.overlay.port, + health: () => ({ + ...state, + rolle: 'hub', + db: repo.stats(), + discordConnected: bot ? bot.isConnected() : false, + goLive: announcer.lastResult() ?? 'noch nichts', + overlayClients: bus.clients(), + ttsWartend: warteschlange.liste().length, + spikeArmed: spikes.isArmed(), + note: state.chatAnonymous + ? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.' + : undefined, + }), + }, + tts: { + wartend: () => warteschlange.liste() + .map((w) => ({ id: w.id, name: w.name, text: w.text, seit: w.seit })), + freigeben: (id) => { + const w = warteschlange.entnehmen(id); + if (!w) return false; + vorlesen(w.id, w.name, w.text); + return true; + }, + ablehnen: (id) => { + const w = warteschlange.entnehmen(id); + if (!w) return false; + log.info(`Vorlesen abgelehnt (${w.name})`); + void erstatten(w.rewardId, w.redemptionId); + return true; + }, + alleAblehnen: () => { + const alle = warteschlange.leeren(); + for (const w of alle) void erstatten(w.rewardId, w.redemptionId); + if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`); + return alle.length; + }, + still: () => { + bus.publish({ kind: 'still' }); + const alle = warteschlange.leeren(); + for (const w of alle) void erstatten(w.rewardId, w.redemptionId); + log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert'); + }, + }, + }); + void server; + + // ---- Zuschauerzahlen und Stream-Sitzungen + void pollStream(cfg, repo, state, bus, abort.signal, (stream) => announcer.announce(stream)); + + // ---- EventSub erst, wenn ein Konto verbunden ist. Ohne Nutzer-Token + // wuerde der Verbindungsversuch im Minutentakt scheitern und nichts als + // Protokollrauschen erzeugen. + if (cfg.rewards.enabled && readSecrets().tokens.broadcaster) { + void eventsub.run(abort.signal); + } else if (cfg.rewards.enabled) { + log.warn('Kanalpunkt-Aktionen sind an, aber kein Konto verbunden - ' + + 'EventSub bleibt aus. Verbinden unter /setup.'); + } + + log.info(`Hub laeuft fuer #${cfg.channel.login}`); + await new Promise((resolve) => abort.signal.addEventListener('abort', () => resolve())); +} + +// ---------------------------------------------------------------- Ereignisse + +/** USERNOTICE in eine Einblendung uebersetzen. Unbekanntes faellt weg. */ +export function toAlert(n: UserNotice): AlertEvent | null { + const gemeinsam = { + kind: 'alert' as const, + id: ulid(), + name: n.displayName, + ...(n.months !== undefined ? { months: n.months } : {}), + ...(n.tier ? { tier: n.tier } : {}), + ...(n.text ? { message: n.text } : {}), + }; + switch (n.kind) { + case 'sub': case 'resub': + return { ...gemeinsam, type: n.kind }; + case 'subgift': + return { ...gemeinsam, type: 'subgift', ...(n.recipient ? { recipient: n.recipient } : {}) }; + case 'massgift': + return { ...gemeinsam, type: 'massgift', ...(n.count ? { amount: n.count } : {}) }; + case 'raid': + return { ...gemeinsam, type: 'raid', ...(n.count ? { amount: n.count } : {}) }; + default: + // Ankuendigungen und alles Unbekannte bekommen keine Einblendung. Eine + // Einblendung fuer etwas, das man nicht kennt, sieht kaputt aus. + return null; + } +} + +function eventTypeFor(n: UserNotice): 'subscribe' | 'subgift' | 'raid.in' | 'chat.message' { + if (n.kind === 'subgift' || n.kind === 'massgift') return 'subgift'; + if (n.kind === 'raid') return 'raid.in'; + if (n.kind === 'sub' || n.kind === 'resub') return 'subscribe'; + return 'chat.message'; +} + +// ---------------------------------------------------------------- Abfrage + +type State = { + streamId: string | null; broadcasterId: string | null; lastSeenLive: number; + viewers: number; title: string; category: string; startedLiveAt: string | null; + lastError: string | null; +}; + +async function pollStream( + cfg: ReturnType, + repo: ReturnType, + state: State, + bus: Bus, + signal: AbortSignal, + onOnline: (stream: StreamInfo) => void +): Promise { + const gapMs = cfg.ingest.sessionGapMinutes * 60_000; + let warnedMissingAuth = false; + + const sendState = (live: boolean): void => { + bus.publish({ + kind: 'stream', live, + title: state.title, category: state.category, + viewers: state.viewers, startedAt: state.startedLiveAt, + }); + }; + + // Ein laufender Stream wird beim Start uebernommen, damit ein Neustart + // mitten im Stream keine zweite Sitzung anlegt - und keinen zweiten Ping. + const open = repo.latestOpenStream(); + if (open) { + state.streamId = open.id; + state.startedLiveAt = open.started_at; + state.lastSeenLive = Date.now(); + log.info(`Laufende Sitzung uebernommen: ${open.id} seit ${open.started_at}`); + } + + while (!signal.aborted) { + try { + // Client-ID und Secret genuegen: `GET /users` und `GET /streams` laufen + // mit einem App-Token. Auf das verbundene Konto wartet hier niemand. + if (!helix.canReadStream()) { + if (!warnedMissingAuth) { + log.warn('Keine Twitch-App hinterlegt - Zuschauerzahlen, Sitzungen und die ' + + 'Go-Live-Ankuendigung bleiben aus. Einrichtung unter /setup.'); + warnedMissingAuth = true; + } + } else { + warnedMissingAuth = false; + const userId = state.broadcasterId || cfg.channel.userId || + (await helix.userByLogin(cfg.channel.login))?.id || ''; + if (userId) state.broadcasterId = userId; + const live = userId ? await helix.streamByUserId(userId) : null; + const now = Date.now(); + + const wechsel = streamWechsel({ + live: live ? { id: live.id, startedAt: live.started_at } : null, + laufendeId: state.streamId, + zuletztLive: state.lastSeenLive, + jetzt: now, + luckeMs: gapMs, + }); + + if (wechsel.art === 'gestartet') { + state.streamId = wechsel.id; + repo.openStream(wechsel.id, wechsel.startedAt); + repo.event('stream.online', + { title: live!.title, category: live!.game_name }, wechsel.id); + log.info(`Stream online: "${live!.title}" (${live!.game_name}), ` + + `gestartet ${wechsel.startedAt}`); + onOnline({ + id: wechsel.id, + title: live!.title, + category: live!.game_name, + login: cfg.channel.login, + startedAt: wechsel.startedAt, + thumbnailUrl: live!.thumbnail_url, + }); + } else if (wechsel.art === 'fortsetzung') { + log.info(`Neue Twitch-Stream-ID ${wechsel.neueId}, behandle als Fortsetzung ` + + `von ${state.streamId} - keine zweite Ankuendigung`); + } else if (wechsel.art === 'beendet') { + repo.closeStream(wechsel.id, new Date().toISOString()); + repo.event('stream.offline', {}, wechsel.id); + log.info(`Stream offline: ${wechsel.id}`); + state.streamId = null; + state.viewers = 0; + state.startedLiveAt = null; + sendState(false); + } + + if (live) { + state.title = live.title; + state.category = live.game_name; + state.startedLiveAt = live.started_at; + state.lastSeenLive = now; + state.viewers = live.viewer_count; + if (state.streamId) { + repo.sample(state.streamId, new Date().toISOString(), live.viewer_count); + } + sendState(true); + } + } + } catch (err) { + state.lastError = (err as Error).message; + log.error(`Abfrage fehlgeschlagen: ${(err as Error).message}`); + } + try { await sleep(cfg.ingest.viewerPollSeconds * 1000, signal); } catch { return; } + } +} diff --git a/apps/ingest/src/server.ts b/apps/ingest/src/server.ts index 6c973d5..cdb7515 100644 --- a/apps/ingest/src/server.ts +++ b/apps/ingest/src/server.ts @@ -59,18 +59,45 @@ async function handle( req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps ): Promise { const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`); + // Auf dem Stream-PC ist die Einrichtung das Einzige, was dieser Server + // kann - also faengt die Wurzel dorthin. Im Hub steht auf / die + // Overlay-Uebersicht, deshalb liegt die Weiche hier und nicht in + // `setupRoutes`. + if (url.pathname === '/') { + res.writeHead(303, { location: '/setup' }); + res.end(); + return; + } + if (await setupRoutes(req, res, deps, url)) return; + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('Nicht gefunden'); +} + +/** + * Die Routen der Einrichtung, ohne 404 am Ende. + * + * Ausgelagert, weil sie seit dem zentralen Betrieb an zwei Servern haengen: + * hier auf dem Stream-PC und im Overlay-Server des Hubs. Der Rueckgabewert + * sagt, ob die Anfrage beantwortet wurde - wer sie einbindet, macht sonst + * selbst weiter. + */ +export async function setupRoutes( + req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps, url: URL +): Promise { const html = (body: string, code = 200) => { res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(body); + return true; }; const back = (kind: 'ok' | 'err', text: string) => { res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` }); res.end(); + return true; }; // ---- Einrichtungsseite - if (url.pathname === '/' || url.pathname === '/setup') { + if (url.pathname === '/setup') { const ok = url.searchParams.get('ok'); const err = url.searchParams.get('err'); return html(setupPage(secretStatus(), { @@ -119,7 +146,7 @@ async function handle( try { res.writeHead(303, { location: authorizeUrl(role, deps.port) }); res.end(); - return; + return true; } catch (err) { return back('err', (err as Error).message); } @@ -144,9 +171,8 @@ async function handle( if (url.pathname === '/health') { res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(deps.health(), null, 2)); - return; + return true; } - res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); - res.end('Nicht gefunden'); + return false; } diff --git a/docs/decisions/ADR-0008-geteilter-betrieb.md b/docs/decisions/ADR-0008-geteilter-betrieb.md new file mode 100644 index 0000000..b009faa --- /dev/null +++ b/docs/decisions/ADR-0008-geteilter-betrieb.md @@ -0,0 +1,100 @@ +# ADR-0008: Geteilter Betrieb - Hub im LXC, Ingest am Stream-PC + +Datum: 2026-09-17 +Status: akzeptiert +Berührt: [ADR-0005](ADR-0005-zugangsdaten.md), [ADR-0006](ADR-0006-ein-dach.md), +[ADR-0007](ADR-0007-native-windows.md) + +## Kontext + +Bis hierher lief alles in einem Prozess auf Abdus Rechner (ADR-0007), und +alles band an `127.0.0.1` (ADR-0005). Das war richtig, solange jede Aufgabe +etwas mit OBS zu tun hatte. + +Zwei neue Aufgaben haben damit aber nichts zu tun: + +- **Der Rollen-Knopf auf Discord.** Er hängt an einer Gateway-Verbindung im + Ingest-Daemon. Ist Abdus Rechner aus, klickt jemand auf den Knopf und + bekommt "Interaktion fehlgeschlagen". Ein Knopf, der nur läuft, während + der Besitzer am Rechner sitzt, ist kein Knopf. +- **Die Go-Live-Ankündigung.** Sie braucht Twitch und Discord, sonst nichts. + +Dazu kam das Overlay. Eine Browser-Source in OBS holt ihre Seite über HTTP; +ob die von `127.0.0.1` kommt oder aus dem Netz, ist ihr gleich. + +## Entscheidung + +Das Toolkit läuft ab jetzt an zwei Stellen, mit einem klaren Schnitt: + +| | Hub (LXC 141, `abdu.dimension47.de`) | Ingest (Abdus PC) | +|---|---|---| +| Befehl | `abdulive hub` | `abdulive ingest` | +| Twitch-Chat lesen und senden | ja | ja | +| Zuschauerzahlen, Stream-Sitzungen | ja | ja | +| Discord: Rollen-Knopf, Go-Live | **ja** | nein | +| Overlay-Seiten ausliefern | **ja** | nein | +| OBS, Wiedergabepuffer, `!clip` | nein | **ja** | +| Rendern, Untertitel | nein | **ja** | + +Der Schnitt verläuft entlang einer einzigen Frage: **braucht die Aufgabe eine +Datei oder ein Programm, das nur auf dem Stream-PC existiert?** Alles, was mit +Nein zu beantworten ist, gehört in den Hub. + +Der Hub ist damit der einzige Teil, der von außen erreichbar ist. Er bindet +auf `0.0.0.0`, hinter dem vorhandenen Nginx Proxy Manager, und bringt Overlay, +Einrichtungsseite und OAuth-Rückruf unter einer Adresse zusammen. + +## Begründung + +**Warum nicht alles zentral.** Der Wiedergabepuffer von OBS liegt als Datei +auf Abdus Platte, ffmpeg und whisper arbeiten darauf, und ein Clip ist +mehrere hundert Megabyte. Das über das Netz zu schieben wäre teurer als es +bringt, und obs-websocket gehört ohnehin nicht ins Internet. + +**Warum nicht alles lokal bleiben.** Genau die zwei Dinge, die Reichweite +bringen sollen - der Ping und der Knopf - sind die, die durchlaufen müssen. +Sie an den Stream-PC zu hängen heißt, sie an die Verfügbarkeit einer Person zu +hängen. + +**Warum zwei Datenbanken und nicht eine.** Beide Seiten schreiben in ihre +eigene SQLite-Datei. Eine gemeinsame wäre eine Netzwerkfreigabe für SQLite, +und das ist der zuverlässigste bekannte Weg zu einer beschädigten Datei. Was +zusammengeführt werden muss, wird später über Ereignisse zusammengeführt, nicht +über geteilten Dateizugriff. + +**Warum der Bruch mit ADR-0005 vertretbar ist.** Dort war die Begründung: was +nur auf `127.0.0.1` hört, ist gegen alles geschützt, was nicht vor dem Rechner +sitzt. Diese Eigenschaft geht verloren, und der Schutz wandert auf zwei Dinge: + +- Passwort mit Sitzungs-Cookie für Übersicht, Einrichtung und Status. +- Ein zufälliger Schlüssel in der Adresse jeder Browser-Source. Er darf nur + lesen, nie schreiben, und lässt sich auf der Übersichtsseite neu würfeln. + +Der Schlüssel in der URL ist kein schönes Verfahren. Er steht in jedem +OBS-Profil und in jedem Screenshot der Einstellungen. Er ist trotzdem der +richtige: eine Browser-Source kann kein Formular ausfüllen, keinen Login +durchhalten und keinen zweiten Faktor bedienen. Jedes Verfahren, das mehr +verlangt, wird spätestens beim ersten Ausfall mitten im Stream abgeschaltet. + +**Warum die Zugangsdaten trotzdem verschlüsselt neben der Datenbank liegen.** +Daran ändert sich nichts. Der Hub hat seinen eigenen Speicher, mit eigenem +Schlüssel, und die Twitch-Apps brauchen jetzt eine zweite Redirect-URI +(`https://abdu.dimension47.de/callback`) neben der bisherigen. + +## Konsequenzen + +- Die Twitch-App braucht eine zweite Redirect-URI. Twitch vergleicht + zeichengenau; ein fehlender Schrägstrich sieht im Fehlertext aus wie ein + falsches Client-Secret. +- Der Discord-Bot darf nur an **einer** Stelle laufen. Laufen Hub und Ingest + beide mit hinterlegtem Bot-Token, antworten zwei Prozesse auf denselben + Klick. Auf dem Stream-PC bleibt das Discord-Token deshalb leer. +- `abdulive ingest` verliert nichts, macht aber weniger. Der Go-Live-Post ist + dort weiterhin eingebaut - wer nur lokal fährt, hat damit alles an einer + Stelle, und dort gibt es zusätzlich das OBS-Vorschaubild. +- Der Hub kennt OBS nicht und kann deshalb keinen Screenshot für die + Ankündigung machen. Er nimmt Twitchs Vorschaubild. Das ist kurz nach dem + Start manchmal noch schwarz - der Preis dafür, dass der Post auch dann + rausgeht, wenn der Daemon am Stream-PC nicht läuft. +- ADR-0006 ("ein Dach") gilt weiter, aber jetzt je Seite: der Hub hat ein + Dach, der Stream-PC hat eins. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0d5c707..470d3a2 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -9,6 +9,7 @@ | [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert | | [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert | | [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert | +| [0008](ADR-0008-geteilter-betrieb.md) | Geteilter Betrieb: Hub im LXC, Ingest am Stream-PC | akzeptiert | Neue Entscheidung: Datei `ADR--.md`, Aufbau Kontext / Entscheidung / Begruendung / Konsequenzen. diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4bfee58..0110be5 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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; + // 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; } diff --git a/packages/twitch/src/oauth.ts b/packages/twitch/src/oauth.ts index e9ec1a1..1f74788 100644 --- a/packages/twitch/src/oauth.ts +++ b/packages/twitch/src/oauth.ts @@ -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 = { '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 = { /** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */ const pending = new Map(); +/** + * 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(); + +export async function appToken(role: Role): Promise { + 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}`); diff --git a/scripts/deploy-hub.sh b/scripts/deploy-hub.sh new file mode 100644 index 0000000..df9fd03 --- /dev/null +++ b/scripts/deploy-hub.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Den Hub auf den LXC ausrollen. +# +# Warum ein Skript und kein `git pull` im Container: der Container hat keinen +# Zugang zum Repo, und er soll auch keinen bekommen. Ausgerollt wird, was +# hier im Arbeitsverzeichnis liegt - damit ist immer nachvollziehbar, welcher +# Stand laeuft, auch wenn gerade etwas nicht gepusht ist. +# +# Aufruf: scripts/deploy-hub.sh +# Umgebung (mit Vorgaben): +# PVE_HOST=192.168.2.20 Proxmox-Host +# CT=141 Container-ID +set -euo pipefail + +PVE_HOST="${PVE_HOST:-192.168.2.20}" +CT="${CT:-141}" +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STAND="$(date -u +%Y%m%d-%H%M%S)" + +echo "Packe den Arbeitsstand ..." +cd "$REPO" +# node_modules bleibt draussen: das Projekt hat keine Laufzeit-Abhaengigkeiten, +# TypeScript wird nur fuer die Typpruefung gebraucht und nicht im Betrieb. +tar czf "/tmp/abdulive-${STAND}.tgz" \ + --exclude='./node_modules' \ + --exclude='./.git' \ + --exclude='./dist' \ + --exclude='./docs/handbuch/build' \ + apps packages config scripts package.json tsconfig.json + +echo "Schiebe nach ${PVE_HOST} -> CT ${CT} ..." +scp -q "/tmp/abdulive-${STAND}.tgz" "root@${PVE_HOST}:/tmp/abdulive.tgz" +ssh "root@${PVE_HOST}" "pct push ${CT} /tmp/abdulive.tgz /tmp/abdulive.tgz && rm -f /tmp/abdulive.tgz" +rm -f "/tmp/abdulive-${STAND}.tgz" + +echo "Entpacke und starte neu ..." +ssh "root@${PVE_HOST}" "pct exec ${CT} -- bash -lc ' + set -e + # In einen frischen Ordner entpacken und erst danach umhaengen: bricht das + # Entpacken ab, laeuft der alte Stand unveraendert weiter. + rm -rf /opt/abdulive.neu + mkdir -p /opt/abdulive.neu + tar xzf /tmp/abdulive.tgz -C /opt/abdulive.neu --no-same-owner + rm -f /tmp/abdulive.tgz + rm -rf /opt/abdulive.alt + [ -d /opt/abdulive ] && mv /opt/abdulive /opt/abdulive.alt || true + mv /opt/abdulive.neu /opt/abdulive + systemctl restart abdulive-hub 2>/dev/null || true +'" + +echo "Fertig. Stand ${STAND}." +ssh "root@${PVE_HOST}" "pct exec ${CT} -- systemctl is-active abdulive-hub" || true