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
+185
-1
@@ -646,6 +646,177 @@ Was jetzt noch von Hand gehoert:
|
|||||||
verbinden, dann bekommen Subs automatisch eine Rolle.`);
|
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> {
|
async function discordBot(): Promise<void> {
|
||||||
const { createBot } = await import('#discord/bot.ts');
|
const { createBot } = await import('#discord/bot.ts');
|
||||||
const bot = createBot();
|
const bot = createBot();
|
||||||
@@ -673,7 +844,10 @@ function help(): void {
|
|||||||
console.log(`
|
console.log(`
|
||||||
abdulive - Werkzeugkasten fuer Abdus Stream
|
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
|
check-env Umgebung pruefen
|
||||||
db:migrate Migrationen anwenden
|
db:migrate Migrationen anwenden
|
||||||
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
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:setup [--server ID] [--vorschau]
|
||||||
Discord-Server nach docs/projects/discord-community.md einrichten
|
Discord-Server nach docs/projects/discord-community.md einrichten
|
||||||
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
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
|
help Diese Uebersicht
|
||||||
|
|
||||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||||
@@ -715,6 +891,14 @@ try {
|
|||||||
}
|
}
|
||||||
case 'render': await renderOne(args[0], args[1]); break;
|
case 'render': await renderOne(args[0], args[1]); break;
|
||||||
case 'discord:setup': await discordSetup(args); 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 'discord:bot': await discordBot(); break;
|
||||||
case 'help': case undefined: help(); break;
|
case 'help': case undefined: help(); break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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<string, unknown>): 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<void>((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<typeof loadConfig>,
|
||||||
|
repo: ReturnType<typeof repos>,
|
||||||
|
state: State,
|
||||||
|
bus: Bus,
|
||||||
|
signal: AbortSignal,
|
||||||
|
onOnline: (stream: StreamInfo) => void
|
||||||
|
): Promise<void> {
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,18 +59,45 @@ async function handle(
|
|||||||
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
|
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`);
|
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<boolean> {
|
||||||
const html = (body: string, code = 200) => {
|
const html = (body: string, code = 200) => {
|
||||||
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
|
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
|
||||||
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
|
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
const back = (kind: 'ok' | 'err', text: string) => {
|
const back = (kind: 'ok' | 'err', text: string) => {
|
||||||
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
|
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
|
||||||
res.end();
|
res.end();
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Einrichtungsseite
|
// ---- Einrichtungsseite
|
||||||
if (url.pathname === '/' || url.pathname === '/setup') {
|
if (url.pathname === '/setup') {
|
||||||
const ok = url.searchParams.get('ok');
|
const ok = url.searchParams.get('ok');
|
||||||
const err = url.searchParams.get('err');
|
const err = url.searchParams.get('err');
|
||||||
return html(setupPage(secretStatus(), {
|
return html(setupPage(secretStatus(), {
|
||||||
@@ -119,7 +146,7 @@ async function handle(
|
|||||||
try {
|
try {
|
||||||
res.writeHead(303, { location: authorizeUrl(role, deps.port) });
|
res.writeHead(303, { location: authorizeUrl(role, deps.port) });
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return back('err', (err as Error).message);
|
return back('err', (err as Error).message);
|
||||||
}
|
}
|
||||||
@@ -144,9 +171,8 @@ async function handle(
|
|||||||
if (url.pathname === '/health') {
|
if (url.pathname === '/health') {
|
||||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
res.end(JSON.stringify(deps.health(), null, 2));
|
res.end(JSON.stringify(deps.health(), null, 2));
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
return false;
|
||||||
res.end('Nicht gefunden');
|
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
| [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert |
|
| [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 |
|
| [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 |
|
| [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-<nummer>-<kurzname>.md`, Aufbau Kontext /
|
Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
|
||||||
Entscheidung / Begruendung / Konsequenzen.
|
Entscheidung / Begruendung / Konsequenzen.
|
||||||
@@ -45,6 +45,37 @@ export type Config = {
|
|||||||
ffmpeg: { encoder: string; preset: string; crf: number; niceness: number };
|
ffmpeg: { encoder: string; preset: string; crf: number; niceness: number };
|
||||||
whisper: { model: string; language: string; threads: 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 };
|
ingestServer: { port: number };
|
||||||
dashboard: { port: number };
|
dashboard: { port: number };
|
||||||
raw: YamlMap;
|
raw: YamlMap;
|
||||||
@@ -81,6 +112,40 @@ const DEFAULTS = {
|
|||||||
ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 },
|
ffmpeg: { encoder: 'libx264', preset: 'veryfast', crf: 20, niceness: 15 },
|
||||||
whisper: { model: 'ggml-small.bin', language: 'de', threads: 4 },
|
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 },
|
ingestServer: { port: 4790 },
|
||||||
dashboard: { port: 4792 },
|
dashboard: { port: 4792 },
|
||||||
};
|
};
|
||||||
@@ -105,7 +170,12 @@ export function loadConfig(): Config {
|
|||||||
if (fs.existsSync(file)) {
|
if (fs.existsSync(file)) {
|
||||||
raw = parseYaml(fs.readFileSync(file, 'utf8'));
|
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;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben
|
// 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 crypto from 'node:crypto';
|
||||||
|
import { loadConfig } from '#core/config.ts';
|
||||||
import { logger } from '#core/logger.ts';
|
import { logger } from '#core/logger.ts';
|
||||||
import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts';
|
import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ export const SCOPES: Record<Role, string[]> = {
|
|||||||
'moderator:read:chatters',
|
'moderator:read:chatters',
|
||||||
'channel:manage:raids',
|
'channel:manage:raids',
|
||||||
'channel:read:redemptions',
|
'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',
|
'bits:read',
|
||||||
],
|
],
|
||||||
bot: ['chat:read', 'chat:edit'],
|
bot: ['chat:read', 'chat:edit'],
|
||||||
@@ -27,9 +32,19 @@ export const SCOPES: Record<Role, string[]> = {
|
|||||||
/** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */
|
/** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */
|
||||||
const pending = new Map<string, { role: Role; created: number }>();
|
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 {
|
export function redirectUri(port: number): string {
|
||||||
// Muss exakt so in der Twitch-App hinterlegt sein.
|
const base = loadConfig().publicUrl;
|
||||||
return `http://localhost:${port}/callback`;
|
return base ? `${base}/callback` : `http://localhost:${port}/callback`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authorizeUrl(role: Role, port: number): string {
|
export function authorizeUrl(role: Role, port: number): string {
|
||||||
@@ -86,6 +101,52 @@ export async function handleCallback(code: string, state: string, port: number):
|
|||||||
return role;
|
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) {
|
export async function validate(accessToken: string) {
|
||||||
const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } });
|
const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } });
|
||||||
if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`);
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user