Compare commits
33
Commits
71dd660d01
...
v0.5.5
No files matched your search
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
data
|
||||
logs
|
||||
.env
|
||||
docs
|
||||
*.md
|
||||
@@ -0,0 +1,76 @@
|
||||
# Baut das Windows-Paket und haengt es an die Veroeffentlichung `latest`.
|
||||
#
|
||||
# Ausgeloest von einem Tag `v0.5.1` oder von Hand ueber die Actions-Seite.
|
||||
# Der Updater auf Abdus Rechner fragt immer dieselbe Adresse ab:
|
||||
# https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest/abdulive.json
|
||||
# Deshalb wird `latest` bei jedem Lauf geloescht und neu angelegt.
|
||||
|
||||
name: Paket bauen und veroeffentlichen
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Versionsnummer, z. B. 0.5.1'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
paket:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Tests und Typpruefung
|
||||
run: |
|
||||
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||
npm i --no-save typescript@5.9 @types/node
|
||||
npx tsc --noEmit
|
||||
|
||||
- name: Version bestimmen
|
||||
id: v
|
||||
run: |
|
||||
if [ -n "${{ inputs.version }}" ]; then
|
||||
echo "wert=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "wert=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Laedt Node, ffmpeg und whisper.cpp bei ihren Herausgebern und packt
|
||||
# alles zusammen. Siehe scripts/build-package.ts und ADR-0007.
|
||||
- name: Paket bauen
|
||||
run: node scripts/build-package.ts --version "${{ steps.v.outputs.wert }}"
|
||||
|
||||
- name: Veroeffentlichung ersetzen
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
VERSION: ${{ steps.v.outputs.wert }}
|
||||
run: |
|
||||
set -eu
|
||||
auth="Authorization: token $TOKEN"
|
||||
|
||||
# Vorhandene `latest` weg - sie soll immer den aktuellen Stand tragen
|
||||
alt=$(curl -sf -H "$auth" "$API/releases/tags/latest" \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).id))}catch{}})' || true)
|
||||
if [ -n "$alt" ]; then
|
||||
curl -sf -X DELETE -H "$auth" "$API/releases/$alt" || true
|
||||
fi
|
||||
|
||||
id=$(curl -sf -H "$auth" -H 'Content-Type: application/json' -X POST "$API/releases" \
|
||||
-d "{\"tag_name\":\"latest\",\"target_commitish\":\"$GITHUB_SHA\",\"name\":\"abdulive $VERSION\",\"body\":\"Paket entpacken und darin install.ps1 ausfuehren.\"}" \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(String(JSON.parse(d).id)))')
|
||||
|
||||
for f in dist/abdulive-"$VERSION"-win-x64.zip dist/abdulive.json; do
|
||||
curl -sf -H "$auth" -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
|
||||
-F "attachment=@$f" -o /dev/null
|
||||
echo " hochgeladen: $(basename "$f")"
|
||||
done
|
||||
|
||||
- name: Was drin ist
|
||||
run: cat dist/abdulive.json
|
||||
+11
@@ -21,3 +21,14 @@ logs/
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Zwischenspeicher des Paketbaus und sein Ergebnis
|
||||
.cache/
|
||||
dist/
|
||||
|
||||
# LaTeX-Zwischendateien
|
||||
docs/handbuch/*.aux
|
||||
docs/handbuch/*.log
|
||||
docs/handbuch/*.out
|
||||
docs/handbuch/*.toc
|
||||
docs/handbuch/*.png
|
||||
@@ -43,31 +43,54 @@ Dazu der Dienst, der unter allen anderen liegt:
|
||||
- [`docs/offene-punkte.md`](docs/offene-punkte.md) — was noch fehlt
|
||||
- [`docs/obs-layout.md`](docs/obs-layout.md) — die Layout-Vorgabe für OBS
|
||||
- [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool
|
||||
- [`docs/projects/discord-community.md`](docs/projects/discord-community.md) — Aufbau des Community-Servers
|
||||
- [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs)
|
||||
|
||||
## Schnellstart
|
||||
## Auf dem Stream-PC installieren
|
||||
|
||||
Alles läuft in Docker — auf dem Rechner werden nur Docker Desktop und OBS
|
||||
gebraucht, ffmpeg und whisper.cpp stecken im Image.
|
||||
Siehe [`docs/05-setup.md`](docs/05-setup.md).
|
||||
Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften
|
||||
sind mitgeliefert — vorausgesetzt wird nur OBS. Kein Docker, kein WSL2, nichts
|
||||
zu übersetzen ([ADR-0007](docs/decisions/ADR-0007-native-windows.md)).
|
||||
|
||||
Ein Befehl, ohne Administratorrechte. Programm und Daten landen im
|
||||
Benutzerprofil.
|
||||
|
||||
```powershell
|
||||
Copy-Item config\config.example.yaml config\config.yaml
|
||||
docker compose build
|
||||
docker compose run --rm toolkit check-env
|
||||
docker compose run --rm toolkit fetch-model
|
||||
docker compose up -d
|
||||
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Dann **http://localhost:4792/setup** öffnen und die Twitch-Zugangsdaten
|
||||
eintragen. Sie werden verschlüsselt im Docker-Volume abgelegt, nicht in einer
|
||||
Datei im Projekt ([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)).
|
||||
Kein Netz auf dem Stream-PC? Dann das Paket von der Releases-Seite laden,
|
||||
entpacken und darin `install.ps1` ausführen.
|
||||
|
||||
Der Installer lädt danach das Sprachmodell, läuft den Selbsttest, startet
|
||||
den Daemon und öffnet die Einrichtungsseite. Ein Befehl, und du bist da.
|
||||
|
||||
Dort die Twitch-Zugangsdaten eintragen. Sie werden verschlüsselt abgelegt,
|
||||
nicht in einer Datei im Projekt
|
||||
([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)).
|
||||
|
||||
Ausführlich: [`docs/05-setup.md`](docs/05-setup.md).
|
||||
|
||||
## Paket bauen und ausliefern
|
||||
|
||||
```powershell
|
||||
node scripts/build-package.ts --version 0.5.0
|
||||
```
|
||||
|
||||
Lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern, packt alles nach
|
||||
`dist/` und schreibt `abdulive.json` — das Manifest, das der Updater auf
|
||||
Abdus Rechner abfragt. Läuft auch in der CI unter Linux.
|
||||
|
||||
Auf dem Stream-PC sieht `abdulive update` täglich nach und legt eine neue
|
||||
Fassung bereit; eingespielt wird sie beim nächsten Start, nie mitten im
|
||||
Stream.
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut: Container-Toolchain,
|
||||
Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut: Toolchain,
|
||||
Einrichtung im Browser, Twitch-Auth, Datenbank, Chat-Aufnahme, OBS-Anbindung mit
|
||||
`!clip`, 9:16-Render und Untertitel. 53 Tests grün.
|
||||
`!clip`, 9:16-Render und Untertitel. Dazu die native Auslieferung mit Installer
|
||||
und Updater. 79 Tests grün.
|
||||
|
||||
Offen: die Abnahme der OBS-Anbindung an einem laufenden OBS, dann S5 —
|
||||
Onboarding-Assistent und Sichtungsseite.
|
||||
@@ -77,5 +100,5 @@ Details: [`docs/handoff.md`](docs/handoff.md)
|
||||
## Tests
|
||||
|
||||
```powershell
|
||||
docker run --rm -v "${PWD}:/app" -w /app node:24-bookworm-slim node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||
```
|
||||
@@ -1,5 +1,23 @@
|
||||
# @abdulive/broadcaster
|
||||
|
||||
Go-Live-Ankuendigung auf Discord, Bluesky, Mastodon, plus Instagram-Story-Bild.
|
||||
Die Go-Live-Ankuendigung auf Discord.
|
||||
|
||||
Wird von `abdulive hub` und von `abdulive ingest` benutzt: sobald ein neuer
|
||||
Stream erkannt wird, wartet der Ankuendiger eine Minute (Titel und Kategorie
|
||||
stehen bei Twitch erst kurz nach dem Start), holt die Daten frisch, und postet
|
||||
ueber den Webhook in `#ankuendigungen` - mit Ping an die Rolle `Live-Ping`.
|
||||
|
||||
Der teuerste Fehler hier ist der doppelte Post: wer zweimal in zehn Minuten
|
||||
angepingt wird, nimmt sich die Rolle wieder ab. Dagegen steht `decide()` in
|
||||
`src/golive.ts`, die Tabelle `posts` und eine Abklingzeit von sechs Stunden.
|
||||
|
||||
Probelauf, ohne auf einen Stream zu warten:
|
||||
|
||||
```
|
||||
abdulive golive:test # in #technik-log, ohne Ping
|
||||
abdulive golive:test --echt # in #ankuendigungen, mit Ping
|
||||
```
|
||||
|
||||
Spezifikation: [docs/projects/go-live-broadcaster.md](../../docs/projects/go-live-broadcaster.md)
|
||||
|
||||
Noch nicht gebaut: Bluesky, Mastodon und das Instagram-Story-Bild.
|
||||
@@ -0,0 +1,283 @@
|
||||
// Der Go-Live-Post geht an Menschen, die sich den Ping selbst geholt haben.
|
||||
// Zwei Pings fuer denselben Stream kosten Abonnenten der Rolle, und die kommen
|
||||
// nicht wieder. Deshalb liegt der Schwerpunkt hier auf dem, was NICHT rausgeht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { repos, type Repos } from '#db/repos.ts';
|
||||
import type { Attachment, PostedMessage, WebhookMessage } from '#discord/webhook.ts';
|
||||
import {
|
||||
buildMessage, createAnnouncer, decide, thumbnailUrl,
|
||||
type GoLiveConfig, type StreamInfo,
|
||||
} from './golive.ts';
|
||||
import { pick, OPENERS } from './templates.ts';
|
||||
|
||||
const MIGRATIONS = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'packages', 'db', 'migrations');
|
||||
|
||||
/** Frische Datenbank im Arbeitsspeicher, mit allen Migrationen. */
|
||||
function freshRepo(): Repos {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
for (const f of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith('.sql')).sort()) {
|
||||
db.exec(fs.readFileSync(path.join(MIGRATIONS, f), 'utf8'));
|
||||
}
|
||||
return repos(db);
|
||||
}
|
||||
|
||||
const STREAM: StreamInfo = {
|
||||
id: '48212345',
|
||||
title: 'Elden Ring, aber nur mit Fäusten',
|
||||
category: 'Elden Ring',
|
||||
login: 'abdulive_',
|
||||
startedAt: '2026-09-17T18:00:00.000Z',
|
||||
thumbnailUrl: 'https://static-cdn.twitch.tv/previews/abdulive_-{width}x{height}.jpg',
|
||||
};
|
||||
|
||||
const CFG: GoLiveConfig = {
|
||||
enabled: true, mode: 'live', delaySeconds: 0, cooldownHours: 6,
|
||||
pingRole: 'Live-Ping', obsScreenshot: false,
|
||||
};
|
||||
|
||||
type Sent = { url: string; message: WebhookMessage; files: Attachment[] };
|
||||
|
||||
function fakePost(sink: Sent[], fail?: string) {
|
||||
return async (url: string, message: WebhookMessage, files: Attachment[] = [])
|
||||
: Promise<PostedMessage> => {
|
||||
if (fail) throw new Error(fail);
|
||||
sink.push({ url, message, files });
|
||||
return { id: '999', channel_id: '888' };
|
||||
};
|
||||
}
|
||||
|
||||
const HOOKS = () => ({ live: 'https://live', draft: 'https://draft', guildId: '1' });
|
||||
const ROLES = { id: async () => '777', forget: () => undefined };
|
||||
|
||||
function announcer(over: Partial<Parameters<typeof createAnnouncer>[0]> = {}, cfg = CFG) {
|
||||
const sent: Sent[] = [];
|
||||
const a = createAnnouncer({
|
||||
cfg, repo: freshRepo(), post: fakePost(sent), roles: ROLES,
|
||||
webhooks: HOOKS, refresh: async (s) => s, wait: async () => undefined,
|
||||
...over,
|
||||
});
|
||||
return { a, sent };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Entscheidung
|
||||
|
||||
test('ein frischer Stream wird angekuendigt', () => {
|
||||
assert.deepEqual(
|
||||
decide({ enabled: true, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0 }),
|
||||
{ post: true });
|
||||
});
|
||||
|
||||
test('der Kill-Switch haelt', () => {
|
||||
const d = decide({ enabled: false, alreadyPosted: false, lastPostAt: null, cooldownHours: 6, now: 0 });
|
||||
assert.equal(d.post, false);
|
||||
assert.match((d as { reason: string }).reason, /enabled/);
|
||||
});
|
||||
|
||||
test('derselbe Stream wird kein zweites Mal angekuendigt', () => {
|
||||
const d = decide({ enabled: true, alreadyPosted: true, lastPostAt: null, cooldownHours: 6, now: 0 });
|
||||
assert.equal(d.post, false);
|
||||
assert.match((d as { reason: string }).reason, /bereits/);
|
||||
});
|
||||
|
||||
test('ein zweites stream.online kurz nach dem ersten laeuft in die Abklingzeit', () => {
|
||||
// Der Fall aus dem Betrieb: Twitch vergibt nach einem Aussetzer eine neue
|
||||
// Stream-ID. Dann greift `alreadyPosted` nicht, und nur die Abklingzeit
|
||||
// verhindert den zweiten Rundruf.
|
||||
const vorZehnMinuten = new Date('2026-09-17T18:00:00Z').toISOString();
|
||||
const d = decide({
|
||||
enabled: true, alreadyPosted: false, lastPostAt: vorZehnMinuten,
|
||||
cooldownHours: 6, now: Date.parse('2026-09-17T18:10:00Z'),
|
||||
});
|
||||
assert.equal(d.post, false);
|
||||
assert.match((d as { reason: string }).reason, /Abklingzeit/);
|
||||
});
|
||||
|
||||
test('nach der Abklingzeit geht es wieder', () => {
|
||||
const d = decide({
|
||||
enabled: true, alreadyPosted: false, lastPostAt: '2026-09-17T10:00:00Z',
|
||||
cooldownHours: 6, now: Date.parse('2026-09-17T18:00:00Z'),
|
||||
});
|
||||
assert.equal(d.post, true);
|
||||
});
|
||||
|
||||
test('force geht durch die Abklingzeit, aber nicht durch den Kill-Switch', () => {
|
||||
assert.equal(decide({
|
||||
enabled: true, alreadyPosted: true, lastPostAt: '2026-09-17T17:59:00Z',
|
||||
cooldownHours: 6, now: Date.parse('2026-09-17T18:00:00Z'), force: true,
|
||||
}).post, true);
|
||||
assert.equal(decide({
|
||||
enabled: false, alreadyPosted: false, lastPostAt: null,
|
||||
cooldownHours: 6, now: 0, force: true,
|
||||
}).post, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Nachricht
|
||||
|
||||
test('der Ping steht im Text UND in allowed_mentions', () => {
|
||||
// Nur im Text faerbt Discord die Erwaehnung ein, benachrichtigt aber
|
||||
// niemanden. Das ist der Fehler, den man erst nach drei leeren Streams sieht.
|
||||
const m = buildMessage({ stream: STREAM, roleId: '777' });
|
||||
assert.match(m.content ?? '', /<@&777>/);
|
||||
assert.deepEqual(m.allowed_mentions, { parse: [], roles: ['777'] });
|
||||
});
|
||||
|
||||
test('ohne aufgeloeste Rolle geht der Post raus, aber pingt niemanden', () => {
|
||||
const m = buildMessage({ stream: STREAM });
|
||||
assert.equal(m.content?.includes('<@&'), false);
|
||||
assert.deepEqual(m.allowed_mentions, { parse: [] });
|
||||
assert.equal(m.embeds?.[0]?.title, STREAM.title, 'der Post selbst bleibt vollstaendig');
|
||||
});
|
||||
|
||||
test('ein Entwurf pingt auch dann nicht, wenn die Rolle bekannt ist', () => {
|
||||
const m = buildMessage({ stream: STREAM, roleId: '777', draft: true });
|
||||
assert.equal(m.content?.includes('<@&777>'), false);
|
||||
assert.deepEqual(m.allowed_mentions, { parse: [] });
|
||||
assert.match(m.content ?? '', /Entwurf/);
|
||||
});
|
||||
|
||||
test('Titel, Kategorie und Link stehen im Embed', () => {
|
||||
const e = buildMessage({ stream: STREAM }).embeds?.[0];
|
||||
assert.equal(e?.title, STREAM.title);
|
||||
assert.equal(e?.url, 'https://twitch.tv/abdulive_');
|
||||
assert.equal(e?.fields?.[0]?.value, 'Elden Ring');
|
||||
assert.match(e?.fields?.[1]?.value ?? '', /^<t:\d+:R>$/, 'Startzeit relativ, nicht in MESZ');
|
||||
});
|
||||
|
||||
test('ein Anhang wird per attachment:// referenziert', () => {
|
||||
const e = buildMessage({ stream: STREAM, attachment: 'vorschau.jpg' }).embeds?.[0];
|
||||
assert.equal(e?.image?.url, 'attachment://vorschau.jpg');
|
||||
});
|
||||
|
||||
test('Twitchs Vorschau bekommt Groesse und einen Anhaenger gegen den Cache', () => {
|
||||
const url = thumbnailUrl(STREAM.thumbnailUrl, '48212345');
|
||||
assert.match(url ?? '', /-1280x720\.jpg\?t=48212345$/);
|
||||
assert.equal(thumbnailUrl(undefined, 'x'), undefined);
|
||||
});
|
||||
|
||||
test('ein leerer Titel laesst das Embed nicht ohne Titel zurueck', () => {
|
||||
// Discord lehnt ein Embed mit leerem title ab, und genau das liefert Twitch
|
||||
// in der ersten Minute nach dem Start.
|
||||
const e = buildMessage({ stream: { ...STREAM, title: '', category: '' } }).embeds?.[0];
|
||||
assert.ok((e?.title ?? '').length > 0);
|
||||
assert.ok((e?.fields?.[0]?.value ?? '').length > 0);
|
||||
});
|
||||
|
||||
test('derselbe Stream bekommt denselben Text, ein anderer einen anderen', () => {
|
||||
assert.equal(pick(OPENERS, 'abc'), pick(OPENERS, 'abc'));
|
||||
const texte = new Set(['1', '2', '3', '4', '5', '6', '7', '8'].map((s) => pick(OPENERS, s)));
|
||||
assert.ok(texte.size > 1, 'die Auswahl darf nicht auf einen Text zusammenfallen');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Ablauf
|
||||
|
||||
test('der Post geht an den Go-Live-Webhook und wird protokolliert', async () => {
|
||||
const repo = freshRepo();
|
||||
const { a, sent } = announcer({ repo });
|
||||
const r = await a.announceNow(STREAM);
|
||||
|
||||
assert.equal(r.status, 'gesendet');
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0]?.url, 'https://live');
|
||||
assert.equal(repo.postFor(STREAM.id, 'golive', 'discord')?.status, 'gesendet');
|
||||
});
|
||||
|
||||
test('zweimal derselbe Stream gibt genau einen Post', async () => {
|
||||
const repo = freshRepo();
|
||||
const { a, sent } = announcer({ repo });
|
||||
await a.announceNow(STREAM);
|
||||
const zweiter = await a.announceNow(STREAM);
|
||||
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(zweiter.status, 'uebersprungen');
|
||||
});
|
||||
|
||||
test('im Entwurfsmodus geht es in den internen Kanal', async () => {
|
||||
const { a, sent } = announcer({}, { ...CFG, mode: 'draft' });
|
||||
await a.announceNow(STREAM);
|
||||
assert.equal(sent[0]?.url, 'https://draft');
|
||||
assert.equal(sent[0]?.message.allowed_mentions?.roles, undefined);
|
||||
});
|
||||
|
||||
test('ohne hinterlegten Webhook wird das gemeldet, nicht verschluckt', async () => {
|
||||
const repo = freshRepo();
|
||||
const { a, sent } = announcer({ repo, webhooks: () => ({}) });
|
||||
const r = await a.announceNow(STREAM);
|
||||
|
||||
assert.equal(r.status, 'fehlgeschlagen');
|
||||
assert.match((r as { reason: string }).reason, /discord:setup/);
|
||||
assert.equal(sent.length, 0);
|
||||
assert.equal(repo.postFor(STREAM.id, 'golive', 'discord'), undefined,
|
||||
'ein Fehlschlag darf den naechsten Versuch nicht sperren');
|
||||
});
|
||||
|
||||
test('ein fehlgeschlagener Post darf wiederholt werden', async () => {
|
||||
const repo = freshRepo();
|
||||
const sent: Sent[] = [];
|
||||
let erster = true;
|
||||
const a = createAnnouncer({
|
||||
cfg: CFG, repo, roles: ROLES, webhooks: HOOKS,
|
||||
refresh: async (s) => s, wait: async () => undefined,
|
||||
post: async (url, message, files = []) => {
|
||||
if (erster) { erster = false; throw new Error('Netz weg'); }
|
||||
sent.push({ url, message, files });
|
||||
return { id: '1', channel_id: '2' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await a.announceNow(STREAM)).status, 'fehlgeschlagen');
|
||||
assert.equal((await a.announceNow(STREAM)).status, 'gesendet');
|
||||
assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
test('eine nicht aufloesbare Rolle stoppt den Post nicht', async () => {
|
||||
const { a, sent } = announcer({
|
||||
roles: { id: async () => { throw new Error('403'); }, forget: () => undefined },
|
||||
});
|
||||
const r = await a.announceNow(STREAM);
|
||||
|
||||
assert.equal(r.status, 'gesendet');
|
||||
assert.equal((r as { pinged: boolean }).pinged, false);
|
||||
assert.equal(sent[0]?.message.content?.includes('<@&'), false);
|
||||
});
|
||||
|
||||
test('der Kill-Switch schickt gar nichts los', async () => {
|
||||
const { a, sent } = announcer({}, { ...CFG, enabled: false });
|
||||
assert.equal((await a.announceNow(STREAM)).status, 'uebersprungen');
|
||||
assert.equal(sent.length, 0);
|
||||
});
|
||||
|
||||
test('ein Probelauf sperrt die naechste echte Ankuendigung nicht', () => {
|
||||
// Genau das ist am 2026-09-17 passiert: `golive:test --echt` lief 93 Minuten
|
||||
// vor dem Streamstart, trug sich als gesendeter Post ein, und die
|
||||
// Abklingzeit hat die richtige Ankuendigung verschluckt. Der Stream lief
|
||||
// ohne Ping.
|
||||
const repo = freshRepo();
|
||||
repo.insertPost({
|
||||
streamId: 'probe-1', kind: 'golive-test', platform: 'discord',
|
||||
mode: 'live', status: 'gesendet',
|
||||
});
|
||||
|
||||
// Der Probelauf steht in der Datenbank ...
|
||||
assert.ok(repo.lastPost('golive-test', 'discord'));
|
||||
// ... zaehlt aber fuer den Echtbetrieb nicht.
|
||||
assert.equal(repo.lastPost('golive', 'discord'), undefined);
|
||||
});
|
||||
|
||||
test('zwei echte Ankuendigungen kurz hintereinander bleiben gesperrt', () => {
|
||||
// Die Abklingzeit soll ja weiter wirken - nur eben nicht gegen Probelaeufe.
|
||||
const repo = freshRepo();
|
||||
const { a, sent } = announcer({ repo });
|
||||
return a.announceNow(STREAM).then(async () => {
|
||||
const zweiter = await a.announceNow({ ...STREAM, id: 'anderer-stream' });
|
||||
assert.equal(zweiter.status, 'uebersprungen');
|
||||
assert.match((zweiter as { reason: string }).reason, /Abklingzeit/);
|
||||
assert.equal(sent.length, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
// Die Go-Live-Ankuendigung auf Discord.
|
||||
//
|
||||
// Ablauf, wenn der Ingest einen neuen Stream sieht:
|
||||
//
|
||||
// stream.online
|
||||
// -> warten (Titel und Kategorie stehen erst kurz nach dem Start)
|
||||
// -> Titel und Kategorie frisch von Helix holen
|
||||
// -> Vorschaubild: OBS-Screenshot, sonst Twitchs Vorschau
|
||||
// -> pruefen, ob dieser Stream schon angekuendigt wurde
|
||||
// -> Webhook posten, mit Ping an die Rolle Live-Ping
|
||||
// -> Ergebnis in `posts` schreiben
|
||||
//
|
||||
// Der teuerste Fehler hier ist nicht der ausgefallene Post, sondern der
|
||||
// doppelte: wer zweimal in zehn Minuten angepingt wird, nimmt die Rolle ab,
|
||||
// und dann ist der Kanal fuer diese Person fuer immer stumm. Deshalb liegt
|
||||
// die halbe Datei in `decide()`.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { sleep } from '#core/retry.ts';
|
||||
import type { Repos } from '#db/repos.ts';
|
||||
import { Discord } from '#discord/api.ts';
|
||||
import { createRoleResolver, type RoleResolver } from '#discord/roles.ts';
|
||||
import { messageUrl, postWebhook, rolePing, type Attachment, type Embed, type WebhookMessage }
|
||||
from '#discord/webhook.ts';
|
||||
import type { ObsClient } from '#obs/client.ts';
|
||||
import { helix, type HelixStream } from '#twitch/helix.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
import { INVITATIONS, OPENERS, pick } from './templates.ts';
|
||||
|
||||
const log = logger('golive');
|
||||
|
||||
/** Twitch-Lila. Dieselbe Farbe wie die Rolle "Abdu" im Bauplan. */
|
||||
const TWITCH_PURPLE = 0x9146ff;
|
||||
|
||||
export type GoLiveConfig = {
|
||||
enabled: boolean;
|
||||
/** `live` postet in die Ankuendigungen, `draft` in den internen Kanal. */
|
||||
mode: 'live' | 'draft';
|
||||
/** Wartezeit nach dem Erkennen. Titel und Kategorie brauchen die. */
|
||||
delaySeconds: number;
|
||||
/** Innerhalb dieser Zeit wird kein zweites Mal angekuendigt. */
|
||||
cooldownHours: number;
|
||||
/** Rolle, die angepingt wird. Leer heisst: Post ohne Ping. */
|
||||
pingRole: string;
|
||||
/** Vorschaubild aus OBS holen. Ohne OBS faellt es auf Twitch zurueck. */
|
||||
obsScreenshot: boolean;
|
||||
};
|
||||
|
||||
export type StreamInfo = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
login: string;
|
||||
startedAt: string;
|
||||
/** Twitchs Vorlage mit {width}/{height} - wird hier eingesetzt. */
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Entscheidung
|
||||
|
||||
export type Decision = { post: true } | { post: false; reason: string };
|
||||
|
||||
/**
|
||||
* Darf dieser Stream angekuendigt werden? Bewusst ohne Netz, Datenbank und
|
||||
* Uhr im Bauch - alles kommt als Wert herein, damit die Faelle pruefbar sind,
|
||||
* die im Betrieb hoechstens einmal im Jahr vorkommen.
|
||||
*/
|
||||
export function decide(input: {
|
||||
enabled: boolean;
|
||||
alreadyPosted: boolean;
|
||||
lastPostAt: string | null;
|
||||
cooldownHours: number;
|
||||
now: number;
|
||||
/** Ein Testpost geht durch die Abklingzeit hindurch, aber nie an die Rolle. */
|
||||
force?: boolean;
|
||||
}): Decision {
|
||||
if (!input.enabled) return { post: false, reason: 'broadcaster.enabled steht auf false' };
|
||||
if (input.force) return { post: true };
|
||||
if (input.alreadyPosted) {
|
||||
return { post: false, reason: 'dieser Stream wurde bereits angekuendigt' };
|
||||
}
|
||||
if (input.lastPostAt) {
|
||||
const since = input.now - Date.parse(input.lastPostAt);
|
||||
const cooldown = input.cooldownHours * 3_600_000;
|
||||
if (Number.isFinite(since) && since < cooldown) {
|
||||
const rest = Math.ceil((cooldown - since) / 60_000);
|
||||
return {
|
||||
post: false,
|
||||
reason: `letzte Ankuendigung ist ${Math.floor(since / 60_000)} min her, ` +
|
||||
`Abklingzeit laeuft noch ${rest} min`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { post: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Nachricht
|
||||
|
||||
/**
|
||||
* Baut die fertige Webhook-Nachricht. Rein - kein Netz, keine Uhr ausser der
|
||||
* uebergebenen. `attachment` ist gesetzt, wenn ein Bild mitgeschickt wird;
|
||||
* dann muss der Dateiname im Anhang genau so heissen.
|
||||
*/
|
||||
export function buildMessage(input: {
|
||||
stream: StreamInfo;
|
||||
/** Aufgeloeste Rollen-ID, oder undefined fuer einen Post ohne Ping. */
|
||||
roleId?: string;
|
||||
/** Name der angehaengten Bilddatei, wenn eines mitgeht. */
|
||||
attachment?: string;
|
||||
/** Fertige Bild-URL, wenn kein Anhang mitgeht (Twitchs Vorschau). */
|
||||
imageUrl?: string;
|
||||
draft?: boolean;
|
||||
}): WebhookMessage {
|
||||
const { stream } = input;
|
||||
const url = `https://twitch.tv/${stream.login}`;
|
||||
const ping = rolePing(input.roleId);
|
||||
const opener = pick(OPENERS, stream.id);
|
||||
const invitation = pick(INVITATIONS, stream.id + 'x');
|
||||
|
||||
// Discord rechnet <t:...:R> in "vor 3 Minuten" um, in der Zeitzone des
|
||||
// Lesenden. Eine feste Uhrzeit waere hier falsch, sobald jemand nicht in
|
||||
// Deutschland sitzt.
|
||||
const startedUnix = Math.floor(Date.parse(stream.startedAt) / 1000);
|
||||
|
||||
const embed: Embed = {
|
||||
author: { name: stream.login, url },
|
||||
title: stream.title || 'Stream läuft',
|
||||
url,
|
||||
description: invitation,
|
||||
color: TWITCH_PURPLE,
|
||||
fields: [
|
||||
{ name: 'Kategorie', value: stream.category || 'ohne Angabe', inline: true },
|
||||
...(Number.isFinite(startedUnix)
|
||||
? [{ name: 'Gestartet', value: `<t:${startedUnix}:R>`, inline: true }]
|
||||
: []),
|
||||
],
|
||||
footer: { text: input.draft ? 'Entwurf - so ginge der Post raus' : 'abdulive Toolkit' },
|
||||
timestamp: new Date(stream.startedAt).toISOString(),
|
||||
};
|
||||
|
||||
const image = input.attachment ? `attachment://${input.attachment}` : input.imageUrl;
|
||||
if (image) embed.image = { url: image };
|
||||
|
||||
const content = [input.draft ? '**Entwurf**' : ping.mention, opener]
|
||||
.filter(Boolean).join(' ').trim();
|
||||
|
||||
return {
|
||||
content,
|
||||
embeds: [embed],
|
||||
// Im Entwurf nie pingen, auch wenn eine Rolle aufgeloest wurde: der
|
||||
// interne Kanal ist zum Ansehen da, nicht zum Benachrichtigen.
|
||||
allowed_mentions: input.draft ? { parse: [] } : ping.allowed_mentions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Twitchs Vorschaubild. Die Vorlage kommt mit Platzhaltern fuer die Groesse. */
|
||||
export function thumbnailUrl(template: string | undefined, cacheBuster: string): string | undefined {
|
||||
if (!template) return undefined;
|
||||
const url = template.replace('{width}', '1280').replace('{height}', '720');
|
||||
// Discord merkt sich Bilder anhand der URL. Ohne diesen Anhang zeigt der
|
||||
// zweite Stream das Vorschaubild des ersten.
|
||||
return `${url}${url.includes('?') ? '&' : '?'}t=${encodeURIComponent(cacheBuster)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Vorschaubild
|
||||
|
||||
/**
|
||||
* Screenshot der laufenden Programmausgabe. Gibt undefined zurueck, wenn OBS
|
||||
* nicht verbunden ist oder nicht antwortet - dann bleibt Twitchs Vorschau, und
|
||||
* die ist kurz nach dem Start haeufig noch schwarz.
|
||||
*/
|
||||
export async function obsScreenshot(obs: ObsClient): Promise<Attachment | undefined> {
|
||||
if (!obs.isConnected()) return undefined;
|
||||
try {
|
||||
const scene = await obs.request<{ currentProgramSceneName?: string; sceneName?: string }>(
|
||||
'GetCurrentProgramScene');
|
||||
const sourceName = scene.sceneName ?? scene.currentProgramSceneName;
|
||||
if (!sourceName) return undefined;
|
||||
|
||||
const shot = await obs.request<{ imageData: string }>('GetSourceScreenshot', {
|
||||
sourceName, imageFormat: 'jpg', imageWidth: 1280,
|
||||
});
|
||||
// OBS liefert eine data:-URL, kein rohes base64.
|
||||
const comma = shot.imageData.indexOf(',');
|
||||
const base64 = comma === -1 ? shot.imageData : shot.imageData.slice(comma + 1);
|
||||
return { name: 'vorschau.jpg', type: 'image/jpeg', data: Buffer.from(base64, 'base64') };
|
||||
} catch (err) {
|
||||
log.warn(`Kein OBS-Screenshot: ${(err as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ankuendiger
|
||||
|
||||
/**
|
||||
* Unter welcher Art ein Post in `posts` steht.
|
||||
*
|
||||
* Ein Probelauf bekommt eine eigene Art, und das ist kein Schoenheitsfehler:
|
||||
* zaehlt er als echter Post, sperrt er ueber die Abklingzeit die naechste
|
||||
* richtige Ankuendigung. Genau das ist am 2026-09-17 passiert - der Testping
|
||||
* lief 93 Minuten vor dem Streamstart, und die Ankuendigung fiel aus.
|
||||
*/
|
||||
export type PostArt = 'golive' | 'golive-test';
|
||||
|
||||
export type Announcer = {
|
||||
/**
|
||||
* Kuendigt den Stream an. Kehrt sofort zurueck - das Warten und der Post
|
||||
* laufen im Hintergrund, damit der Ingest nicht daran haengt.
|
||||
*/
|
||||
announce: (stream: StreamInfo, opts?: { force?: boolean; immediate?: boolean }) => void;
|
||||
/** Dasselbe, aber wartbar. Fuer den Testbefehl und fuer Tests. */
|
||||
announceNow: (stream: StreamInfo, opts?: { force?: boolean; art?: PostArt }) => Promise<Result>;
|
||||
lastResult: () => Result | null;
|
||||
};
|
||||
|
||||
export type Result =
|
||||
| { status: 'gesendet'; url: string; mode: 'live' | 'draft'; pinged: boolean }
|
||||
| { status: 'uebersprungen'; reason: string }
|
||||
| { status: 'fehlgeschlagen'; reason: string };
|
||||
|
||||
export type AnnouncerDeps = {
|
||||
cfg: GoLiveConfig;
|
||||
repo: Repos;
|
||||
/** Ohne OBS gibt es kein eigenes Vorschaubild, sonst aendert sich nichts. */
|
||||
obs?: ObsClient;
|
||||
/** Fuer Tests: Webhook-Post und Rollenauflösung unterschieben. */
|
||||
post?: typeof postWebhook;
|
||||
roles?: RoleResolver;
|
||||
/** Webhook-URLs. Vorgabe ist der verschluesselte Speicher. */
|
||||
webhooks?: () => { live?: string; draft?: string; guildId?: string };
|
||||
now?: () => number;
|
||||
/** Fuer Tests: Wartezeit ueberspringen. */
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
/** Frische Stream-Daten nach der Wartezeit. Vorgabe ist Helix. */
|
||||
refresh?: (stream: StreamInfo) => Promise<StreamInfo>;
|
||||
};
|
||||
|
||||
function secretWebhooks(): { live?: string; draft?: string; guildId?: string } {
|
||||
const d = readSecrets().discord;
|
||||
return {
|
||||
...(d?.goLiveWebhook ? { live: d.goLiveWebhook } : {}),
|
||||
...(d?.technikWebhook ? { draft: d.technikWebhook } : {}),
|
||||
...(d?.guildId ? { guildId: d.guildId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function helixRefresh(stream: StreamInfo): Promise<StreamInfo> {
|
||||
try {
|
||||
const user = await helix.userByLogin(stream.login);
|
||||
const live: HelixStream | null = user ? await helix.streamByUserId(user.id) : null;
|
||||
if (!live) return stream;
|
||||
const frisch = live.thumbnail_url || stream.thumbnailUrl;
|
||||
return {
|
||||
...stream,
|
||||
title: live.title || stream.title,
|
||||
category: live.game_name || stream.category,
|
||||
...(frisch ? { thumbnailUrl: frisch } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
// Kein Grund, den Post ausfallen zu lassen: die Daten aus dem ersten
|
||||
// Abruf sind selten falsch, nur manchmal leer.
|
||||
log.warn(`Konnte Titel und Kategorie nicht nachladen: ${(err as Error).message}`);
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnnouncer(deps: AnnouncerDeps): Announcer {
|
||||
const { cfg, repo } = deps;
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const wait = deps.wait ?? ((ms: number) => sleep(ms));
|
||||
const post = deps.post ?? postWebhook;
|
||||
const refresh = deps.refresh ?? helixRefresh;
|
||||
const webhooks = deps.webhooks ?? secretWebhooks;
|
||||
let last: Result | null = null;
|
||||
|
||||
// Der Rollen-Resolver braucht das Bot-Token. Fehlt es, gibt es keinen Ping,
|
||||
// aber sehr wohl einen Post - ein Post ohne Ping ist besser als keiner.
|
||||
let roles: RoleResolver | null = deps.roles ?? null;
|
||||
function roleResolver(): RoleResolver | null {
|
||||
if (roles) return roles;
|
||||
const d = readSecrets().discord;
|
||||
if (!d?.botToken || !d.guildId) return null;
|
||||
// Bremse von einer Minute: hier fragt niemand von Hand nach.
|
||||
roles = createRoleResolver(new Discord(d.botToken), d.guildId, 60_000);
|
||||
return roles;
|
||||
}
|
||||
|
||||
async function run(
|
||||
stream: StreamInfo, opts: { force?: boolean; art?: PostArt } = {},
|
||||
): Promise<Result> {
|
||||
const art: PostArt = opts.art ?? 'golive';
|
||||
const draft = cfg.mode === 'draft';
|
||||
const hooks = webhooks();
|
||||
const target = draft ? hooks.draft : hooks.live;
|
||||
|
||||
const decision = decide({
|
||||
enabled: cfg.enabled,
|
||||
alreadyPosted: Boolean(repo.postFor(stream.id, art, 'discord')),
|
||||
lastPostAt: repo.lastPost(art, 'discord')?.created_at ?? null,
|
||||
cooldownHours: cfg.cooldownHours,
|
||||
now: now(),
|
||||
...(opts.force ? { force: true } : {}),
|
||||
});
|
||||
|
||||
if (!decision.post) {
|
||||
log.info(`Keine Go-Live-Ankuendigung: ${decision.reason}`);
|
||||
last = { status: 'uebersprungen', reason: decision.reason };
|
||||
return last;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
const reason = draft
|
||||
? 'kein Webhook fuer den internen Kanal - einmal `abdulive discord:setup` laufen lassen'
|
||||
: 'kein Go-Live-Webhook hinterlegt - einmal `abdulive discord:setup` laufen lassen';
|
||||
log.warn(`Go-Live-Ankuendigung faellt aus: ${reason}`);
|
||||
last = { status: 'fehlgeschlagen', reason };
|
||||
repo.insertPost({
|
||||
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
|
||||
status: 'fehlgeschlagen', title: stream.title, category: stream.category, detail: reason,
|
||||
});
|
||||
return last;
|
||||
}
|
||||
|
||||
// Ping erst hier aufloesen: schlaegt es fehl, geht der Post trotzdem raus.
|
||||
let roleId: string | undefined;
|
||||
if (cfg.pingRole && !draft) {
|
||||
try {
|
||||
roleId = await roleResolver()?.id(cfg.pingRole);
|
||||
if (!roleId) {
|
||||
log.warn(`Rolle "${cfg.pingRole}" nicht gefunden - der Post geht ohne Ping raus`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Rolle nicht aufloesbar (${(err as Error).message}) - Post ohne Ping`);
|
||||
}
|
||||
}
|
||||
|
||||
const files: Attachment[] = [];
|
||||
if (cfg.obsScreenshot && deps.obs) {
|
||||
const shot = await obsScreenshot(deps.obs);
|
||||
if (shot) files.push(shot);
|
||||
}
|
||||
|
||||
// Entweder eigener Screenshot als Anhang oder Twitchs Vorschau als URL -
|
||||
// nie beides, sonst zeigt Discord zwei Bilder.
|
||||
const fallbackImage = files.length === 0
|
||||
? thumbnailUrl(stream.thumbnailUrl, stream.id) : undefined;
|
||||
|
||||
const message = buildMessage({
|
||||
stream,
|
||||
...(roleId ? { roleId } : {}),
|
||||
...(files[0] ? { attachment: files[0].name } : {}),
|
||||
...(fallbackImage ? { imageUrl: fallbackImage } : {}),
|
||||
...(draft ? { draft: true } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
const sent = await post(target, message, files);
|
||||
const url = hooks.guildId ? messageUrl(hooks.guildId, sent) : '';
|
||||
repo.insertPost({
|
||||
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
|
||||
status: 'gesendet', title: stream.title, category: stream.category, url,
|
||||
});
|
||||
log.info(`Go-Live-Post raus${draft ? ' (Entwurf, interner Kanal)' : ''}` +
|
||||
`${roleId ? `, mit Ping an ${cfg.pingRole}` : ', ohne Ping'}` +
|
||||
`${files.length ? ', mit OBS-Vorschau' : ''}`);
|
||||
last = { status: 'gesendet', url, mode: cfg.mode, pinged: Boolean(roleId) };
|
||||
return last;
|
||||
} catch (err) {
|
||||
const reason = (err as Error).message;
|
||||
log.error(`Go-Live-Post fehlgeschlagen: ${reason}`);
|
||||
repo.insertPost({
|
||||
streamId: stream.id, kind: art, platform: 'discord', mode: cfg.mode,
|
||||
status: 'fehlgeschlagen', title: stream.title, category: stream.category, detail: reason,
|
||||
});
|
||||
last = { status: 'fehlgeschlagen', reason };
|
||||
return last;
|
||||
}
|
||||
}
|
||||
|
||||
async function delayedRun(
|
||||
stream: StreamInfo, opts: { force?: boolean; immediate?: boolean; art?: PostArt },
|
||||
) {
|
||||
if (!opts.immediate && cfg.delaySeconds > 0) {
|
||||
log.info(`Stream erkannt - Ankuendigung in ${cfg.delaySeconds} s ` +
|
||||
'(Titel und Kategorie stehen erst kurz nach dem Start)');
|
||||
await wait(cfg.delaySeconds * 1000);
|
||||
}
|
||||
const fresh = await refresh(stream);
|
||||
return run(fresh, opts);
|
||||
}
|
||||
|
||||
return {
|
||||
announce(stream, opts = {}) {
|
||||
void delayedRun(stream, opts).catch((err: Error) => {
|
||||
log.error(`Ankuendigung abgebrochen: ${err.message}`);
|
||||
last = { status: 'fehlgeschlagen', reason: err.message };
|
||||
});
|
||||
},
|
||||
announceNow: (stream, opts = {}) => delayedRun(stream, { ...opts, immediate: true }),
|
||||
lastResult: () => last,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Textbausteine fuer die Go-Live-Ankuendigung.
|
||||
//
|
||||
// Warum mehrere: eine Ankuendigung, die jeden Donnerstag Wort fuer Wort
|
||||
// dieselbe ist, wird nach drei Wochen nicht mehr gelesen. Das ist kein
|
||||
// Algorithmus-Aberglaube, sondern gilt genauso fuer Menschen, die die
|
||||
// Nachricht in der Seitenleiste ueberfliegen.
|
||||
//
|
||||
// Die Auswahl ist nicht zufaellig, sondern haengt an der Stream-ID: derselbe
|
||||
// Stream bekommt bei einem Wiederholungsversuch denselben Text, und Tests
|
||||
// bekommen ein vorhersagbares Ergebnis. Zwei aufeinanderfolgende Streams
|
||||
// bekommen verschiedene IDs und damit fast immer verschiedene Texte.
|
||||
|
||||
/** Die Zeile ueber dem Embed. Hier steht der Ping, also faellt sie ins Auge. */
|
||||
export const OPENERS = [
|
||||
'Abdu ist live.',
|
||||
'Der Stream läuft.',
|
||||
'Es geht los.',
|
||||
'Abdu ist da.',
|
||||
'Ab jetzt live.',
|
||||
];
|
||||
|
||||
/** Die Zeile im Embed. Darf laenger sein, wird ruhiger gelesen. */
|
||||
export const INVITATIONS = [
|
||||
'Schau vorbei.',
|
||||
'Komm rein, es ist noch Platz.',
|
||||
'Jetzt einschalten.',
|
||||
'Der Chat ist offen.',
|
||||
'Bis gleich im Chat.',
|
||||
];
|
||||
|
||||
/**
|
||||
* Kleiner, stabiler Hash. Kein crypto: er muss nur gleichmaessig streuen und
|
||||
* ueber Neustarts hinweg dasselbe Ergebnis liefern.
|
||||
*/
|
||||
export function pick<T>(list: readonly T[], seed: string): T {
|
||||
if (list.length === 0) throw new Error('Keine Textbausteine hinterlegt.');
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return list[Math.abs(h) % list.length]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter ersetzen. Unbekannte Platzhalter bleiben stehen - so sieht man
|
||||
* im Ergebnis, dass einer falsch geschrieben ist, statt eine Luecke zu
|
||||
* bekommen, die nach Absicht aussieht.
|
||||
*/
|
||||
export function fill(template: string, values: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (all, key: string) =>
|
||||
key in values ? values[key]! : all);
|
||||
}
|
||||
+1021
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,53 @@
|
||||
// Tests fuer den Updater. Der Anlass fuer `looksLikeLogin` war ein echter
|
||||
// Fehlschlag: git.zeasy.dev verlangt instanzweit eine Anmeldung und antwortet
|
||||
// auf jede Adresse mit 303 auf /user/login. `irm ... | iex` folgte der
|
||||
// Umleitung und versuchte, die Anmeldeseite als PowerShell auszufuehren.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isNewer, looksLikeLogin, parseJsonLoose } from './update.ts';
|
||||
|
||||
test('vergleicht Versionen als Zahlen, nicht als Text', () => {
|
||||
// Der Fall, der bei Zeichenkettenvergleich falsch herauskaeme
|
||||
assert.equal(isNewer('2026.10.1', '2026.9.9'), true);
|
||||
assert.equal(isNewer('2026.9.9', '2026.10.1'), false);
|
||||
assert.equal(isNewer('0.5.1', '0.5.0'), true);
|
||||
assert.equal(isNewer('0.5.0', '0.5.0'), false);
|
||||
assert.equal(isNewer('0.10.0', '0.9.0'), true);
|
||||
});
|
||||
|
||||
test('fehlende Stellen zaehlen als Null', () => {
|
||||
assert.equal(isNewer('1.1', '1'), true);
|
||||
assert.equal(isNewer('1', '1.0.0'), false);
|
||||
});
|
||||
|
||||
test('erkennt eine Anmeldeseite am Inhaltstyp', () => {
|
||||
assert.equal(looksLikeLogin('text/html; charset=utf-8', Buffer.from('x')), true);
|
||||
assert.equal(looksLikeLogin('application/json', Buffer.from('{"version":"1"}')), false);
|
||||
assert.equal(looksLikeLogin('application/zip', Buffer.from([0x50, 0x4b, 3, 4])), false);
|
||||
});
|
||||
|
||||
test('erkennt sie auch, wenn der Inhaltstyp schweigt', () => {
|
||||
// Genau die Antwort, die git.zeasy.dev geliefert hat
|
||||
const gitea = Buffer.from(
|
||||
'<a href="/user/login?redirect_to=%2FZeasyAlex%2Fabdulive">See Other</a>.');
|
||||
assert.equal(looksLikeLogin(null, gitea), true);
|
||||
assert.equal(looksLikeLogin(null, Buffer.from('<!DOCTYPE html><html>')), true);
|
||||
});
|
||||
|
||||
test('haelt ein echtes Manifest nicht faelschlich fuer HTML', () => {
|
||||
const manifest = Buffer.from(JSON.stringify({
|
||||
version: '0.5.0', datei: 'abdulive-0.5.0-win-x64.zip', groesse: 119_000_000,
|
||||
sha256: 'a'.repeat(64),
|
||||
}));
|
||||
assert.equal(looksLikeLogin(null, manifest), false);
|
||||
});
|
||||
|
||||
test('ein Byte Order Mark macht das Manifest nicht unlesbar', () => {
|
||||
// Windows PowerShell 5.1 schreibt bei `Set-Content -Encoding utf8` eines,
|
||||
// und Notepad ebenso. JSON.parse verschluckt sich daran.
|
||||
const mitBom = '{"version":"0.5.0"}';
|
||||
assert.throws(() => JSON.parse(mitBom));
|
||||
assert.deepEqual(parseJsonLoose(mitBom), { version: '0.5.0' });
|
||||
assert.deepEqual(parseJsonLoose('{"a":1}'), { a: 1 });
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// Nach einer neuen Fassung sehen und sie bereitlegen.
|
||||
//
|
||||
// Bereitlegen, nicht einspielen: Windows sperrt die Dateien laufender
|
||||
// Programme, und mitten im Stream soll ohnehin nichts ausgetauscht werden.
|
||||
// Das neue Paket landet unter `pending\`; `launcher.ps1` tauscht es beim
|
||||
// naechsten Start aus, wenn nichts laeuft. Siehe ADR-0007.
|
||||
//
|
||||
// Die Adresse steht in der Konfiguration und hat bewusst KEINE Vorgabe. Ein
|
||||
// erfundener Standardwert sieht so lange richtig aus, bis jemand ihn aufruft
|
||||
// und eine Anmeldeseite als Skript ausgefuehrt bekommt.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { installRoot } from '#core/platform.ts';
|
||||
import { readZip } from '#core/zip.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
|
||||
type Manifest = {
|
||||
version: string;
|
||||
datei: string;
|
||||
groesse: number;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Versionen vergleichen. `2026.9.9` ist aelter als `2026.10.1` - ein
|
||||
* Zeichenkettenvergleich saehe das andersherum.
|
||||
*/
|
||||
export function isNewer(candidate: string, current: string): boolean {
|
||||
const parts = (v: string) => v.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p));
|
||||
const a = parts(candidate);
|
||||
const b = parts(current);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x === y) continue;
|
||||
if (typeof x === 'number' && typeof y === 'number') return x > y;
|
||||
return String(x) > String(y);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Antwort, die wie eine Anmeldeseite aussieht, ist keine Datei.
|
||||
*
|
||||
* Genau das ist einmal passiert: `git.zeasy.dev` ist komplett angemeldet-only
|
||||
* und antwortet auf jede Adresse mit 303 auf `/user/login`. Wer dem folgt,
|
||||
* bekommt HTML - und `iex` versucht, es als PowerShell auszufuehren.
|
||||
*/
|
||||
export function looksLikeLogin(contentType: string | null, body: Buffer): boolean {
|
||||
if (contentType?.includes('text/html')) return true;
|
||||
const head = body.subarray(0, 200).toString('utf8').toLowerCase();
|
||||
return head.includes('<!doctype html') || head.includes('<html') ||
|
||||
head.includes('/user/login');
|
||||
}
|
||||
|
||||
/** JSON.parse verschluckt sich an einem Byte Order Mark - Windows schreibt eines. */
|
||||
export function parseJsonLoose(text: string): unknown {
|
||||
return JSON.parse(text.replace(/^/, ''));
|
||||
}
|
||||
|
||||
async function localVersion(): Promise<string> {
|
||||
const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null);
|
||||
if (!raw) return '0';
|
||||
return (parseJsonLoose(raw) as { version?: string }).version ?? '0';
|
||||
}
|
||||
|
||||
/** Adresse und - falls hinterlegt - der Zugriffstoken. */
|
||||
function quelle(): { url: string; headers: Record<string, string> } {
|
||||
const url = (process.env['ABDULIVE_RELEASE_URL'] ?? loadConfig().update?.url ?? '').trim();
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'Keine Update-Adresse hinterlegt.\n\n' +
|
||||
' In config.yaml eintragen:\n' +
|
||||
' update:\n' +
|
||||
' url: "https://.../abdulive"\n\n' +
|
||||
' Dort muessen `abdulive.json` und die darin genannte ZIP-Datei liegen.\n' +
|
||||
' Gebaut werden beide mit: node scripts/build-package.ts');
|
||||
}
|
||||
const headers: Record<string, string> = { 'user-agent': 'abdulive-update' };
|
||||
const token = process.env['ABDULIVE_UPDATE_TOKEN'] ?? readSecrets().update?.token;
|
||||
// Gitea und Forgejo erwarten genau diese Form; ein Bearer-Token lehnen sie ab.
|
||||
if (token) headers['authorization'] = `token ${token}`;
|
||||
return { url: url.replace(/\/+$/, ''), headers };
|
||||
}
|
||||
|
||||
async function hole(url: string, headers: Record<string, string>): Promise<Buffer> {
|
||||
// Umleitungen NICHT automatisch folgen: eine Anmeldeseite ist genau das,
|
||||
// und ihr zu folgen liefert HTML statt einer Fehlermeldung.
|
||||
const res = await fetch(url, { headers, redirect: 'manual' });
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const ziel = res.headers.get('location') ?? '(ohne Angabe)';
|
||||
throw new Error(
|
||||
`${url}\n antwortet mit ${res.status} und leitet nach ${ziel} um.\n\n` +
|
||||
(ziel.includes('login')
|
||||
? ' Das ist eine Anmeldeseite - die Ablage ist nicht oeffentlich.\n' +
|
||||
' Entweder die Pakete an einen Ort ohne Anmeldung legen, oder einen\n' +
|
||||
' Lese-Token auf der Einrichtungsseite hinterlegen.'
|
||||
: ' Umleitungen werden hier absichtlich nicht verfolgt.'));
|
||||
}
|
||||
if (!res.ok) throw new Error(`${url}\n antwortet mit HTTP ${res.status}.`);
|
||||
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (looksLikeLogin(res.headers.get('content-type'), body)) {
|
||||
throw new Error(
|
||||
`${url}\n liefert eine HTML-Seite statt einer Datei.\n\n` +
|
||||
' Vermutlich eine Anmelde- oder Fehlerseite. Die Adresse im Browser\n' +
|
||||
' aufrufen zeigt, was dort wirklich steht.');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function checkForUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
const { url, headers } = quelle();
|
||||
const current = await localVersion();
|
||||
console.log(`Quelle: ${url}`);
|
||||
console.log(`Installiert: ${current}`);
|
||||
|
||||
const manifest = parseJsonLoose(
|
||||
(await hole(`${url}/abdulive.json`, headers)).toString('utf8')) as Manifest;
|
||||
console.log(`Angeboten: ${manifest.version}`);
|
||||
|
||||
if (!opts.force && !isNewer(manifest.version, current)) {
|
||||
console.log('\nNichts zu tun.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = path.join(installRoot, 'pending');
|
||||
// Ein Rest von einem abgebrochenen Versuch darf nicht mit eingespielt
|
||||
// werden - also erst weg damit.
|
||||
await fs.rm(pending, { recursive: true, force: true });
|
||||
|
||||
process.stdout.write(`\nLade ${manifest.datei} ... `);
|
||||
const zip = await hole(`${url}/${manifest.datei}`, headers);
|
||||
console.log(`${(zip.length / 1e6).toFixed(0)} MB`);
|
||||
|
||||
const sum = crypto.createHash('sha256').update(zip).digest('hex');
|
||||
if (sum !== manifest.sha256.toLowerCase()) {
|
||||
throw new Error(
|
||||
`Pruefsumme stimmt nicht.\n erwartet ${manifest.sha256}\n bekommen ${sum}\n` +
|
||||
'Das Paket wird nicht eingespielt.');
|
||||
}
|
||||
console.log('Pruefsumme stimmt.');
|
||||
|
||||
let count = 0;
|
||||
for (const entry of readZip(zip)) {
|
||||
if (entry.isDirectory) continue;
|
||||
const target = path.join(pending, entry.name);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, entry.data());
|
||||
count++;
|
||||
}
|
||||
console.log(`${count} Dateien nach ${pending} gelegt.`);
|
||||
console.log('\nEingespielt wird beim naechsten Start - waehrend eines Streams');
|
||||
console.log('wird nichts ausgetauscht. Sofort uebernehmen:');
|
||||
console.log(' Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# @abdulive/hub
|
||||
|
||||
Der zentrale Dienst. Alles, was nur Internet braucht: Twitch-Chat,
|
||||
Zuschauerzahlen, der Discord-Rollen-Knopf, die Go-Live-Ankuendigung und die
|
||||
Overlay-Seiten fuer OBS.
|
||||
|
||||
Laeuft im LXC, nicht auf Abdus Rechner - damit der Ping auch dann rausgeht,
|
||||
wenn der Stream-PC gerade nicht laeuft. Der Schnitt zwischen Hub und Ingest
|
||||
steht in [ADR-0008](../../docs/decisions/ADR-0008-geteilter-betrieb.md).
|
||||
|
||||
```
|
||||
abdulive hub
|
||||
```
|
||||
|
||||
Bedienung ueber die Weboberflaeche des Overlay-Servers: Uebersicht auf `/`,
|
||||
Einrichtung auf `/setup`. Beides hinter einem Passwort
|
||||
(`abdulive web:passwort`).
|
||||
|
||||
Nicht hier drin und auch nicht hierher zu holen: OBS, der Wiedergabepuffer,
|
||||
ffmpeg und whisper. Die bleiben in `abdulive ingest`.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAudioAblage } from './audio-ablage.ts';
|
||||
|
||||
const wav = (n: number): Buffer => Buffer.alloc(44 + n);
|
||||
|
||||
test('abgelegter Ton ist unter seiner ID abholbar', () => {
|
||||
const a = createAudioAblage();
|
||||
a.ablegen('x', wav(1));
|
||||
assert.equal(a.holen('x')?.length, 45);
|
||||
assert.equal(a.holen('y'), undefined);
|
||||
});
|
||||
|
||||
test('laeuft sie voll, faellt das Aelteste heraus', () => {
|
||||
const a = createAudioAblage({ maximal: 2 });
|
||||
a.ablegen('1', wav(1));
|
||||
a.ablegen('2', wav(2));
|
||||
a.ablegen('3', wav(3));
|
||||
assert.equal(a.holen('1'), undefined);
|
||||
assert.ok(a.holen('2'));
|
||||
assert.ok(a.holen('3'));
|
||||
});
|
||||
|
||||
test('abgelaufener Ton ist weg', async () => {
|
||||
const a = createAudioAblage({ haltbarMs: 5 });
|
||||
a.ablegen('x', wav(1));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(a.holen('x'), undefined);
|
||||
});
|
||||
|
||||
test('leeren wirft alles weg - fuer den Panikschalter', () => {
|
||||
const a = createAudioAblage();
|
||||
a.ablegen('x', wav(1));
|
||||
a.leeren();
|
||||
assert.equal(a.holen('x'), undefined);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Fertig gesprochene Nachrichten, bis die Browser-Quelle sie abholt.
|
||||
//
|
||||
// Warum nicht direkt im Ereignis: eine WAV von 200 Zeichen hat gut 500 KB.
|
||||
// Der Ereignisstrom geht an alle Browser-Sources, auch an Chat und
|
||||
// Statusleiste - die sollen das nicht mitschleppen. Also geht nur die ID
|
||||
// hinaus, und `/tts` holt sich den Ton einzeln.
|
||||
//
|
||||
// Bewusst im Arbeitsspeicher und kurzlebig: niemand braucht eine vorgelesene
|
||||
// Nachricht von gestern, und fremder Text soll nicht auf der Platte liegen.
|
||||
|
||||
export type AudioAblage = {
|
||||
ablegen: (id: string, wav: Buffer) => void;
|
||||
holen: (id: string) => Buffer | undefined;
|
||||
leeren: () => void;
|
||||
};
|
||||
|
||||
export function createAudioAblage(opts: { maximal?: number; haltbarMs?: number } = {}): AudioAblage {
|
||||
const maximal = opts.maximal ?? 20;
|
||||
const haltbarMs = opts.haltbarMs ?? 10 * 60_000;
|
||||
// Map haelt die Einfuegereihenfolge; das Aelteste steht vorn.
|
||||
const ablage = new Map<string, { wav: Buffer; seit: number }>();
|
||||
|
||||
const aufraeumen = (jetzt: number): void => {
|
||||
for (const [id, e] of ablage) if (jetzt - e.seit > haltbarMs) ablage.delete(id);
|
||||
while (ablage.size > maximal) {
|
||||
const aeltestes = ablage.keys().next().value;
|
||||
if (aeltestes === undefined) break;
|
||||
ablage.delete(aeltestes);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ablegen(id, wav) {
|
||||
ablage.set(id, { wav, seit: Date.now() });
|
||||
aufraeumen(Date.now());
|
||||
},
|
||||
holen(id) {
|
||||
aufraeumen(Date.now());
|
||||
return ablage.get(id)?.wav;
|
||||
},
|
||||
leeren: () => ablage.clear(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
// 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 { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
|
||||
import {
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
||||
} from './rewards.ts';
|
||||
import { createAudioAblage } from './audio-ablage.ts';
|
||||
import { createLiegestuetze } from './liegestuetze.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();
|
||||
const liegestuetze = createLiegestuetze(db);
|
||||
// Gleich zu Beginn in den Bus, sonst zeigt eine frisch verbundene
|
||||
// Browser-Source 0, bis die naechste Einloesung kommt.
|
||||
bus.publish({ kind: 'liegestuetze', ...liegestuetze.stand() });
|
||||
|
||||
/**
|
||||
* Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
|
||||
* (CANCELED). Geht nur bei Belohnungen, die das Toolkit selbst angelegt hat
|
||||
* (`abdulive rewards:anlegen`) - sonst lehnt Twitch ab, und es bleibt bei
|
||||
* einer Warnung.
|
||||
*/
|
||||
const einloesungSetzen = async (
|
||||
rewardId: string, redemptionId: string, status: 'FULFILLED' | 'CANCELED',
|
||||
): Promise<void> => {
|
||||
const was = status === 'CANCELED' ? 'Punkte nicht erstattet' : 'Einloesung nicht abgeschlossen';
|
||||
if (!state.broadcasterId) return;
|
||||
if (!helix.hasScope('channel:manage:redemptions')) {
|
||||
log.warn(`${was}: dem verbundenen Konto fehlt ` +
|
||||
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, status);
|
||||
} catch (err) {
|
||||
log.warn(`${was}: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
const erstatten = (rewardId: string, redemptionId: string): Promise<void> =>
|
||||
einloesungSetzen(rewardId, redemptionId, 'CANCELED');
|
||||
|
||||
// ---- Vorlesen. Die Stimme entsteht hier im Hub (Piper), nicht im Browser:
|
||||
// dort haengt sie am Rechner, der streamt, und kam in OBS amerikanisch.
|
||||
const stimme = {
|
||||
model: stimmenPfad(cfg.rewards.tts.stimme),
|
||||
...(typeof cfg.rewards.tts.sprecher === 'number' ? { speaker: cfg.rewards.tts.sprecher } : {}),
|
||||
};
|
||||
const audio = createAudioAblage();
|
||||
const stimmeFehlt = piperFehlt(stimme);
|
||||
if (stimmeFehlt.length) {
|
||||
log.warn(`Vorlesen ohne Stimme - es fehlt: ${stimmeFehlt.join(', ')}. ` +
|
||||
'Einrichten mit scripts/install-piper.sh. Bis dahin wird nur angezeigt.');
|
||||
}
|
||||
|
||||
const vorlesen = async (
|
||||
id: string, name: string, text: string,
|
||||
/** Fehlt beim Testknopf - dann gibt es bei Twitch nichts abzuschliessen. */
|
||||
einloesung?: { rewardId: string; redemptionId: string },
|
||||
): Promise<void> => {
|
||||
let mitTon = false;
|
||||
try {
|
||||
audio.ablegen(id, await sprechen(text, stimme));
|
||||
mitTon = true;
|
||||
} catch (err) {
|
||||
// Lieber stumm anzeigen als gar nicht: wer Punkte ausgegeben hat, soll
|
||||
// seine Nachricht wenigstens im Bild sehen.
|
||||
log.warn(`Nicht gesprochen, nur angezeigt: ${(err as Error).message}`);
|
||||
}
|
||||
bus.publish({ kind: 'tts', id, name, text, audio: mitTon });
|
||||
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
|
||||
// Auch ohne Ton abgeschlossen: angezeigt wurde es, und eine Einloesung,
|
||||
// die ewig in Twitchs Warteschlange haengt, raeumt niemand auf.
|
||||
if (einloesung) void einloesungSetzen(einloesung.rewardId, einloesung.redemptionId, 'FULFILLED');
|
||||
};
|
||||
|
||||
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 });
|
||||
if (ent.aktion === 'liegestuetze') {
|
||||
const dazu = ent.plan.anzahl ?? 5;
|
||||
bus.publish({ kind: 'liegestuetze', ...liegestuetze.aendern(dazu), dazu, name: e.userName });
|
||||
}
|
||||
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
|
||||
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
|
||||
break;
|
||||
|
||||
case 'vorlesen':
|
||||
void vorlesen(ulid(), e.userName, ent.text, { rewardId: e.rewardId, redemptionId: e.id });
|
||||
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,
|
||||
liegestuetze: liegestuetze.stand(),
|
||||
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
|
||||
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;
|
||||
void vorlesen(w.id, w.name, w.text, { rewardId: w.rewardId, redemptionId: w.redemptionId });
|
||||
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;
|
||||
},
|
||||
probe: (name, text) => { void vorlesen(ulid(), name, text); },
|
||||
audio: (id) => audio.holen(id),
|
||||
still: () => {
|
||||
bus.publish({ kind: 'still' });
|
||||
audio.leeren();
|
||||
const alle = warteschlange.leeren();
|
||||
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
||||
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
|
||||
},
|
||||
},
|
||||
liegestuetze: {
|
||||
stand: () => liegestuetze.stand(),
|
||||
aendern: (n) => {
|
||||
const neu = liegestuetze.aendern(n);
|
||||
bus.publish({ kind: 'liegestuetze', ...neu });
|
||||
log.info(`Liegestuetze von Hand ${n > 0 ? '+' : ''}${n} -> ` +
|
||||
`${neu.offen} offen, ${neu.gemacht} gemacht`);
|
||||
return neu;
|
||||
},
|
||||
setzen: (n) => {
|
||||
const neu = liegestuetze.setzen(n);
|
||||
bus.publish({ kind: 'liegestuetze', ...neu });
|
||||
log.info(`Liegestuetze von Hand gesetzt -> ${neu.offen} offen`);
|
||||
return neu;
|
||||
},
|
||||
gemachtSetzen: (n) => {
|
||||
const neu = liegestuetze.gemachtSetzen(n);
|
||||
bus.publish({ kind: 'liegestuetze', ...neu });
|
||||
log.info(`Liegestuetze gemacht von Hand gesetzt -> ${neu.gemacht}`);
|
||||
return neu;
|
||||
},
|
||||
},
|
||||
});
|
||||
void server;
|
||||
|
||||
// ---- Zuschauerzahlen und Stream-Sitzungen
|
||||
void pollStream(cfg, repo, state, bus, abort.signal, (stream) => {
|
||||
announcer.announce(stream);
|
||||
// Nur bei einem wirklich neuen Stream, nicht beim Neustart des Hubs
|
||||
// mitten im Stream: dann wird die Sitzung uebernommen, nicht gestartet.
|
||||
bus.publish({ kind: 'liegestuetze', ...liegestuetze.neuerStream() });
|
||||
});
|
||||
|
||||
// ---- 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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Der Zaehler haelt fest, was Zuschauer mit Kanalpunkten bezahlt haben und
|
||||
// was Abdu davon geschafft hat. Er darf weder beim Neustart vergessen noch
|
||||
// ins Negative laufen, und eine Korrektur ist keine Liegestuetze.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { migrate } from '#db/index.ts';
|
||||
import { createLiegestuetze, HOECHSTENS } from './liegestuetze.ts';
|
||||
import { entscheiden, type RewardsConfig } from './rewards.ts';
|
||||
import { zahlAus } from '../../overlay-server/src/server.ts';
|
||||
|
||||
function datei(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-lz-'));
|
||||
return path.join(dir, 't.db');
|
||||
}
|
||||
|
||||
function oeffnen(file: string): DatabaseSync {
|
||||
const db = new DatabaseSync(file);
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
test('Liegestuetze: zaehlt hoch und herunter, nie unter 0', () => {
|
||||
const lz = createLiegestuetze(oeffnen(datei()));
|
||||
assert.deepEqual(lz.stand(), { offen: 0, gemacht: 0 });
|
||||
assert.equal(lz.aendern(10).offen, 10);
|
||||
assert.equal(lz.aendern(10).offen, 20);
|
||||
assert.equal(lz.aendern(-5).offen, 15);
|
||||
assert.equal(lz.aendern(-100).offen, 0);
|
||||
assert.equal(lz.setzen(42).offen, 42);
|
||||
assert.equal(lz.setzen(-3).offen, 0);
|
||||
assert.equal(lz.setzen(10_000_000).offen, HOECHSTENS);
|
||||
});
|
||||
|
||||
test('Liegestuetze: Herunterzaehlen ist gemacht, Setzen nicht', () => {
|
||||
const lz = createLiegestuetze(oeffnen(datei()));
|
||||
lz.aendern(20);
|
||||
assert.deepEqual(lz.aendern(-5), { offen: 15, gemacht: 5 });
|
||||
// Nur was wirklich offen war, kann gemacht sein.
|
||||
assert.deepEqual(lz.aendern(-100), { offen: 0, gemacht: 20 });
|
||||
lz.aendern(10);
|
||||
assert.deepEqual(lz.setzen(3), { offen: 3, gemacht: 20 }, 'Korrektur zaehlt nicht');
|
||||
assert.deepEqual(lz.gemachtSetzen(7), { offen: 3, gemacht: 7 });
|
||||
});
|
||||
|
||||
test('Liegestuetze: neuer Stream setzt gemacht zurueck, offen bleibt', () => {
|
||||
const lz = createLiegestuetze(oeffnen(datei()));
|
||||
lz.aendern(30);
|
||||
lz.aendern(-10);
|
||||
assert.deepEqual(lz.neuerStream(), { offen: 20, gemacht: 0 });
|
||||
});
|
||||
|
||||
test('Liegestuetze: der Stand ueberlebt einen Neustart', () => {
|
||||
const file = datei();
|
||||
const erst = oeffnen(file);
|
||||
const lz = createLiegestuetze(erst);
|
||||
lz.aendern(30);
|
||||
lz.aendern(-5);
|
||||
erst.close();
|
||||
|
||||
assert.deepEqual(createLiegestuetze(oeffnen(file)).stand(), { offen: 25, gemacht: 5 });
|
||||
});
|
||||
|
||||
test('Belohnung mit Aktion liegestuetze wird ausgefuehrt und traegt die Anzahl', () => {
|
||||
const cfg: RewardsConfig = {
|
||||
enabled: true,
|
||||
belohnungen: [{ titel: '5 Liegestütze', aktion: 'liegestuetze', anzahl: 5 }],
|
||||
tts: { freigabe: true, maxZeichen: 200, blockliste: [], proPersonProStunde: 3 },
|
||||
};
|
||||
const ent = entscheiden({
|
||||
id: 'r1', rewardId: 'x', rewardTitle: '5 liegestütze', userId: 'u1', userName: 'Kevin',
|
||||
text: '', kosten: 2000, ts: '2026-10-04T20:00:00Z',
|
||||
}, cfg);
|
||||
assert.equal(ent.art, 'ausfuehren');
|
||||
assert.ok(ent.art === 'ausfuehren' && ent.aktion === 'liegestuetze' && ent.plan.anzahl === 5);
|
||||
});
|
||||
|
||||
test('zahlAus: nur ganze Zahlen, alles andere ist null', () => {
|
||||
assert.equal(zahlAus('10'), 10);
|
||||
assert.equal(zahlAus(' -5 '), -5);
|
||||
assert.equal(zahlAus('+3'), 3);
|
||||
for (const x of ['', 'abc', '1.5', '1e3', '9999999', null, undefined]) {
|
||||
assert.equal(zahlAus(x), null, String(x));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Liegestuetze aus Kanalpunkten: was offen ist und was in diesem Stream
|
||||
// schon gemacht wurde.
|
||||
//
|
||||
// Eine Einloesung zaehlt `offen` hoch. Abdu zaehlt auf der Uebersichtsseite
|
||||
// herunter, wenn er sie gemacht hat - das wandert nach `gemacht`. Setzen ist
|
||||
// dagegen eine Korrektur und zaehlt nicht als gemacht: wer einen Tippfehler
|
||||
// behebt, hat dabei keine Liegestuetze gemacht.
|
||||
//
|
||||
// Beides liegt in der Datenbank: was die Zuschauer bezahlt haben, darf ein
|
||||
// Neustart nicht loeschen. `gemacht` beginnt mit jedem neuen Stream bei 0.
|
||||
|
||||
import type { Db } from '#db/index.ts';
|
||||
|
||||
/** Mehr glaubt niemand, und ein Tippfehler mit sechs Nullen soll nicht im Bild stehen. */
|
||||
export const HOECHSTENS = 99_999;
|
||||
|
||||
export type LiegestuetzStand = { offen: number; gemacht: number };
|
||||
|
||||
export type Liegestuetze = {
|
||||
stand: () => LiegestuetzStand;
|
||||
/**
|
||||
* Zaehlt `offen` um `n` hoch oder (negativ) herunter, nie unter 0. Was
|
||||
* dabei wirklich abgezogen wurde, kommt zu `gemacht`.
|
||||
*/
|
||||
aendern: (n: number) => LiegestuetzStand;
|
||||
/** Setzt `offen` fest. Korrektur, zaehlt nicht als gemacht. */
|
||||
setzen: (n: number) => LiegestuetzStand;
|
||||
/** Setzt `gemacht` fest. Korrektur. */
|
||||
gemachtSetzen: (n: number) => LiegestuetzStand;
|
||||
/** Neuer Stream: `gemacht` auf 0, `offen` bleibt. */
|
||||
neuerStream: () => LiegestuetzStand;
|
||||
};
|
||||
|
||||
export function createLiegestuetze(db: Db, name = 'liegestuetze'): Liegestuetze {
|
||||
const lesen = db.prepare('SELECT wert FROM zaehler WHERE name = ?');
|
||||
const schreiben = db.prepare(`
|
||||
INSERT INTO zaehler (name, wert, geaendert) VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert
|
||||
`);
|
||||
const OFFEN = name;
|
||||
const GEMACHT = `${name}-gemacht`;
|
||||
|
||||
const wert = (n: string): number => (lesen.get(n) as { wert: number } | undefined)?.wert ?? 0;
|
||||
const schreibe = (n: string, w: number): number => {
|
||||
const sauber = Math.min(HOECHSTENS, Math.max(0, Math.trunc(w)));
|
||||
schreiben.run(n, sauber, new Date().toISOString());
|
||||
return sauber;
|
||||
};
|
||||
const stand = (): LiegestuetzStand => ({ offen: wert(OFFEN), gemacht: wert(GEMACHT) });
|
||||
|
||||
return {
|
||||
stand,
|
||||
aendern(n) {
|
||||
const vorher = wert(OFFEN);
|
||||
const nachher = schreibe(OFFEN, vorher + n);
|
||||
if (nachher < vorher) schreibe(GEMACHT, wert(GEMACHT) + (vorher - nachher));
|
||||
return stand();
|
||||
},
|
||||
setzen(n) { schreibe(OFFEN, n); return stand(); },
|
||||
gemachtSetzen(n) { schreibe(GEMACHT, n); return stand(); },
|
||||
neuerStream() { schreibe(GEMACHT, 0); return stand(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Hier entscheidet sich, ob fremder Text im Stream vorgelesen wird. Was hier
|
||||
// durchrutscht, hoert das Publikum - und die Aufzeichnung behaelt es.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden, fuerFilter, planFuer,
|
||||
wortfilterTreffer, type Einloesung, type RewardsConfig,
|
||||
} from './rewards.ts';
|
||||
|
||||
// ---- Wortfilter. Vorgelesen wird ohne Freigabe, also muss er halten.
|
||||
|
||||
const LISTE = ['hitler', 'nazi'];
|
||||
|
||||
test('Wortfilter: Leetspeak, Punkte und gezogene Buchstaben helfen nicht', () => {
|
||||
for (const t of ['H1tl3r war super', 'h.i.t.l.e.r', 'H I T L E R', 'hiiiitlerrr', 'HİTLER']) {
|
||||
assert.equal(wortfilterTreffer(t, LISTE), 'hitler', t);
|
||||
}
|
||||
});
|
||||
|
||||
test('Wortfilter: kurze Eintraege nur am Wortanfang, sonst trifft es Harmloses', () => {
|
||||
assert.equal(wortfilterTreffer('ich kann azubi werden', LISTE), undefined);
|
||||
assert.equal(wortfilterTreffer('Gymnasium', LISTE), undefined);
|
||||
assert.equal(wortfilterTreffer('alles Nazis hier', LISTE), 'nazi');
|
||||
assert.equal(wortfilterTreffer('N4Z1', LISTE), 'nazi');
|
||||
});
|
||||
|
||||
test('Wortfilter: "www" wird zu "w" und darf nur als ganzes Wort treffen', () => {
|
||||
// So im Stream passiert: jede Nachricht mit "wie" oder "weg" flog raus.
|
||||
const liste = ['www', 'http'];
|
||||
assert.equal(wortfilterTreffer('wie kann es sein, dass ich nicht mod bin?!', liste), undefined);
|
||||
assert.equal(wortfilterTreffer('Ladiesman hat einen weg', liste), undefined);
|
||||
assert.equal(wortfilterTreffer('geh auf www.example.de', liste), 'www');
|
||||
assert.equal(wortfilterTreffer('https://example.de', liste), 'http');
|
||||
});
|
||||
|
||||
test('Wortfilter: normaler Text geht durch', () => {
|
||||
assert.equal(wortfilterTreffer('Grüße an Abdu, geiler Stream!', LISTE), undefined);
|
||||
});
|
||||
|
||||
test('fuerFilter: Umlaute und ß werden zu dem, was man hoert', () => {
|
||||
assert.equal(fuerFilter('Größe', false), 'grose');
|
||||
assert.equal(fuerFilter('a-b c', true), 'a b c');
|
||||
});
|
||||
|
||||
const CFG: RewardsConfig = {
|
||||
enabled: true,
|
||||
belohnungen: [
|
||||
{ titel: 'Konfetti', aktion: 'konfetti' },
|
||||
{ titel: 'Vorlesen', aktion: 'tts' },
|
||||
{ id: 'abc-123', aktion: 'emote-regen' },
|
||||
],
|
||||
tts: { freigabe: true, maxZeichen: 200, blockliste: ['heilhitler'], proPersonProStunde: 3 },
|
||||
};
|
||||
|
||||
function einloesung(over: Partial<Einloesung> = {}): Einloesung {
|
||||
return {
|
||||
id: 'r1', rewardId: 'xyz', rewardTitle: 'Vorlesen', userId: 'u1', userName: 'Kevin',
|
||||
text: 'moin zusammen', kosten: 500, ts: '2026-09-17T20:00:00Z', ...over,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zuordnung
|
||||
|
||||
test('die ID gewinnt gegen den Titel', () => {
|
||||
// Der Titel kann sich aendern, die ID nicht. Wer beides hinterlegt, meint
|
||||
// die ID.
|
||||
const p = planFuer({ rewardId: 'abc-123', rewardTitle: 'Konfetti' }, CFG.belohnungen);
|
||||
assert.equal(p?.aktion, 'emote-regen');
|
||||
});
|
||||
|
||||
test('der Titel wird ohne Ruecksicht auf Gross-/Kleinschreibung gefunden', () => {
|
||||
assert.equal(planFuer({ rewardId: 'x', rewardTitle: 'kOnFeTtI' }, CFG.belohnungen)?.aktion,
|
||||
'konfetti');
|
||||
});
|
||||
|
||||
test('eine unbekannte Belohnung ist kein Fehler', () => {
|
||||
// Abdu darf Belohnungen anlegen, die mit dem Toolkit nichts zu tun haben.
|
||||
const e = entscheiden(einloesung({ rewardTitle: 'Nachricht anheften' }), CFG);
|
||||
assert.equal(e.art, 'unbekannt');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Einfache Aktionen
|
||||
|
||||
test('Konfetti laeuft sofort, ohne Freigabe', () => {
|
||||
const e = entscheiden(einloesung({ rewardTitle: 'Konfetti', text: '' }), CFG);
|
||||
assert.deepEqual(e, { art: 'ausfuehren', aktion: 'konfetti', plan: CFG.belohnungen[0] });
|
||||
});
|
||||
|
||||
test('der Kill-Switch haelt alles an', () => {
|
||||
const e = entscheiden(einloesung({ rewardTitle: 'Konfetti' }), { ...CFG, enabled: false });
|
||||
assert.equal(e.art, 'abgelehnt');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- TTS
|
||||
|
||||
test('mit Freigabe geht Text in die Warteschlange, nicht ins Bild', () => {
|
||||
const e = entscheiden(einloesung(), CFG);
|
||||
assert.deepEqual(e, { art: 'warteschlange', text: 'moin zusammen' });
|
||||
});
|
||||
|
||||
test('ohne Freigabe wird sofort vorgelesen', () => {
|
||||
const e = entscheiden(einloesung(), { ...CFG, tts: { ...CFG.tts, freigabe: false } });
|
||||
assert.deepEqual(e, { art: 'vorlesen', text: 'moin zusammen' });
|
||||
});
|
||||
|
||||
test('die Einstellung an der Belohnung schlaegt die allgemeine', () => {
|
||||
const cfg: RewardsConfig = {
|
||||
...CFG,
|
||||
belohnungen: [{ titel: 'Vorlesen', aktion: 'tts', freigabe: false }],
|
||||
tts: { ...CFG.tts, freigabe: true },
|
||||
};
|
||||
assert.equal(entscheiden(einloesung(), cfg).art, 'vorlesen');
|
||||
});
|
||||
|
||||
test('zu langer Text wird abgelehnt, nicht abgeschnitten', () => {
|
||||
// Abgeschnitten waere schlimmer: dann wird ein halber Satz vorgelesen, und
|
||||
// niemand weiss, ob der Rest Absicht war.
|
||||
const e = entscheiden(einloesung({ text: 'a'.repeat(201) }), CFG);
|
||||
assert.equal(e.art, 'abgelehnt');
|
||||
assert.match((e as { grund: string }).grund, /zu lang/);
|
||||
});
|
||||
|
||||
test('leerer Text wird abgelehnt', () => {
|
||||
assert.equal(entscheiden(einloesung({ text: ' ' }), CFG).art, 'abgelehnt');
|
||||
});
|
||||
|
||||
test('der Wortfilter greift unabhaengig von Gross-/Kleinschreibung', () => {
|
||||
const e = entscheiden(einloesung({ text: 'guck mal HeilHitler lol' }), CFG);
|
||||
assert.equal(e.art, 'abgelehnt');
|
||||
assert.equal((e as { grund: string }).grund, 'Wortfilter');
|
||||
});
|
||||
|
||||
test('der Ablehnungsgrund verraet den gefilterten Text nicht', () => {
|
||||
// Der Grund landet im Protokoll und moeglicherweise im Chat. Er darf nicht
|
||||
// wiederholen, was gerade herausgefiltert wurde.
|
||||
const e = entscheiden(einloesung({ text: 'heilhitler' }), CFG);
|
||||
assert.equal((e as { grund: string }).grund.includes('heilhitler'), false);
|
||||
});
|
||||
|
||||
test('wer zu oft einloest, wird gebremst', () => {
|
||||
assert.equal(entscheiden(einloesung(), CFG, 2).art, 'warteschlange');
|
||||
assert.equal(entscheiden(einloesung(), CFG, 3).art, 'abgelehnt');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Warteschlange
|
||||
|
||||
test('eingereiht, entnommen, weg', () => {
|
||||
const w = createWarteschlange();
|
||||
w.einreihen({ id: 'a', name: 'Kevin', text: 'moin', rewardId: 'r', redemptionId: 'x', seit: '' });
|
||||
assert.equal(w.liste().length, 1);
|
||||
assert.equal(w.entnehmen('a')?.text, 'moin');
|
||||
assert.equal(w.liste().length, 0);
|
||||
assert.equal(w.entnehmen('a'), undefined, 'zweimal entnehmen geht nicht');
|
||||
});
|
||||
|
||||
test('eine volle Warteschlange wirft das Aelteste raus', () => {
|
||||
// Eine Schlange, die bei einem Ansturm auf 300 waechst, arbeitet niemand ab.
|
||||
const w = createWarteschlange(3);
|
||||
for (const id of ['a', 'b', 'c', 'd']) {
|
||||
w.einreihen({ id, name: 'x', text: id, rewardId: 'r', redemptionId: id, seit: '' });
|
||||
}
|
||||
assert.deepEqual(w.liste().map((x) => x.id), ['b', 'c', 'd']);
|
||||
});
|
||||
|
||||
test('leeren gibt alles zurueck, damit die Punkte erstattet werden koennen', () => {
|
||||
const w = createWarteschlange();
|
||||
w.einreihen({ id: 'a', name: 'x', text: '1', rewardId: 'r', redemptionId: 'a', seit: '' });
|
||||
w.einreihen({ id: 'b', name: 'y', text: '2', rewardId: 'r', redemptionId: 'b', seit: '' });
|
||||
assert.equal(w.leeren().length, 2);
|
||||
assert.equal(w.liste().length, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Drossel
|
||||
|
||||
test('die Drossel zaehlt nur das gleitende Fenster', () => {
|
||||
const d = createDrossel(3_600_000);
|
||||
const t = Date.parse('2026-09-17T20:00:00Z');
|
||||
d.merken('u1', t);
|
||||
d.merken('u1', t + 60_000);
|
||||
assert.equal(d.zaehlen('u1', t + 120_000), 2);
|
||||
// Zwei Stunden spaeter ist beides aus dem Fenster gefallen.
|
||||
assert.equal(d.zaehlen('u1', t + 2 * 3_600_000), 0);
|
||||
});
|
||||
|
||||
test('die Drossel trennt nach Person', () => {
|
||||
const d = createDrossel();
|
||||
d.merken('u1');
|
||||
assert.equal(d.zaehlen('u2'), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- EventSub
|
||||
|
||||
test('das EventSub-Ereignis wird in unsere Form gebracht', () => {
|
||||
const e = ausEventSub({
|
||||
id: 'red-1',
|
||||
user_id: '42',
|
||||
user_name: 'Kevin',
|
||||
user_input: 'lies das vor',
|
||||
redeemed_at: '2026-09-17T20:00:00Z',
|
||||
reward: { id: 'rw-9', title: 'Vorlesen', cost: 500 },
|
||||
});
|
||||
assert.equal(e?.id, 'red-1');
|
||||
assert.equal(e?.rewardId, 'rw-9');
|
||||
assert.equal(e?.rewardTitle, 'Vorlesen');
|
||||
assert.equal(e?.text, 'lies das vor');
|
||||
assert.equal(e?.kosten, 500);
|
||||
});
|
||||
|
||||
test('eine Belohnung ohne Texteingabe hat einen leeren Text, nicht "undefined"', () => {
|
||||
// Ohne das stuende im Bild woertlich "undefined".
|
||||
const e = ausEventSub({ id: 'x', user_id: '1', reward: { id: 'r' } });
|
||||
assert.equal(e?.text, '');
|
||||
assert.equal(e?.userName, '');
|
||||
});
|
||||
|
||||
test('unvollstaendige Ereignisse werden verworfen', () => {
|
||||
assert.equal(ausEventSub({}), null);
|
||||
assert.equal(ausEventSub({ id: 'x' }), null);
|
||||
assert.equal(ausEventSub({ id: 'x', user_id: '1' }), null, 'ohne Belohnung keine Aktion');
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
// Was bei einer Kanalpunkt-Einloesung passiert.
|
||||
//
|
||||
// Der Weg: EventSub meldet die Einloesung -> hier wird sie einer Aktion aus
|
||||
// der Konfiguration zugeordnet -> die Aktion laeuft, oder sie landet in der
|
||||
// Warteschlange und wartet auf Abdus Klick.
|
||||
//
|
||||
// Zur Zuordnung: Belohnungen werden ueber ihren Titel gefunden, wahlweise
|
||||
// ueber die ID. Der Titel ist lesbar und laesst sich in der Konfiguration
|
||||
// wiedererkennen, kann sich aber aendern; die ID ist stabil, aber
|
||||
// nichtssagend. Deshalb beides, und die ID gewinnt.
|
||||
//
|
||||
// Warum TTS eine Warteschlange hat, steht in docs/projects/chat-control.md:
|
||||
// Kanalpunkte sammeln sich beim Zugucken von allein an und sind damit kaum
|
||||
// eine Huerde. Wer beliebigen Fremdtext ungeprueft vorlesen laesst, hat eine
|
||||
// Vorlesefunktion fuer Fremde im eigenen Stream.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
|
||||
const log = logger('rewards');
|
||||
|
||||
/** Eine Einloesung, so weit sie hier gebraucht wird. */
|
||||
export type Einloesung = {
|
||||
id: string;
|
||||
rewardId: string;
|
||||
rewardTitle: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
/** Der eingegebene Text. Leer, wenn die Belohnung keinen verlangt. */
|
||||
text: string;
|
||||
kosten: number;
|
||||
ts: string;
|
||||
};
|
||||
|
||||
export type AktionsArt =
|
||||
| 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
|
||||
|
||||
export type BelohnungsPlan = {
|
||||
/** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */
|
||||
titel?: string;
|
||||
/** Stabile Belohnungs-ID. Gewinnt gegen den Titel. */
|
||||
id?: string;
|
||||
aktion: AktionsArt;
|
||||
/** Nur bei `tts`: ohne Freigabe wird sofort vorgelesen. */
|
||||
freigabe?: boolean;
|
||||
/** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */
|
||||
maxZeichen?: number;
|
||||
/** Nur bei `liegestuetze`: so viele kommen je Einloesung dazu. Vorgabe 5. */
|
||||
anzahl?: number;
|
||||
};
|
||||
|
||||
export type RewardsConfig = {
|
||||
enabled: boolean;
|
||||
belohnungen: BelohnungsPlan[];
|
||||
tts: {
|
||||
/** Vorgabe fuer `freigabe`, wenn eine Belohnung nichts anderes sagt. */
|
||||
freigabe: boolean;
|
||||
maxZeichen: number;
|
||||
/** Woerter, die den Text ohne Nachfrage ablehnen. */
|
||||
blockliste: string[];
|
||||
/** So viele Einloesungen je Person und Stunde. */
|
||||
proPersonProStunde: number;
|
||||
};
|
||||
};
|
||||
|
||||
/** Was mit einer Einloesung geschehen soll. Rein, damit pruefbar. */
|
||||
export type Entscheidung =
|
||||
| { art: 'ausfuehren'; aktion: Exclude<AktionsArt, 'tts' | 'nichts'>; plan: BelohnungsPlan }
|
||||
| { art: 'vorlesen'; text: string }
|
||||
| { art: 'warteschlange'; text: string }
|
||||
| { art: 'abgelehnt'; grund: string }
|
||||
| { art: 'unbekannt' };
|
||||
|
||||
/** Findet den Plan zu einer Einloesung. ID gewinnt, sonst der Titel. */
|
||||
export function planFuer(
|
||||
e: Pick<Einloesung, 'rewardId' | 'rewardTitle'>, plaene: BelohnungsPlan[],
|
||||
): BelohnungsPlan | undefined {
|
||||
return plaene.find((p) => p.id && p.id === e.rewardId)
|
||||
?? plaene.find((p) => p.titel && p.titel.toLowerCase() === e.rewardTitle.toLowerCase());
|
||||
}
|
||||
|
||||
export function entscheiden(
|
||||
e: Einloesung,
|
||||
cfg: RewardsConfig,
|
||||
/** Wie oft diese Person in der letzten Stunde schon eingeloest hat. */
|
||||
bisherigeDieseStunde = 0,
|
||||
): Entscheidung {
|
||||
if (!cfg.enabled) return { art: 'abgelehnt', grund: 'Belohnungs-Aktionen sind ausgeschaltet' };
|
||||
|
||||
const plan = planFuer(e, cfg.belohnungen);
|
||||
// Unbekannte Belohnung heisst nicht "Fehler": Abdu darf Belohnungen anlegen,
|
||||
// die mit dem Toolkit nichts zu tun haben. Sie werden protokolliert und
|
||||
// sonst in Ruhe gelassen.
|
||||
if (!plan) return { art: 'unbekannt' };
|
||||
if (plan.aktion === 'nichts') return { art: 'unbekannt' };
|
||||
|
||||
if (plan.aktion !== 'tts') {
|
||||
return { art: 'ausfuehren', aktion: plan.aktion, plan };
|
||||
}
|
||||
|
||||
// ---- ab hier nur noch TTS
|
||||
const text = e.text.trim();
|
||||
if (!text) return { art: 'abgelehnt', grund: 'kein Text eingegeben' };
|
||||
|
||||
const max = plan.maxZeichen ?? cfg.tts.maxZeichen;
|
||||
if (text.length > max) {
|
||||
// Abschneiden waere schlimmer: dann wird ein halber Satz vorgelesen, und
|
||||
// niemand weiss, ob der Rest Absicht war.
|
||||
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
|
||||
}
|
||||
|
||||
if (wortfilterTreffer(text, cfg.tts.blockliste)) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
||||
|
||||
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
|
||||
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
|
||||
}
|
||||
|
||||
const freigabe = plan.freigabe ?? cfg.tts.freigabe;
|
||||
return freigabe ? { art: 'warteschlange', text } : { art: 'vorlesen', text };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Wortfilter
|
||||
|
||||
const LEET: Record<string, string> = {
|
||||
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b',
|
||||
'@': 'a', '$': 's', '!': 'i', '|': 'i',
|
||||
};
|
||||
|
||||
/**
|
||||
* Text auf das bringen, was man hoert: klein, ohne Akzente, Leetspeak
|
||||
* zurueckuebersetzt, Buchstabenketten auf einen gekuerzt. Wer "N 3 G G 3 R"
|
||||
* oder "h.i.t.l.e.r" schreibt, will am Filter vorbei - vorgelesen klingt es
|
||||
* trotzdem wie das Original.
|
||||
*
|
||||
* `mitLuecken` laesst Wortgrenzen als Leerzeichen stehen, sonst wird alles
|
||||
* zusammengezogen.
|
||||
*/
|
||||
export function fuerFilter(text: string, mitLuecken: boolean): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.normalize('NFKD').replace(/\p{M}/gu, '')
|
||||
.replace(/[0134578@$!|]/g, (c) => LEET[c] ?? c)
|
||||
.replace(/[^a-z]+/g, mitLuecken ? ' ' : '')
|
||||
.replace(/([a-z])\1+/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trifft der Text ein Wort der Blockliste?
|
||||
*
|
||||
* Lange Eintraege (ab 5 Buchstaben) werden im zusammengezogenen Text
|
||||
* gesucht, damit Punkte und Leerzeichen dazwischen nicht helfen. Kurze nur
|
||||
* am Wortanfang: zusammengezogen stuende "nazi" sonst auch in "kann azubi".
|
||||
* Was danach kuerzer als 3 Buchstaben ist, nur als ganzes Wort: aus "www"
|
||||
* wird "w", und das stuende am Anfang von "wie" und "weg".
|
||||
*/
|
||||
export function wortfilterTreffer(text: string, blockliste: string[]): string | undefined {
|
||||
const dicht = fuerFilter(text, false);
|
||||
const woerter = fuerFilter(text, true).split(' ');
|
||||
return blockliste.find((eintrag) => {
|
||||
const e = fuerFilter(eintrag, false);
|
||||
if (!e) return false;
|
||||
if (e.length < 3) return woerter.includes(e);
|
||||
return e.length >= 5 ? dicht.includes(e) : woerter.some((w) => w.startsWith(e));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warteschlange
|
||||
|
||||
export type Wartend = {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
/** Fuer die Erstattung, falls abgelehnt wird. */
|
||||
rewardId: string;
|
||||
redemptionId: string;
|
||||
seit: string;
|
||||
};
|
||||
|
||||
export type Warteschlange = {
|
||||
einreihen: (w: Wartend) => void;
|
||||
/** Gibt den Eintrag zurueck, damit der Aufrufer ihn vorlesen lassen kann. */
|
||||
entnehmen: (id: string) => Wartend | undefined;
|
||||
liste: () => Wartend[];
|
||||
leeren: () => Wartend[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Wartende Nachrichten. Bewusst im Arbeitsspeicher: was beim Neustart
|
||||
* verloren geht, war ohnehin nicht dringend, und eine Tabelle mit fremdem
|
||||
* Text, die niemand aufraeumt, will hier niemand.
|
||||
*/
|
||||
export function createWarteschlange(maximal = 25): Warteschlange {
|
||||
const wartend: Wartend[] = [];
|
||||
return {
|
||||
einreihen(w) {
|
||||
wartend.push(w);
|
||||
// Laeuft sie voll, faellt das Aelteste raus. Eine Warteschlange, die
|
||||
// bei einem Ansturm auf 300 waechst, arbeitet Abdu nie ab.
|
||||
while (wartend.length > maximal) {
|
||||
const weg = wartend.shift();
|
||||
if (weg) log.warn(`Warteschlange voll, aelteste Nachricht verworfen (${weg.name})`);
|
||||
}
|
||||
},
|
||||
entnehmen(id) {
|
||||
const i = wartend.findIndex((w) => w.id === id);
|
||||
return i === -1 ? undefined : wartend.splice(i, 1)[0];
|
||||
},
|
||||
liste: () => [...wartend],
|
||||
leeren: () => wartend.splice(0, wartend.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaehlt Einloesungen je Person in einem gleitenden Fenster.
|
||||
* Braucht keine Datenbank - nach einem Neustart faengt das Fenster neu an,
|
||||
* und das ist bei einer Stunde Fensterbreite egal.
|
||||
*/
|
||||
export function createDrossel(fensterMs = 3_600_000) {
|
||||
const gesehen = new Map<string, number[]>();
|
||||
return {
|
||||
zaehlen(userId: string, jetzt = Date.now()): number {
|
||||
const bisher = (gesehen.get(userId) ?? []).filter((t) => jetzt - t < fensterMs);
|
||||
gesehen.set(userId, bisher);
|
||||
return bisher.length;
|
||||
},
|
||||
merken(userId: string, jetzt = Date.now()): void {
|
||||
const bisher = (gesehen.get(userId) ?? []).filter((t) => jetzt - t < fensterMs);
|
||||
bisher.push(jetzt);
|
||||
gesehen.set(userId, bisher);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Das EventSub-Ereignis in unsere Form bringen. */
|
||||
export function ausEventSub(event: Record<string, unknown>): Einloesung | null {
|
||||
const reward = event['reward'] as { id?: string; title?: string; cost?: number } | undefined;
|
||||
const id = event['id'];
|
||||
const userId = event['user_id'];
|
||||
if (typeof id !== 'string' || typeof userId !== 'string' || !reward?.id) return null;
|
||||
return {
|
||||
id,
|
||||
rewardId: reward.id,
|
||||
rewardTitle: reward.title ?? '',
|
||||
userId,
|
||||
userName: String(event['user_name'] ?? event['user_login'] ?? ''),
|
||||
text: String(event['user_input'] ?? ''),
|
||||
kosten: Number(reward.cost ?? 0),
|
||||
ts: String(event['redeemed_at'] ?? new Date().toISOString()),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Die Leitung zum Hub, Seite des Stream-PCs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { ObsClient } from '#obs/client.ts';
|
||||
import { befehlAus, sseZerleger, standLesen } from './fern.ts';
|
||||
|
||||
test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
|
||||
const z = sseZerleger();
|
||||
assert.deepEqual(z('retry: 2000\n\n'), [], 'retry allein ist keine Nachricht');
|
||||
assert.deepEqual(z('data: {"a"'), []);
|
||||
assert.deepEqual(z(':1}\n\ndata: 2\n\nda'), ['{"a":1}', '2']);
|
||||
assert.deepEqual(z('ta: 3\r\n\r\n'), ['3'], 'auch mit Windows-Zeilenenden');
|
||||
});
|
||||
|
||||
test('nur bekannte Befehle mit ID werden ausgefuehrt', () => {
|
||||
assert.deepEqual(befehlAus({ id: 'x', art: 'szene', szene: 'Pause' }), { id: 'x', art: 'szene', szene: 'Pause' });
|
||||
assert.deepEqual(befehlAus({ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true }),
|
||||
{ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true });
|
||||
assert.equal(befehlAus({ art: 'clip' }), null, 'ohne ID gibt es niemanden, dem man antwortet');
|
||||
assert.equal(befehlAus({ id: 'x', art: 'beat' }), null);
|
||||
assert.equal(befehlAus({ id: 'x', art: 'StopStream' }), null);
|
||||
});
|
||||
|
||||
function falschesObs(antworten: Record<string, (d: Record<string, unknown>) => unknown>): ObsClient {
|
||||
return {
|
||||
run: async () => {},
|
||||
isConnected: () => true,
|
||||
request: async <T>(type: string, data?: unknown): Promise<T> => {
|
||||
const f = antworten[type];
|
||||
if (!f) throw new Error(`OBS lehnt ab (604): ${type}`);
|
||||
return f((data ?? {}) as Record<string, unknown>) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Stand: Szenen in OBS-Reihenfolge, nur Eingaenge mit Ton', async () => {
|
||||
const obs = falschesObs({
|
||||
GetSceneList: () => ({
|
||||
currentProgramSceneName: 'Gameplay',
|
||||
// OBS zaehlt von unten: Index 0 steht im Fenster ganz unten.
|
||||
scenes: [{ sceneName: 'Pause', sceneIndex: 0 }, { sceneName: 'Gameplay', sceneIndex: 2 },
|
||||
{ sceneName: 'Just Chatting', sceneIndex: 1 }],
|
||||
}),
|
||||
GetInputList: () => ({ inputs: [{ inputName: 'Mikro' }, { inputName: 'Facecam' }, { inputName: 'Desktop' }] }),
|
||||
GetInputMute: (d) => {
|
||||
if (d['inputName'] === 'Facecam') throw new Error('kein Ton');
|
||||
return { inputMuted: d['inputName'] === 'Desktop' };
|
||||
},
|
||||
GetReplayBufferStatus: () => ({ outputActive: true }),
|
||||
});
|
||||
assert.deepEqual(await standLesen(obs), {
|
||||
obs: true,
|
||||
szenen: ['Gameplay', 'Just Chatting', 'Pause'],
|
||||
aktuell: 'Gameplay',
|
||||
ton: [{ name: 'Mikro', stumm: false }, { name: 'Desktop', stumm: true }],
|
||||
puffer: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
|
||||
const obs = falschesObs({
|
||||
GetSceneList: () => ({ currentProgramSceneName: 'A', scenes: [{ sceneName: 'A', sceneIndex: 0 }] }),
|
||||
GetInputList: () => ({ inputs: [] }),
|
||||
});
|
||||
const s = await standLesen(obs);
|
||||
assert.equal(s.puffer, false);
|
||||
assert.deepEqual(s.szenen, ['A']);
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
// Fernsteuerung fuer OBS, Seite des Stream-PCs.
|
||||
//
|
||||
// Das Pult am Handy redet mit dem Hub, OBS laeuft hier. Der Hub kommt an
|
||||
// diesen Rechner nicht heran (fremder Router, keine Portfreigabe), also waehlt
|
||||
// sich der PC beim Hub ein und haelt die Leitung offen:
|
||||
//
|
||||
// GET /fern/befehle SSE, Befehle kommen herein (Hub -> PC)
|
||||
// POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub)
|
||||
// POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub)
|
||||
//
|
||||
// Gegenstueck: apps/overlay-server/src/fern.ts. Eingerichtet wird mit
|
||||
// `abdulive fern:verbinden <adresse> <schluessel>`; beides steht fertig auf
|
||||
// der Uebersichtsseite des Hubs.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { forever, sleep } from '#core/retry.ts';
|
||||
import type { ObsClient } from '#obs/client.ts';
|
||||
|
||||
const log = logger('fern');
|
||||
|
||||
/** Der Hub schickt alle 15 s einen Herzschlag. Drei verpasste heisst: tot. */
|
||||
const TOT_NACH_MS = 45_000;
|
||||
|
||||
/** Nach diesen OBS-Ereignissen stimmt der gemeldete Stand nicht mehr. */
|
||||
const STAND_EREIGNISSE = new Set([
|
||||
'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
|
||||
'SceneNameChanged', 'InputMuteStateChanged', 'InputCreated', 'InputRemoved',
|
||||
'InputNameChanged', 'ReplayBufferStateChanged',
|
||||
]);
|
||||
|
||||
export type Stand = {
|
||||
obs: boolean;
|
||||
szenen: string[];
|
||||
aktuell: string | null;
|
||||
ton: Array<{ name: string; stumm: boolean }>;
|
||||
puffer: boolean;
|
||||
};
|
||||
|
||||
export type Befehl =
|
||||
| { id: string; art: 'szene'; szene: string }
|
||||
| { id: string; art: 'clip' }
|
||||
| { id: string; art: 'stumm'; quelle: string; stumm: boolean };
|
||||
|
||||
/**
|
||||
* Zerlegt einen SSE-Strom in Nachrichten. Bekommt beliebige Stuecke - eine
|
||||
* Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem
|
||||
* Stueck stecken.
|
||||
*/
|
||||
export function sseZerleger(): (stueck: string) => string[] {
|
||||
let rest = '';
|
||||
return (stueck) => {
|
||||
rest += stueck.replace(/\r\n?/g, '\n');
|
||||
const fertig: string[] = [];
|
||||
let i: number;
|
||||
while ((i = rest.indexOf('\n\n')) !== -1) {
|
||||
const block = rest.slice(0, i);
|
||||
rest = rest.slice(i + 2);
|
||||
const daten = block.split('\n')
|
||||
.filter((z) => z.startsWith('data:'))
|
||||
.map((z) => z.slice(5).replace(/^ /, ''));
|
||||
if (daten.length) fertig.push(daten.join('\n'));
|
||||
}
|
||||
return fertig;
|
||||
};
|
||||
}
|
||||
|
||||
/** Was vom Hub kommt, pruefen. Unbekanntes wird nicht ausgefuehrt. */
|
||||
export function befehlAus(roh: unknown): Befehl | null {
|
||||
if (!roh || typeof roh !== 'object') return null;
|
||||
const r = roh as Record<string, unknown>;
|
||||
const id = typeof r['id'] === 'string' ? r['id'] : '';
|
||||
if (!id) return null;
|
||||
if (r['art'] === 'szene' && typeof r['szene'] === 'string') return { id, art: 'szene', szene: r['szene'] };
|
||||
if (r['art'] === 'clip') return { id, art: 'clip' };
|
||||
if (r['art'] === 'stumm' && typeof r['quelle'] === 'string') {
|
||||
return { id, art: 'stumm', quelle: r['quelle'], stumm: r['stumm'] === true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Szenen, aktuelle Szene, stummschaltbare Eingaenge und der Puffer. */
|
||||
export async function standLesen(obs: ObsClient): Promise<Stand> {
|
||||
const leer: Stand = { obs: false, szenen: [], aktuell: null, ton: [], puffer: false };
|
||||
if (!obs.isConnected()) return leer;
|
||||
try {
|
||||
const liste = await obs.request<{
|
||||
currentProgramSceneName: string;
|
||||
scenes: Array<{ sceneName: string; sceneIndex: number }>;
|
||||
}>('GetSceneList');
|
||||
// OBS zaehlt von unten. Im Fenster steht die mit dem hoechsten Index oben,
|
||||
// und in dieser Reihenfolge erwartet man sie auch am Handy.
|
||||
const szenen = [...liste.scenes]
|
||||
.sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName);
|
||||
|
||||
const eingaenge = await obs.request<{ inputs: Array<{ inputName: string }> }>('GetInputList');
|
||||
// Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute
|
||||
// antwortet, hat welchen; Bildquellen lehnen ab.
|
||||
const stumm = await Promise.allSettled(eingaenge.inputs.map((e) =>
|
||||
obs.request<{ inputMuted: boolean }>('GetInputMute', { inputName: e.inputName })
|
||||
.then((r) => ({ name: e.inputName, stumm: r.inputMuted }))));
|
||||
const ton = stumm.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));
|
||||
|
||||
const puffer = await obs.request<{ outputActive: boolean }>('GetReplayBufferStatus')
|
||||
.then((r) => r.outputActive, () => false);
|
||||
|
||||
return { obs: true, szenen, aktuell: liste.currentProgramSceneName || null, ton, puffer };
|
||||
} catch (err) {
|
||||
log.debug(`Stand nicht gelesen: ${(err as Error).message}`);
|
||||
return { ...leer, obs: obs.isConnected() };
|
||||
}
|
||||
}
|
||||
|
||||
export type FernDeps = {
|
||||
url: string;
|
||||
key: string;
|
||||
obs: ObsClient;
|
||||
/** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
|
||||
clip: () => Promise<{ ok: boolean; reason?: string }>;
|
||||
};
|
||||
|
||||
export type FernAnschluss = {
|
||||
run: (signal: AbortSignal) => Promise<void>;
|
||||
/** Nach einem OBS-Ereignis rufen. Sammelt kurz, dann geht ein Stand raus. */
|
||||
obsEreignis: (type: string) => void;
|
||||
/** OBS verbunden oder getrennt. */
|
||||
obsWechsel: () => void;
|
||||
verbunden: () => boolean;
|
||||
};
|
||||
|
||||
export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
||||
const basis = deps.url.replace(/\/+$/, '');
|
||||
const kopf = { authorization: `Bearer ${deps.key}` };
|
||||
let verbunden = false;
|
||||
let sammler: NodeJS.Timeout | null = null;
|
||||
|
||||
async function post(pfad: string, body: unknown): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${basis}${pfad}`, {
|
||||
method: 'POST',
|
||||
headers: { ...kopf, 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) log.warn(`${pfad}: Hub antwortet ${res.status}`);
|
||||
} catch (err) {
|
||||
log.debug(`${pfad} nicht zugestellt: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const standSenden = async (): Promise<void> => {
|
||||
if (!verbunden) return;
|
||||
await post('/fern/stand', await standLesen(deps.obs));
|
||||
};
|
||||
|
||||
const spaeterSenden = (): void => {
|
||||
// OBS feuert beim Szenenwechsel mehrere Ereignisse kurz hintereinander.
|
||||
// Ein Stand danach reicht.
|
||||
if (sammler) clearTimeout(sammler);
|
||||
sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300);
|
||||
};
|
||||
|
||||
async function ausfuehren(b: Befehl): Promise<{ ok: boolean; text: string }> {
|
||||
if (b.art === 'clip') {
|
||||
const r = await deps.clip();
|
||||
return r.ok ? { ok: true, text: 'Clip wird gesichert.' }
|
||||
: { ok: false, text: r.reason ?? 'Clip ging nicht.' };
|
||||
}
|
||||
if (!deps.obs.isConnected()) return { ok: false, text: 'OBS ist auf dem Stream-PC nicht verbunden.' };
|
||||
try {
|
||||
if (b.art === 'szene') {
|
||||
await deps.obs.request('SetCurrentProgramScene', { sceneName: b.szene });
|
||||
return { ok: true, text: `Szene: ${b.szene}` };
|
||||
}
|
||||
await deps.obs.request('SetInputMute', { inputName: b.quelle, inputMuted: b.stumm });
|
||||
return { ok: true, text: `${b.quelle} ${b.stumm ? 'stumm' : 'wieder an'}` };
|
||||
} catch (err) {
|
||||
return { ok: false, text: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
async function einmal(signal: AbortSignal): Promise<void> {
|
||||
const wachhund = new AbortController();
|
||||
const beides = AbortSignal.any([signal, wachhund.signal]);
|
||||
let zuletzt = Date.now();
|
||||
const takt = setInterval(() => {
|
||||
if (Date.now() - zuletzt > TOT_NACH_MS) {
|
||||
log.warn('Vom Hub nichts mehr gehoert, verbinde neu');
|
||||
wachhund.abort();
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${basis}/fern/befehle`, {
|
||||
headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
|
||||
});
|
||||
if (res.status === 403) {
|
||||
log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
|
||||
'von der Uebersichtsseite: abdulive fern:verbinden ...');
|
||||
// Nicht im Sekundentakt anklopfen, der Schluessel wird nicht von allein richtig.
|
||||
await sleep(5 * 60_000, signal).catch(() => {});
|
||||
throw new Error('Schluessel abgelehnt');
|
||||
}
|
||||
if (!res.ok || !res.body) throw new Error(`Hub antwortet ${res.status}`);
|
||||
|
||||
verbunden = true;
|
||||
log.info(`Mit dem Hub verbunden (${basis}) - das Pult kann OBS jetzt steuern`);
|
||||
void standSenden();
|
||||
|
||||
const zerleger = sseZerleger();
|
||||
const decoder = new TextDecoder();
|
||||
for await (const stueck of res.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
zuletzt = Date.now();
|
||||
for (const daten of zerleger(decoder.decode(stueck, { stream: true }))) {
|
||||
let roh: unknown;
|
||||
try { roh = JSON.parse(daten); } catch { continue; }
|
||||
const b = befehlAus(roh);
|
||||
if (!b) continue;
|
||||
void ausfuehren(b).then((a) => {
|
||||
log.info(`Pult: ${b.art} -> ${a.ok ? 'ok' : 'abgelehnt'} (${a.text})`);
|
||||
return post('/fern/antwort', { id: b.id, ...a });
|
||||
}).then(spaeterSenden);
|
||||
}
|
||||
}
|
||||
throw new Error('Hub hat die Leitung geschlossen');
|
||||
} catch (err) {
|
||||
if (!signal.aborted) log.debug(`Leitung zum Hub weg: ${(err as Error).message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
clearInterval(takt);
|
||||
if (verbunden) log.info('Leitung zum Hub getrennt');
|
||||
verbunden = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
run: (signal) => forever(einmal, {
|
||||
signal, baseMs: 2000, maxMs: 60_000,
|
||||
onWait: (ms, n) => {
|
||||
const zeile = `Hub nicht erreichbar, neuer Versuch in ${Math.round(ms / 1000)} s`;
|
||||
if (n >= 3) log.warn(zeile); else log.debug(zeile);
|
||||
},
|
||||
}),
|
||||
obsEreignis: (type) => { if (STAND_EREIGNISSE.has(type)) spaeterSenden(); },
|
||||
obsWechsel: spaeterSenden,
|
||||
verbunden: () => verbunden,
|
||||
};
|
||||
}
|
||||
+96
-29
@@ -10,11 +10,16 @@ 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 { createObs } from '#obs/client.ts';
|
||||
import { isWindows } from '#core/platform.ts';
|
||||
import { helix } from '#twitch/helix.ts';
|
||||
import { createIrc } from '#twitch/irc.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||
import { createClipper } from './clips.ts';
|
||||
import { createFernanschluss } from './fern.ts';
|
||||
import { createRenderer } from './render.ts';
|
||||
import { createSpikeDetector } from './spike.ts';
|
||||
import { startServer } from './server.ts';
|
||||
@@ -40,6 +45,7 @@ export async function runIngest(): Promise<void> {
|
||||
messages: 0,
|
||||
spikeCandidates: 0,
|
||||
clipsTriggered: 0,
|
||||
goLive: 'noch nichts' as string,
|
||||
lastError: null as string | null,
|
||||
};
|
||||
|
||||
@@ -57,8 +63,9 @@ export async function runIngest(): Promise<void> {
|
||||
cfg.obs.url,
|
||||
() => readSecrets().obs?.password,
|
||||
{
|
||||
onIdentified: () => { state.obsConnected = true; },
|
||||
onClosed: () => { state.obsConnected = false; },
|
||||
onIdentified: () => { state.obsConnected = true; fern?.obsWechsel(); },
|
||||
onClosed: () => { state.obsConnected = false; fern?.obsWechsel(); },
|
||||
onEvent: (type) => fern?.obsEreignis(type),
|
||||
onSceneChanged: (scene) => {
|
||||
state.obsScene = scene;
|
||||
repo.event('obs.scene_changed', { scene }, state.streamId);
|
||||
@@ -95,7 +102,9 @@ export async function runIngest(): Promise<void> {
|
||||
cooldownUserSeconds: cfg.clipper.cooldownUserSeconds,
|
||||
followersOnly: cfg.clipper.followersOnly,
|
||||
...(process.env.OBS_REPLAY_DIR ? { replayDirHost: process.env.OBS_REPLAY_DIR } : {}),
|
||||
replayDirContainer: paths.replay,
|
||||
// Nativ sieht die Anwendung denselben Pfad wie OBS. Leer heisst:
|
||||
// nicht uebersetzen (siehe #obs/replay-path.ts).
|
||||
replayDirContainer: isWindows ? '' : paths.replay,
|
||||
clipsDir: paths.clips,
|
||||
workDir: paths.work,
|
||||
},
|
||||
@@ -107,14 +116,45 @@ export async function runIngest(): Promise<void> {
|
||||
onRawReady: (job) => renderer.enqueue(job),
|
||||
});
|
||||
|
||||
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Nur, wenn mit
|
||||
// `abdulive fern:verbinden` eingerichtet - sonst laeuft alles wie vorher.
|
||||
const fernZugang = readSecrets().fern;
|
||||
const fern = fernZugang?.url && fernZugang.key
|
||||
? createFernanschluss({
|
||||
url: fernZugang.url,
|
||||
key: fernZugang.key,
|
||||
obs,
|
||||
clip: () => clipper.trigger('manual', { login: 'Pult' }),
|
||||
})
|
||||
: null;
|
||||
if (fern) void fern.run(abort.signal);
|
||||
else log.info('Pult-Fernsteuerung nicht eingerichtet (abdulive fern:verbinden)');
|
||||
|
||||
// ---- Go-Live-Ankuendigung auf Discord
|
||||
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: cfg.broadcaster.obsScreenshot,
|
||||
},
|
||||
repo,
|
||||
obs,
|
||||
});
|
||||
|
||||
startServer({
|
||||
channel: cfg.channel.login,
|
||||
port: cfg.dashboard.port,
|
||||
health: () => ({
|
||||
...state,
|
||||
db: repo.stats(),
|
||||
discordConnected: bot ? bot.isConnected() : false,
|
||||
goLive: announcer.lastResult() ?? state.goLive,
|
||||
spikeArmed: spikes.isArmed(),
|
||||
clipsWaitingForFile: clipper.pendingCount(),
|
||||
pult: fern ? (fern.verbunden() ? 'verbunden' : 'getrennt') : 'nicht eingerichtet',
|
||||
renderQueue: renderer.pending(),
|
||||
rendering: renderer.currentJob(),
|
||||
note: state.chatAnonymous
|
||||
@@ -167,8 +207,15 @@ export async function runIngest(): Promise<void> {
|
||||
void irc.run(abort.signal);
|
||||
void obs.run(abort.signal);
|
||||
|
||||
// ---- Discord (Rollen-Knoepfe). Fehlt die Einrichtung, entfaellt er.
|
||||
const bot = createBot();
|
||||
if (bot) void bot.run(abort.signal);
|
||||
|
||||
// ---- Zuschauerzahlen und Stream-Sessions
|
||||
void pollStream(cfg, repo, state, abort.signal);
|
||||
void pollStream(cfg, repo, state, abort.signal, (stream) => {
|
||||
state.goLive = 'laeuft';
|
||||
announcer.announce(stream);
|
||||
});
|
||||
|
||||
log.info(`Ingest laeuft fuer #${cfg.channel.login}`);
|
||||
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
|
||||
@@ -176,14 +223,16 @@ export async function runIngest(): Promise<void> {
|
||||
|
||||
type State = {
|
||||
streamId: string | null; broadcasterId: string | null;
|
||||
lastSeenLive: number; viewers: number; lastError: string | null;
|
||||
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
|
||||
};
|
||||
|
||||
async function pollStream(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
repo: ReturnType<typeof repos>,
|
||||
state: State,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
/** Genau einmal je neu erkanntem Stream. Fortsetzungen zaehlen nicht. */
|
||||
onOnline: (stream: StreamInfo) => void
|
||||
): Promise<void> {
|
||||
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
|
||||
let warnedMissingAuth = false;
|
||||
@@ -199,10 +248,12 @@ async function pollStream(
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
if (!readSecrets().tokens.broadcaster) {
|
||||
// Client-ID und Secret genuegen: `GET /users` und `GET /streams` laufen
|
||||
// mit einem App-Token, auch ohne verbundenes Konto.
|
||||
if (!helix.canReadStream()) {
|
||||
if (!warnedMissingAuth) {
|
||||
log.warn('Kein Broadcaster-Token - Zuschauerzahlen und Stream-Sitzungen bleiben aus. ' +
|
||||
'Einrichtung unter /setup.');
|
||||
log.warn('Keine Twitch-App hinterlegt - Zuschauerzahlen und Stream-Sitzungen ' +
|
||||
'bleiben aus. Einrichtung unter /setup.');
|
||||
warnedMissingAuth = true;
|
||||
}
|
||||
} else {
|
||||
@@ -214,33 +265,49 @@ async function pollStream(
|
||||
const live = userId ? await helix.streamByUserId(userId) : null;
|
||||
const now = Date.now();
|
||||
|
||||
if (live) {
|
||||
if (state.streamId !== live.id) {
|
||||
// Kurze Unterbrechungen gelten als dieselbe Sitzung, sonst
|
||||
// zerfaellt ein Stream bei jedem Aussetzer in Fragmente.
|
||||
const wasRecent = now - state.lastSeenLive < gapMs;
|
||||
if (state.streamId && wasRecent) {
|
||||
log.info(`Neue Twitch-Stream-ID ${live.id}, behandle als Fortsetzung von ${state.streamId}`);
|
||||
} else {
|
||||
state.streamId = live.id;
|
||||
repo.openStream(live.id, live.started_at);
|
||||
// Die Entscheidung "neuer Stream oder Fortsetzung" steht in
|
||||
// #twitch/stream-state.ts und ist dort geprueft. Sie haengt daran,
|
||||
// ob ein zweiter Rundruf rausgeht.
|
||||
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 }, live.id);
|
||||
log.info(`Stream online: "${live.title}" (${live.game_name})`);
|
||||
}
|
||||
{ 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}`);
|
||||
} 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;
|
||||
}
|
||||
|
||||
if (live) {
|
||||
state.lastSeenLive = now;
|
||||
state.viewers = live.viewer_count;
|
||||
if (state.streamId) {
|
||||
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
|
||||
}
|
||||
} else if (state.streamId && now - state.lastSeenLive > gapMs) {
|
||||
const ended = new Date().toISOString();
|
||||
repo.closeStream(state.streamId, ended);
|
||||
repo.event('stream.offline', {}, state.streamId);
|
||||
log.info(`Stream offline: ${state.streamId}`);
|
||||
state.streamId = null;
|
||||
state.viewers = 0;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -59,18 +59,45 @@ async function handle(
|
||||
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
|
||||
): Promise<void> {
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage {
|
||||
clock += advanceMs;
|
||||
return {
|
||||
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId,
|
||||
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [],
|
||||
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [],
|
||||
isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
|
||||
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# @abdulive/overlay-server
|
||||
|
||||
WebSocket-Backend und Command-Registry fuer alle Overlays.
|
||||
Der einzige Server, der von außen erreichbar ist. Liefert die Seiten aus
|
||||
[`overlay-web`](../overlay-web/README.md) aus, hält den Ereignisstrom dorthin
|
||||
offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse.
|
||||
|
||||
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
||||
```
|
||||
/ Übersicht mit den Adressen für OBS Passwort
|
||||
/setup Einrichtung: Twitch-Apps, Tokens Passwort
|
||||
/callback OAuth-Rückruf von Twitch offen, `state` schützt
|
||||
/alerts … die Browser-Sources Schlüssel in der URL
|
||||
/events der Ereignisstrom dahinter Schlüssel in der URL
|
||||
/pult Steuerpult fürs Handy (PWA) Passwort
|
||||
/soundboard/… Sounds hochladen, ändern, spielen Passwort (Datei: Schlüssel)
|
||||
/fern/… Leitung zum Stream-PC für OBS eigener Schlüssel im Kopf
|
||||
```
|
||||
|
||||
Der Fern-Schlüssel ist der dritte Zugang und der einzige, der etwas schaltet:
|
||||
er steht deshalb nie in einer Adresse, sondern in `authorization: Bearer …`.
|
||||
|
||||
## Server-Sent Events statt WebSocket
|
||||
|
||||
[`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen
|
||||
WS-Kanal. Es sind SSE geworden, aus drei Gründen:
|
||||
|
||||
1. Node bringt einen WebSocket-*Client* mit, aber keinen Server. Den müsste
|
||||
man selbst schreiben, inklusive Handshake und Rahmenformat - in einem
|
||||
Projekt ohne Abhängigkeiten heißt das: selbst pflegen.
|
||||
2. `EventSource` verbindet sich von allein neu. Die tote Browser-Source nach
|
||||
einem Neustart ist der häufigste Fehler bei solchen Overlays, und hier
|
||||
löst ihn der Browser.
|
||||
3. Die Richtung stimmt. Das Overlay hört nur zu; es sendet nie.
|
||||
|
||||
Drei Dinge halten die Verbindung ehrlich:
|
||||
|
||||
- **Herzschlag alle 15 s**, als echtes Ereignis und nicht als SSE-Kommentar -
|
||||
ein Kommentar hält die Verbindung offen, ist im Browser aber unsichtbar.
|
||||
- **Wachhund im Client**: 45 s ohne Nachricht heißt neu verbinden. OBS hält
|
||||
eine tote Verbindung erstaunlich lange für lebendig.
|
||||
- **Schnappschuss beim Verbinden**: der letzte Stream-Zustand und die letzten
|
||||
Chatnachrichten, damit die Quelle nach dem Neuverbinden nicht leer im Bild
|
||||
steht.
|
||||
|
||||
## Zugang
|
||||
|
||||
Siehe [ADR-0008](../../docs/decisions/ADR-0008-geteilter-betrieb.md).
|
||||
|
||||
- **Passwort** für alles, was ein Mensch bedient. Setzen mit
|
||||
`abdulive web:passwort`, nie über das Netz - ein Dienst, bei dem man das
|
||||
Passwort über die offene Weboberfläche setzen kann, hat kein Passwort.
|
||||
- **Schlüssel in der Adresse** für die Browser-Sources. Eine Browser-Source
|
||||
kann kein Formular ausfüllen. Der Schlüssel darf nur lesen und lässt sich
|
||||
auf der Übersichtsseite neu würfeln.
|
||||
|
||||
Ohne `publicUrl` bindet der Server weiter auf `127.0.0.1` und verhält sich wie
|
||||
vorher.
|
||||
@@ -0,0 +1,79 @@
|
||||
// Das Sitzungs-Cookie ist der einzige Schutz vor der Einrichtungsseite, auf
|
||||
// der Twitch-Zugangsdaten eingetragen werden. Hier wird geprueft, dass es
|
||||
// haelt, was es soll - und dass es einen Neustart übersteht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import { sitzungGueltig, sitzungToken } from './auth.ts';
|
||||
|
||||
const KEY = crypto.randomBytes(32);
|
||||
const HASH = 'ein-passwort-hash';
|
||||
const JETZT = Date.parse('2026-09-17T18:00:00Z');
|
||||
const IN_30_TAGEN = JETZT + 30 * 24 * 3_600_000;
|
||||
|
||||
test('ein frisch ausgestelltes Cookie gilt', () => {
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), true);
|
||||
});
|
||||
|
||||
test('ein Neustart meldet niemanden ab', () => {
|
||||
// Der eigentliche Grund für diese Bauform: der Dienst startet bei jedem
|
||||
// Ausrollen neu. Lag die Sitzungsliste im Arbeitsspeicher, war man danach
|
||||
// jedes Mal draußen. Derselbe Schlüssel - dasselbe Ergebnis, ohne dass
|
||||
// irgendwo etwas gemerkt wurde.
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
const nachNeustart = Buffer.from(KEY); // derselbe Wert, neues Objekt
|
||||
assert.equal(sitzungGueltig(t, nachNeustart, HASH, JETZT), true);
|
||||
});
|
||||
|
||||
test('abgelaufen ist abgelaufen', () => {
|
||||
const t = sitzungToken(KEY, HASH, JETZT - 1);
|
||||
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('das Ablaufdatum laesst sich nicht verlaengern', () => {
|
||||
// Ohne Signatur ueber das Datum koennte jeder das Cookie umschreiben und
|
||||
// waere damit dauerhaft angemeldet.
|
||||
const t = sitzungToken(KEY, HASH, JETZT + 1000);
|
||||
const [, sig] = t.split('.');
|
||||
const gefaelscht = `${Buffer.from(String(IN_30_TAGEN)).toString('base64url')}.${sig}`;
|
||||
assert.equal(sitzungGueltig(gefaelscht, KEY, HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('ein fremder Schluessel oeffnet nichts', () => {
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, crypto.randomBytes(32), HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('ein neues Passwort macht alle Cookies ungueltig', () => {
|
||||
// Das ist der Ersatz für eine Abmeldeliste: wer sein Passwort wechselt,
|
||||
// wirft damit alle offenen Sitzungen raus.
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, KEY, 'neuer-hash', JETZT), false);
|
||||
});
|
||||
|
||||
test('Unsinn im Cookie wird abgewiesen, nicht geglaubt', () => {
|
||||
for (const müll of [undefined, '', 'abc', 'abc.def', '.', 'x.y.z',
|
||||
`${Buffer.from('keine-zahl').toString('base64url')}.xx`]) {
|
||||
assert.equal(sitzungGueltig(müll, KEY, HASH, JETZT), false, `"${müll}" haette abgewiesen gehoert`);
|
||||
}
|
||||
});
|
||||
|
||||
test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
|
||||
// Bewusst so: ohne Zufallsanteil gibt es nichts, was der Server sich merken
|
||||
// müsste. Der Preis ist, dass sich eine einzelne Sitzung nicht gezielt
|
||||
// beenden lässt - dafür gibt es den Passwortwechsel.
|
||||
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
|
||||
});
|
||||
|
||||
test('der Schluessel des Stream-PCs kommt aus dem Bearer-Kopf', async () => {
|
||||
// Hier fehlten beim ersten Wurf die Backslashes in der Regex - jeder
|
||||
// richtige Schluessel wurde abgelehnt, und nichts im Log sah danach aus.
|
||||
const { bearer } = await import('./auth.ts');
|
||||
assert.equal(bearer('Bearer 33qg61wQf_ad-x9v'), '33qg61wQf_ad-x9v');
|
||||
assert.equal(bearer('Bearer abc'), 'abc');
|
||||
assert.equal(bearer('Basic abc'), null);
|
||||
assert.equal(bearer('Bearer'), null);
|
||||
assert.equal(bearer(undefined), null);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// Zugangskontrolle. Nur noetig, seit der Dienst zentral laeuft: solange er an
|
||||
// 127.0.0.1 hing, war "wer am Rechner sitzt, darf" die ganze Regel.
|
||||
//
|
||||
// Zwei Wege, weil es zwei Arten von Besuchern gibt:
|
||||
//
|
||||
// Mensch -> Passwort, einmal eingeben, danach ein Sitzungs-Cookie.
|
||||
// Gilt fuer die Uebersicht, die Einrichtung und den Status.
|
||||
// OBS -> Schluessel in der Adresse (?key=...). Eine Browser-Source
|
||||
// kann kein Formular ausfuellen und keinen Login durchhalten;
|
||||
// ein Geheimnis in der URL ist hier der einzige Weg, der ohne
|
||||
// Handgriff mitten im Stream auskommt.
|
||||
//
|
||||
// Der Schluessel steht damit in jedem OBS-Profil und in jedem Screenshot der
|
||||
// Einstellungen. Deshalb kann er nur lesen, nie schreiben, und er laesst sich
|
||||
// auf der Uebersichtsseite neu wuerfeln.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type http from 'node:http';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { readSecrets, updateSecrets } from '#twitch/secrets.ts';
|
||||
|
||||
const log = logger('overlay:zugang');
|
||||
|
||||
/** Wie lange eine Anmeldung gilt. Danach einmal neu eintippen. */
|
||||
const SESSION_MS = 30 * 24 * 3_600_000;
|
||||
const COOKIE = 'abdulive_sitzung';
|
||||
|
||||
export type Zugang = {
|
||||
/** Darf diese Anfrage eine geschuetzte Seite sehen? */
|
||||
istAngemeldet: (req: http.IncomingMessage) => boolean;
|
||||
/** Stimmt der Schluessel in der Adresse? Angemeldete duerfen auch ohne. */
|
||||
darfOverlay: (req: http.IncomingMessage, url: URL) => boolean;
|
||||
/** Prueft das Passwort und gibt bei Erfolg den Cookie-Kopf zurueck. */
|
||||
anmelden: (passwort: string) => string | null;
|
||||
abmelden: () => string;
|
||||
overlayKey: () => string;
|
||||
neuerOverlayKey: () => string;
|
||||
/** Schluessel des Stream-PCs, im Kopf `authorization: Bearer ...`. */
|
||||
darfFern: (req: http.IncomingMessage) => boolean;
|
||||
fernKey: () => string;
|
||||
neuerFernKey: () => string;
|
||||
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
|
||||
hatPasswort: () => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vergleich in konstanter Zeit. Bei einem einzelnen Passwortfeld ist der
|
||||
* Zeitunterschied kaum messbar, aber das ist kein Grund, ihn zu verschenken.
|
||||
*/
|
||||
function gleich(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ab.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
function hash(passwort: string, salt: string): string {
|
||||
return crypto.scryptSync(passwort, salt, 32).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Cookie traegt sein eigenes Ablaufdatum und eine Signatur darueber.
|
||||
* Der Server merkt sich keine Sitzungen.
|
||||
*
|
||||
* Der Grund ist eine gelernte Lektion: eine Liste im Arbeitsspeicher ist
|
||||
* hinfaellig, sobald der Dienst neu startet - und er startet bei jedem
|
||||
* Ausrollen neu. Wer sich nach jeder Aenderung neu anmelden muss, waehlt
|
||||
* irgendwann ein kuerzeres Passwort.
|
||||
*
|
||||
* Mit signiert wird auch der Passwort-Hash. Ein neues Passwort macht damit
|
||||
* alle ausgegebenen Cookies ungueltig, ohne dass es eine Abmeldeliste braucht.
|
||||
*/
|
||||
export function sitzungToken(key: Buffer, passwordHash: string, bisMs: number): string {
|
||||
const bis = String(bisMs);
|
||||
return `${Buffer.from(bis).toString('base64url')}.${signatur(key, passwordHash, bis)}`;
|
||||
}
|
||||
|
||||
export function sitzungGueltig(
|
||||
token: string | undefined, key: Buffer, passwordHash: string, jetztMs = Date.now()
|
||||
): boolean {
|
||||
if (!token) return false;
|
||||
const [bisB64, sig] = token.split('.');
|
||||
if (!bisB64 || !sig) return false;
|
||||
const bis = Buffer.from(bisB64, 'base64url').toString('utf8');
|
||||
if (!/^\d+$/.test(bis) || Number(bis) < jetztMs) return false;
|
||||
return gleich(sig, signatur(key, passwordHash, bis));
|
||||
}
|
||||
|
||||
function signatur(key: Buffer, passwordHash: string, bis: string): string {
|
||||
return crypto.createHmac('sha256', key)
|
||||
.update(`${bis}|${passwordHash}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
/** Der Schluessel aus `authorization: Bearer ...`, sonst `null`. */
|
||||
export function bearer(kopf: string | undefined): string | null {
|
||||
return /^Bearer\s+(\S+)$/.exec(kopf ?? '')?.[1] ?? null;
|
||||
}
|
||||
|
||||
function cookies(req: http.IncomingMessage): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const teil of (req.headers.cookie ?? '').split(';')) {
|
||||
const i = teil.indexOf('=');
|
||||
if (i > 0) out[teil.slice(0, i).trim()] = decodeURIComponent(teil.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createZugang(): Zugang {
|
||||
function store() {
|
||||
const s = readSecrets();
|
||||
return s.web ?? {};
|
||||
}
|
||||
|
||||
// Beim ersten Start einen Overlay-Schluessel wuerfeln. Ohne einen waere
|
||||
// jede Browser-Source-Adresse zu erraten.
|
||||
function key(): string {
|
||||
const vorhanden = store().overlayKey;
|
||||
if (vorhanden) return vorhanden;
|
||||
const neu = crypto.randomBytes(18).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||
log.info('Neuer Overlay-Schluessel erzeugt - die Adressen stehen auf der Uebersichtsseite');
|
||||
return neu;
|
||||
}
|
||||
|
||||
function fernKey(): string {
|
||||
const vorhanden = store().fernKey;
|
||||
if (vorhanden) return vorhanden;
|
||||
const neu = crypto.randomBytes(24).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
|
||||
return neu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird
|
||||
* einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren,
|
||||
* sind nur die Anmeldungen weg.
|
||||
*/
|
||||
function sitzungsKey(): Buffer {
|
||||
const vorhanden = store().sessionKey;
|
||||
if (vorhanden) return Buffer.from(vorhanden, 'base64');
|
||||
const neu = crypto.randomBytes(32);
|
||||
updateSecrets((s) => { s.web = { ...s.web, sessionKey: neu.toString('base64') }; });
|
||||
return neu;
|
||||
}
|
||||
|
||||
function istAngemeldet(req: http.IncomingMessage): boolean {
|
||||
const hash = store().passwordHash;
|
||||
if (!hash) return true; // noch nichts eingerichtet
|
||||
return sitzungGueltig(cookies(req)[COOKIE], sitzungsKey(), hash);
|
||||
}
|
||||
|
||||
return {
|
||||
hatPasswort: () => Boolean(store().passwordHash),
|
||||
istAngemeldet,
|
||||
|
||||
darfOverlay(req, url) {
|
||||
const gegeben = url.searchParams.get('key');
|
||||
if (gegeben && gleich(gegeben, key())) return true;
|
||||
// Wer angemeldet ist, darf die Seiten auch im Browser ansehen - sonst
|
||||
// muesste man zum Nachschauen jedes Mal den Schluessel anhaengen.
|
||||
return istAngemeldet(req);
|
||||
},
|
||||
|
||||
anmelden(passwort) {
|
||||
const s = store();
|
||||
if (!s.passwordHash || !s.passwordSalt) return null;
|
||||
if (!gleich(hash(passwort, s.passwordSalt), s.passwordHash)) {
|
||||
log.warn('Fehlgeschlagene Anmeldung');
|
||||
return null;
|
||||
}
|
||||
const token = sitzungToken(sitzungsKey(), s.passwordHash, Date.now() + SESSION_MS);
|
||||
// Kein `Secure`: NPM beendet TLS, und der Dienst selbst spricht HTTP.
|
||||
// Das Cookie geht trotzdem nur ueber die HTTPS-Adresse raus, weil es
|
||||
// keine andere gibt, die von aussen erreichbar ist.
|
||||
return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MS / 1000}`;
|
||||
},
|
||||
|
||||
abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||
overlayKey: key,
|
||||
|
||||
darfFern(req) {
|
||||
const gegeben = bearer(req.headers.authorization);
|
||||
return Boolean(gegeben && gleich(gegeben, fernKey()));
|
||||
},
|
||||
fernKey,
|
||||
neuerFernKey() {
|
||||
const neu = crypto.randomBytes(24).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
|
||||
log.info('Schluessel fuer den Stream-PC neu gewuerfelt - dort neu eintragen');
|
||||
return neu;
|
||||
},
|
||||
neuerOverlayKey() {
|
||||
const neu = crypto.randomBytes(18).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||
log.info('Overlay-Schluessel neu gewuerfelt - die alten Adressen sind damit tot');
|
||||
return neu;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt das Passwort. Wird von `abdulive web:passwort` aufgerufen, nicht ueber
|
||||
* das Netz - ein Dienst, bei dem man das Passwort ueber die offene
|
||||
* Weboberflaeche setzen kann, hat kein Passwort.
|
||||
*/
|
||||
export function setzePasswort(passwort: string): void {
|
||||
if (passwort.length < 8) throw new Error('Mindestens acht Zeichen.');
|
||||
const salt = crypto.randomBytes(16).toString('base64');
|
||||
updateSecrets((s) => {
|
||||
s.web = { ...s.web, passwordSalt: salt, passwordHash: hash(passwort, salt) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Der Bus entscheidet, was eine frisch verbundene Browser-Source sieht.
|
||||
// Danach richtet sich, ob das Overlay nach einem Neustart vollstaendig ist
|
||||
// oder leer im Bild steht - und ob eine geloeschte Nachricht geloescht bleibt.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBus, type ChatEvent, type OverlayEvent } from './bus.ts';
|
||||
|
||||
function nachricht(over: Partial<ChatEvent> = {}): ChatEvent {
|
||||
return {
|
||||
kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin',
|
||||
text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false,
|
||||
sub: false, broadcaster: false, bits: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function schnappschuss(bus: ReturnType<typeof createBus>) {
|
||||
const s = bus.snapshot();
|
||||
assert.equal(s.kind, 'state');
|
||||
return s as Extract<OverlayEvent, { kind: 'state' }>;
|
||||
}
|
||||
|
||||
test('ein neuer Zuhoerer bekommt den bisherigen Chat', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish(nachricht({ id: 'b' }));
|
||||
|
||||
const s = schnappschuss(bus);
|
||||
assert.deepEqual(s.chat.map((m) => m.id), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('der Rueckblick waechst nicht unbegrenzt', () => {
|
||||
const bus = createBus();
|
||||
for (let i = 0; i < 100; i++) bus.publish(nachricht({ id: `m${i}` }));
|
||||
|
||||
const s = schnappschuss(bus);
|
||||
assert.equal(s.chat.length, 30);
|
||||
assert.equal(s.chat[s.chat.length - 1]?.id, 'm99', 'die neuesten bleiben');
|
||||
});
|
||||
|
||||
test('der zuletzt gemeldete Stream-Zustand steht im Schnappschuss', () => {
|
||||
const bus = createBus();
|
||||
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
|
||||
viewers: 12, startedAt: '2026-09-17T18:00:00Z' });
|
||||
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
|
||||
viewers: 19, startedAt: '2026-09-17T18:00:00Z' });
|
||||
|
||||
assert.equal(schnappschuss(bus).stream.viewers, 19);
|
||||
});
|
||||
|
||||
test('eine geloeschte Nachricht verschwindet auch aus dem Rueckblick', () => {
|
||||
// Sonst holt der naechste Neustart der Browser-Source sie zurueck ins Bild -
|
||||
// und zwar genau die eine, die ein Mod entfernt hat.
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish(nachricht({ id: 'b' }));
|
||||
bus.publish({ kind: 'clear', messageId: 'a' });
|
||||
|
||||
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
|
||||
});
|
||||
|
||||
test('ein gesperrter Chatter verschwindet mit allen Nachrichten', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a', userId: 'u1' }));
|
||||
bus.publish(nachricht({ id: 'b', userId: 'u2' }));
|
||||
bus.publish(nachricht({ id: 'c', userId: 'u1' }));
|
||||
bus.publish({ kind: 'clear', userId: 'u1' });
|
||||
|
||||
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
|
||||
});
|
||||
|
||||
test('ein geleerter Chat leert auch den Rueckblick', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish({ kind: 'clear' });
|
||||
assert.deepEqual(schnappschuss(bus).chat, []);
|
||||
});
|
||||
|
||||
test('alte Einblendungen werden nicht endlos aufbewahrt', () => {
|
||||
const bus = createBus();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
bus.publish({ kind: 'alert', type: 'sub', id: `a${i}`, name: 'x' });
|
||||
}
|
||||
assert.equal(schnappschuss(bus).alerts.length, 5);
|
||||
});
|
||||
|
||||
test('ein Zuhoerer, der wirft, reisst die anderen nicht mit', () => {
|
||||
// Eine Browser-Source, deren Verbindung gerade zusammengebrochen ist, darf
|
||||
// den Chat nicht fuer alle anderen anhalten.
|
||||
const bus = createBus();
|
||||
const gesehen: string[] = [];
|
||||
bus.subscribe(() => { throw new Error('Verbindung weg'); });
|
||||
bus.subscribe((ev) => { gesehen.push(ev.kind); });
|
||||
|
||||
bus.publish(nachricht());
|
||||
assert.deepEqual(gesehen, ['chat']);
|
||||
});
|
||||
|
||||
test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
|
||||
const bus = createBus();
|
||||
let n = 0;
|
||||
const ab = bus.subscribe(() => { n++; });
|
||||
bus.publish(nachricht());
|
||||
ab();
|
||||
bus.publish(nachricht());
|
||||
|
||||
assert.equal(n, 1);
|
||||
assert.equal(bus.clients(), 0);
|
||||
});
|
||||
|
||||
test('der Schnappschuss traegt den letzten Stand der Liegestuetze', () => {
|
||||
const bus = createBus();
|
||||
assert.deepEqual(schnappschuss(bus).liegestuetze, { offen: 0, gemacht: 0 });
|
||||
bus.publish({ kind: 'liegestuetze', offen: 20, gemacht: 0, dazu: 10, name: 'Kevin' });
|
||||
bus.publish({ kind: 'liegestuetze', offen: 15, gemacht: 5 });
|
||||
assert.deepEqual(schnappschuss(bus).liegestuetze, { offen: 15, gemacht: 5 });
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// Der Ereignisbus zwischen Daemon und Browser-Sources.
|
||||
//
|
||||
// Warum Server-Sent Events und nicht WebSocket, wie in chat-control.md
|
||||
// skizziert: Node bringt einen WebSocket-*Client* mit, aber keinen Server -
|
||||
// den muesste man selbst schreiben, inklusive Handshake und Rahmenformat.
|
||||
// SSE ist einfacher HTTP, `EventSource` verbindet sich von allein neu, und
|
||||
// die Richtung stimmt: das Overlay hoert nur zu, es sendet nie.
|
||||
//
|
||||
// Der haeufigste Fehler bei solchen Overlays ist die Browser-Source, die nach
|
||||
// einem Serverneustart tot im Bild haengt. Dagegen stehen hier drei Dinge:
|
||||
// der eingebaute Reconnect von EventSource, ein Herzschlag alle paar
|
||||
// Sekunden, und der Zustands-Schnappschuss, den jeder neue Client als erstes
|
||||
// bekommt - damit ist das Overlay nach dem Neuverbinden sofort wieder
|
||||
// vollstaendig und nicht erst nach der naechsten Chatnachricht.
|
||||
|
||||
export type ChatEvent = {
|
||||
kind: 'chat';
|
||||
/** msg-id aus IRC - dieselbe ID, mit der CLEARMSG spaeter loeschen kann. */
|
||||
id: string;
|
||||
userId: string;
|
||||
login: string;
|
||||
name: string;
|
||||
text: string;
|
||||
color: string | null;
|
||||
emotes: Array<{ id: string; name: string; count: number }>;
|
||||
/** GIFs aus Twitchs Auswahlfenster, mit dem Zeichenbereich im Text. */
|
||||
gifs: Array<{ id: string; url: string; from: number; to: number }>;
|
||||
first: boolean;
|
||||
mod: boolean;
|
||||
sub: boolean;
|
||||
broadcaster: boolean;
|
||||
bits: number;
|
||||
};
|
||||
|
||||
export type AlertKind =
|
||||
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
|
||||
/**
|
||||
* Kanalpunkt-Einloesung. Bewusst eine eigene Art und nicht die des Abos:
|
||||
* eine eingeloeste Belohnung als "Neues Abo" einzublenden ist eine
|
||||
* Falschmeldung im Bild, und zwar eine, die in der Aufzeichnung bleibt.
|
||||
*/
|
||||
| 'reward';
|
||||
|
||||
export type AlertEvent = {
|
||||
kind: 'alert';
|
||||
type: AlertKind;
|
||||
id: string;
|
||||
name: string;
|
||||
/** Bits, Zuschauer beim Raid, Anzahl verschenkter Abos. */
|
||||
amount?: number;
|
||||
months?: number;
|
||||
tier?: string;
|
||||
recipient?: string;
|
||||
message?: string;
|
||||
/** Titel der eingeloesten Belohnung. Nur bei `reward`. */
|
||||
reward?: string;
|
||||
};
|
||||
|
||||
export type StreamEvent = {
|
||||
kind: 'stream';
|
||||
live: boolean;
|
||||
title: string;
|
||||
category: string;
|
||||
viewers: number;
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Neuer Stand der Liegestuetze: offen und in diesem Stream gemacht. `dazu`
|
||||
* und `name` nur bei einer Einloesung - eine Korrektur von Hand soll nicht
|
||||
* aussehen wie eine.
|
||||
*/
|
||||
export type LiegestuetzeEvent = {
|
||||
kind: 'liegestuetze';
|
||||
offen: number;
|
||||
gemacht: number;
|
||||
dazu?: number;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Was der Stream-PC ueber OBS meldet. Kommt ueber die Fernsteuerung
|
||||
* (fern.ts) herein und steht im Schnappschuss, damit das Pult nach dem
|
||||
* Neuverbinden sofort die Szenen kennt.
|
||||
*/
|
||||
export type ObsStandEvent = {
|
||||
kind: 'obs';
|
||||
/** Ist der Stream-PC ueberhaupt mit dem Hub verbunden? */
|
||||
pc: boolean;
|
||||
/** Und hat er OBS erreicht? */
|
||||
obs: boolean;
|
||||
szenen: string[];
|
||||
aktuell: string | null;
|
||||
/** Toneingaenge, die sich stummschalten lassen. */
|
||||
ton: Array<{ name: string; stumm: boolean }>;
|
||||
/** Laeuft der Wiedergabepuffer? Ohne ihn gibt es keinen Clip. */
|
||||
puffer: boolean;
|
||||
};
|
||||
|
||||
export type OverlayEvent =
|
||||
| ChatEvent
|
||||
| AlertEvent
|
||||
| StreamEvent
|
||||
| { kind: 'hype'; score: number; rate: number }
|
||||
/**
|
||||
* Konfetti als eigenes Ereignis, nicht als Nebenwirkung einer Einblendung.
|
||||
* So muss die Effektseite nicht wissen, welche Belohnung welche Aktion
|
||||
* ausloest - das entscheidet der Hub.
|
||||
*/
|
||||
| { kind: 'konfetti'; staerke: number }
|
||||
/**
|
||||
* Vorzulesender Text. Geht nur raus, wenn er freigegeben wurde oder die
|
||||
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
|
||||
* (apps/hub/src/rewards.ts), nicht hier.
|
||||
*/
|
||||
| {
|
||||
kind: 'tts'; id: string; name: string; text: string;
|
||||
/** Liegt unter /tts/audio/<id> bereit. Ohne Ton wird nur angezeigt. */
|
||||
audio?: boolean;
|
||||
}
|
||||
/** Alles sofort still. Fuer den Panikschalter. */
|
||||
| { kind: 'still' }
|
||||
| LiegestuetzeEvent
|
||||
| { kind: 'scene'; scene: string }
|
||||
/**
|
||||
* Ein Sound vom Pult. Wie beim Vorlesen geht nur die ID hinaus; die Datei
|
||||
* holt sich die Seite /sounds einzeln, damit Chat und Statusleiste keine
|
||||
* Megabytes mitschleppen.
|
||||
*/
|
||||
| { kind: 'sound'; id: string; name: string; laut: number }
|
||||
/** Alle laufenden Sounds abbrechen. Vorlesen bleibt davon unberuehrt. */
|
||||
| { kind: 'sound-stopp' }
|
||||
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
|
||||
| { kind: 'sounds-geaendert' }
|
||||
| ObsStandEvent
|
||||
| { kind: 'clear'; userId?: string; messageId?: string }
|
||||
| { kind: 'beat'; t: number }
|
||||
| {
|
||||
kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[];
|
||||
/** Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */
|
||||
liegestuetze: { offen: number; gemacht: number };
|
||||
obs: ObsStandEvent;
|
||||
};
|
||||
|
||||
/**
|
||||
* So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
|
||||
* braucht keine Seite: der Chat zeigt ein gutes Dutzend, und ein Alarm von
|
||||
* vor zehn Minuten soll nach einem Neustart gerade nicht noch einmal
|
||||
* durchlaufen.
|
||||
*/
|
||||
const KEEP_CHAT = 30;
|
||||
const KEEP_ALERTS = 5;
|
||||
|
||||
export type Bus = {
|
||||
publish: (ev: OverlayEvent) => void;
|
||||
/** Meldet einen Zuhoerer an. Der Rueckgabewert meldet ihn wieder ab. */
|
||||
subscribe: (fn: (ev: OverlayEvent) => void) => () => void;
|
||||
/** Der Zustand, den ein neuer Client als erstes bekommt. */
|
||||
snapshot: () => OverlayEvent;
|
||||
clients: () => number;
|
||||
};
|
||||
|
||||
export function createBus(): Bus {
|
||||
const listeners = new Set<(ev: OverlayEvent) => void>();
|
||||
const chat: ChatEvent[] = [];
|
||||
const alerts: AlertEvent[] = [];
|
||||
let stream: StreamEvent = {
|
||||
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||
};
|
||||
let liegestuetze = { offen: 0, gemacht: 0 };
|
||||
let obs: ObsStandEvent = {
|
||||
kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
|
||||
};
|
||||
|
||||
return {
|
||||
publish(ev) {
|
||||
// Mitschreiben, bevor verteilt wird: ein Client, der genau jetzt
|
||||
// verbindet, soll dasselbe sehen wie einer, der schon verbunden war.
|
||||
if (ev.kind === 'chat') {
|
||||
chat.push(ev);
|
||||
if (chat.length > KEEP_CHAT) chat.shift();
|
||||
} else if (ev.kind === 'alert') {
|
||||
alerts.push(ev);
|
||||
if (alerts.length > KEEP_ALERTS) alerts.shift();
|
||||
} else if (ev.kind === 'stream') {
|
||||
stream = ev;
|
||||
} else if (ev.kind === 'liegestuetze') {
|
||||
liegestuetze = { offen: ev.offen, gemacht: ev.gemacht };
|
||||
} else if (ev.kind === 'obs') {
|
||||
obs = ev;
|
||||
} else if (ev.kind === 'clear') {
|
||||
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
|
||||
// naechste Neustart der Browser-Source es zurueck ins Bild.
|
||||
for (let i = chat.length - 1; i >= 0; i--) {
|
||||
const m = chat[i]!;
|
||||
if ((ev.messageId && m.id === ev.messageId) ||
|
||||
(ev.userId && m.userId === ev.userId)) chat.splice(i, 1);
|
||||
}
|
||||
if (!ev.messageId && !ev.userId) chat.length = 0;
|
||||
}
|
||||
|
||||
for (const fn of listeners) {
|
||||
// Ein kaputter Zuhoerer darf die anderen nicht mitreissen.
|
||||
try { fn(ev); } catch { /* der Transport meldet es selbst */ }
|
||||
}
|
||||
},
|
||||
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
},
|
||||
|
||||
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze, obs }),
|
||||
clients: () => listeners.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Dauerbetrieb mit erfundenen Ereignissen: erfundener Chat und alle paar
|
||||
// Sekunden eine Einblendung.
|
||||
//
|
||||
// Fuer `abdulive overlay --demo` und fuer den Knopf "Vorfuehrung" auf der
|
||||
// Uebersichtsseite. Die einzelnen Ereignisse kommen aus test-events.ts - die
|
||||
// Knoepfe und der Dauerbetrieb sollen dasselbe zeigen.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import type { Bus } from './bus.ts';
|
||||
import { testEvent, zufall, type TestArt } from './test-events.ts';
|
||||
|
||||
const log = logger('overlay:demo');
|
||||
|
||||
const ALARME: TestArt[] = ['sub', 'resub', 'subgift', 'massgift', 'raid', 'cheer', 'first'];
|
||||
const NACHRICHTEN: TestArt[] = ['chat', 'chat', 'chat', 'chat-emote', 'chat-emote', 'chat-erstmals'];
|
||||
|
||||
/**
|
||||
* Speist den Bus, bis `signal` abbricht.
|
||||
*
|
||||
* Der Stream-Zustand wird mitgesetzt, damit Statusleiste und Pausenbild etwas
|
||||
* zu zeigen haben. Genau deshalb darf das nicht laufen, waehrend ein echter
|
||||
* Stream laeuft - der Aufrufer prueft das.
|
||||
*/
|
||||
export function startDemo(bus: Bus, signal: AbortSignal): void {
|
||||
log.info('Vorfuehrmodus: erfundener Chat und alle paar Sekunden eine Einblendung');
|
||||
|
||||
let viewers = 17;
|
||||
const seit = new Date(Date.now() - 74 * 60_000).toISOString();
|
||||
|
||||
const stand = (): void => {
|
||||
viewers = Math.max(3, viewers + Math.round((Math.random() - 0.45) * 4));
|
||||
bus.publish({
|
||||
kind: 'stream', live: true,
|
||||
title: 'Elden Ring, aber nur mit Fäusten - Tag 3',
|
||||
category: 'Elden Ring',
|
||||
viewers, startedAt: seit,
|
||||
});
|
||||
};
|
||||
stand();
|
||||
|
||||
const sende = (art: TestArt): void => {
|
||||
const ev = testEvent(art);
|
||||
if (ev) bus.publish(ev);
|
||||
};
|
||||
|
||||
const chat = setInterval(() => sende(zufall(NACHRICHTEN)), 1400);
|
||||
const alarm = setInterval(() => sende(zufall(ALARME)), 9000);
|
||||
const puls = setInterval(stand, 20_000);
|
||||
// Ab und zu eine Chat-Spitze, damit der Emote-Regen auch mal laeuft.
|
||||
const spitze = setInterval(() => sende('hype'), 31_000);
|
||||
|
||||
signal.addEventListener('abort', () => {
|
||||
clearInterval(chat); clearInterval(alarm); clearInterval(puls); clearInterval(spitze);
|
||||
log.info('Vorfuehrmodus beendet');
|
||||
}, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Die Leitung zum Stream-PC, Hub-Seite. Geprueft wird ohne Netz: die
|
||||
// SSE-Antwort ist ein nachgebautes Objekt, das mitschreibt.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type http from 'node:http';
|
||||
import { createBus, type ObsStandEvent } from './bus.ts';
|
||||
import { createFernsteuerung, standAus } from './fern.ts';
|
||||
import { befehlAus, sichererPfad } from './server.ts';
|
||||
|
||||
function leitung() {
|
||||
const req = new EventEmitter();
|
||||
const res = Object.assign(new EventEmitter(), {
|
||||
geschrieben: [] as string[],
|
||||
writeHead() { return this; },
|
||||
write(s: string) { this.geschrieben.push(s); return true; },
|
||||
end() { req.emit('close'); },
|
||||
});
|
||||
const befehle = (): Array<Record<string, unknown>> => res.geschrieben
|
||||
.filter((s) => s.startsWith('data: '))
|
||||
.map((s) => JSON.parse(s.slice(6)) as Record<string, unknown>)
|
||||
.filter((b) => b['art'] !== 'beat');
|
||||
return {
|
||||
req: req as unknown as http.IncomingMessage,
|
||||
res: res as unknown as http.ServerResponse,
|
||||
befehle,
|
||||
trennen: () => req.emit('close'),
|
||||
};
|
||||
}
|
||||
|
||||
function obsStand(bus: ReturnType<typeof createBus>): ObsStandEvent {
|
||||
const s = bus.snapshot();
|
||||
if (s.kind !== 'state') throw new Error('kein Schnappschuss');
|
||||
return s.obs;
|
||||
}
|
||||
|
||||
test('ohne Stream-PC sagt ein Befehl ehrlich, dass es nicht ging', async () => {
|
||||
const fern = createFernsteuerung(createBus());
|
||||
const a = await fern.senden({ art: 'szene', szene: 'Pause' });
|
||||
assert.equal(a.ok, false);
|
||||
assert.match(a.text, /nicht verbunden/);
|
||||
});
|
||||
|
||||
test('Befehl geht raus, Antwort kommt zurueck', async () => {
|
||||
const bus = createBus();
|
||||
const fern = createFernsteuerung(bus);
|
||||
const l = leitung();
|
||||
fern.annehmen(l.req, l.res);
|
||||
assert.equal(obsStand(bus).pc, true);
|
||||
|
||||
const warten = fern.senden({ art: 'szene', szene: 'Just Chatting' });
|
||||
const [b] = l.befehle();
|
||||
assert.equal(b?.['art'], 'szene');
|
||||
assert.equal(b?.['szene'], 'Just Chatting');
|
||||
|
||||
fern.antwort({ id: b!['id'], ok: true, text: 'Szene: Just Chatting' });
|
||||
assert.deepEqual(await warten, { ok: true, text: 'Szene: Just Chatting' });
|
||||
});
|
||||
|
||||
test('reisst die Leitung ab, wartet niemand weiter', async () => {
|
||||
const bus = createBus();
|
||||
const fern = createFernsteuerung(bus);
|
||||
const l = leitung();
|
||||
fern.annehmen(l.req, l.res);
|
||||
fern.melden({ obs: true, szenen: ['A'], aktuell: 'A', ton: [], puffer: true });
|
||||
assert.deepEqual(obsStand(bus).szenen, ['A']);
|
||||
|
||||
const warten = fern.senden({ art: 'clip' });
|
||||
l.trennen();
|
||||
const a = await warten;
|
||||
assert.equal(a.ok, false);
|
||||
assert.equal(fern.verbunden(), false);
|
||||
// Alte Szenen stehen sonst als Knoepfe da, die nichts mehr tun.
|
||||
assert.deepEqual(obsStand(bus), { kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
|
||||
});
|
||||
|
||||
test('ein zweiter PC ersetzt den ersten', () => {
|
||||
const bus = createBus();
|
||||
const fern = createFernsteuerung(bus);
|
||||
const alt = leitung();
|
||||
const neu = leitung();
|
||||
fern.annehmen(alt.req, alt.res);
|
||||
fern.annehmen(neu.req, neu.res);
|
||||
// Das Schliessen der alten Leitung darf die neue nicht als getrennt melden.
|
||||
assert.equal(fern.verbunden(), true);
|
||||
assert.equal(obsStand(bus).pc, true);
|
||||
void fern.senden({ art: 'clip' });
|
||||
assert.equal(neu.befehle().length, 1);
|
||||
assert.equal(alt.befehle().length, 0);
|
||||
});
|
||||
|
||||
test('der Stand vom PC wird auf die erwartete Form gebracht', () => {
|
||||
assert.deepEqual(standAus({
|
||||
obs: true, szenen: ['A', 7, 'B'], aktuell: 'A',
|
||||
ton: [{ name: 'Mikro', stumm: true }, { name: 3 }, null], puffer: 'ja',
|
||||
}), { obs: true, szenen: ['A', 'B'], aktuell: 'A', ton: [{ name: 'Mikro', stumm: true }], puffer: false });
|
||||
assert.deepEqual(standAus('quatsch'), { obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
|
||||
});
|
||||
|
||||
test('vom Pult kommen nur bekannte Befehle durch', () => {
|
||||
const f = (o: Record<string, string>): URLSearchParams => new URLSearchParams(o);
|
||||
assert.deepEqual(befehlAus(f({ was: 'szene', szene: 'Pause' })), { art: 'szene', szene: 'Pause' });
|
||||
assert.deepEqual(befehlAus(f({ was: 'stumm', quelle: 'Mikro', stumm: '1' })),
|
||||
{ art: 'stumm', quelle: 'Mikro', stumm: true });
|
||||
assert.deepEqual(befehlAus(f({ was: 'clip' })), { art: 'clip' });
|
||||
assert.equal(befehlAus(f({ was: 'szene' })), null);
|
||||
assert.equal(befehlAus(f({ was: 'StopStream' })), null);
|
||||
});
|
||||
|
||||
test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
|
||||
assert.equal(sichererPfad('/pult'), '/pult');
|
||||
assert.equal(sichererPfad('https://boese.example'), '/');
|
||||
assert.equal(sichererPfad('//boese.example'), '/');
|
||||
assert.equal(sichererPfad(null), '/');
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Fernsteuerung fuer OBS, Hub-Seite.
|
||||
//
|
||||
// OBS laeuft auf dem Stream-PC, das Pult am Handy spricht mit dem Hub. Der
|
||||
// Hub kommt an den Stream-PC nicht heran - der steht hinter einem fremden
|
||||
// Router. Also waehlt sich der PC beim Hub ein, nicht umgekehrt:
|
||||
//
|
||||
// Handy --POST /pult/obs--> Hub --SSE /fern/befehle--> Stream-PC --> OBS
|
||||
// Handy <--SSE /events---- Hub <--POST /fern/stand---- Stream-PC
|
||||
//
|
||||
// Dieselbe Bauform wie bei den Browser-Sources (SSE statt WebSocket, siehe
|
||||
// README), nur mit einem eigenen Schluessel: der fuer OBS darf nur lesen,
|
||||
// dieser hier schaltet Szenen um.
|
||||
//
|
||||
// Jeder Befehl wartet auf eine Antwort vom PC. Ein Knopf, der "erledigt"
|
||||
// meldet, waehrend OBS gar nicht erreichbar war, ist am Handy schlimmer als
|
||||
// einer, der ehrlich sagt, dass es nicht ging.
|
||||
|
||||
import type http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import type { Bus, ObsStandEvent } from './bus.ts';
|
||||
|
||||
const log = logger('overlay:fern');
|
||||
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const ANTWORT_MS = 6_000;
|
||||
|
||||
export type FernBefehl =
|
||||
| { art: 'szene'; szene: string }
|
||||
| { art: 'clip' }
|
||||
| { art: 'stumm'; quelle: string; stumm: boolean };
|
||||
|
||||
export type FernAntwort = { ok: boolean; text: string };
|
||||
|
||||
export type Fernsteuerung = {
|
||||
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
|
||||
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
||||
/** Stand von OBS, wie ihn der PC meldet. */
|
||||
melden: (roh: unknown) => void;
|
||||
antwort: (roh: unknown) => void;
|
||||
senden: (b: FernBefehl) => Promise<FernAntwort>;
|
||||
verbunden: () => boolean;
|
||||
};
|
||||
|
||||
const LEER: Omit<ObsStandEvent, 'kind'> = {
|
||||
pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
|
||||
};
|
||||
|
||||
/** Was vom PC kommt, auf die erwartete Form bringen. Fremdes faellt weg. */
|
||||
export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
|
||||
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
|
||||
const text = (v: unknown): string | null => (typeof v === 'string' && v.length <= 200 ? v : null);
|
||||
const szenen = Array.isArray(r['szenen'])
|
||||
? r['szenen'].map(text).filter((s): s is string => s !== null).slice(0, 100) : [];
|
||||
const ton = Array.isArray(r['ton'])
|
||||
? r['ton'].flatMap((t) => {
|
||||
const name = text((t as Record<string, unknown>)?.['name']);
|
||||
return name === null ? [] : [{ name, stumm: (t as Record<string, unknown>)['stumm'] === true }];
|
||||
}).slice(0, 50) : [];
|
||||
return {
|
||||
obs: r['obs'] === true,
|
||||
szenen,
|
||||
aktuell: text(r['aktuell']),
|
||||
ton,
|
||||
puffer: r['puffer'] === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFernsteuerung(bus: Bus): Fernsteuerung {
|
||||
let leitung: http.ServerResponse | null = null;
|
||||
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
|
||||
|
||||
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
|
||||
bus.publish({ kind: 'obs', ...stand });
|
||||
};
|
||||
|
||||
return {
|
||||
annehmen(req, res) {
|
||||
if (leitung) {
|
||||
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
|
||||
// Der neuere gewinnt, meist ist der alte ohnehin eine tote Leitung.
|
||||
log.info('Stream-PC hat sich neu verbunden, alte Leitung geschlossen');
|
||||
leitung.end();
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
leitung = res;
|
||||
log.info('Stream-PC verbunden');
|
||||
veroeffentlichen({ ...LEER, pc: true });
|
||||
|
||||
const beat = setInterval(
|
||||
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
|
||||
HEARTBEAT_MS);
|
||||
beat.unref();
|
||||
|
||||
const ende = (): void => {
|
||||
clearInterval(beat);
|
||||
if (leitung !== res) return; // schon durch eine neuere ersetzt
|
||||
leitung = null;
|
||||
log.info('Stream-PC getrennt');
|
||||
veroeffentlichen(LEER);
|
||||
for (const [id, o] of offen) {
|
||||
clearTimeout(o.timer);
|
||||
o.fertig({ ok: false, text: 'Verbindung zum Stream-PC abgerissen.' });
|
||||
offen.delete(id);
|
||||
}
|
||||
};
|
||||
req.on('close', ende);
|
||||
res.on('error', ende);
|
||||
},
|
||||
|
||||
melden(roh) {
|
||||
if (!leitung) return;
|
||||
veroeffentlichen({ pc: true, ...standAus(roh) });
|
||||
},
|
||||
|
||||
antwort(roh) {
|
||||
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
|
||||
const id = typeof r['id'] === 'string' ? r['id'] : '';
|
||||
const o = offen.get(id);
|
||||
if (!o) return;
|
||||
offen.delete(id);
|
||||
clearTimeout(o.timer);
|
||||
o.fertig({
|
||||
ok: r['ok'] === true,
|
||||
text: typeof r['text'] === 'string' ? r['text'].slice(0, 300) : '',
|
||||
});
|
||||
},
|
||||
|
||||
senden(b) {
|
||||
const res = leitung;
|
||||
if (!res) {
|
||||
return Promise.resolve({
|
||||
ok: false, text: 'Der Stream-PC ist nicht verbunden. Läuft dort abdulive?',
|
||||
});
|
||||
}
|
||||
const id = crypto.randomBytes(6).toString('hex');
|
||||
return new Promise<FernAntwort>((fertig) => {
|
||||
const timer = setTimeout(() => {
|
||||
offen.delete(id);
|
||||
fertig({ ok: false, text: 'Der Stream-PC hat nicht geantwortet.' });
|
||||
}, ANTWORT_MS);
|
||||
offen.set(id, { fertig, timer });
|
||||
res.write(`data: ${JSON.stringify({ id, ...b })}\n\n`);
|
||||
});
|
||||
},
|
||||
|
||||
verbunden: () => leitung !== null,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
||||
// Der Sound-Speicher nimmt Dateien vom Handy an und liefert sie unter der
|
||||
// eigenen Adresse wieder aus. Hier wird vor allem geprueft, dass er nur Ton
|
||||
// annimmt - und dass die Liste einen Neustart uebersteht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createSoundboard, erkenneTyp, nameAusDatei, MAX_BYTES } from './sounds.ts';
|
||||
|
||||
const ordner = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-sounds-'));
|
||||
|
||||
const MP3 = Buffer.concat([Buffer.from('ID3'), Buffer.alloc(64, 1)]);
|
||||
const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(32)]);
|
||||
const M4A = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypM4A '), Buffer.alloc(32)]);
|
||||
|
||||
test('Tontypen werden an den ersten Bytes erkannt', () => {
|
||||
assert.equal(erkenneTyp(MP3), 'audio/mpeg');
|
||||
assert.equal(erkenneTyp(Buffer.from([0xff, 0xfb, 0x90, 0x64, 0, 0, 0, 0, 0, 0, 0, 0])), 'audio/mpeg');
|
||||
assert.equal(erkenneTyp(WAV), 'audio/wav');
|
||||
assert.equal(erkenneTyp(Buffer.concat([Buffer.from('OggS'), Buffer.alloc(20)])), 'audio/ogg');
|
||||
assert.equal(erkenneTyp(M4A), 'audio/mp4');
|
||||
});
|
||||
|
||||
test('HTML ist kein Ton, auch wenn der Browser es behauptet', () => {
|
||||
// Sonst waere das eine Ablage fuer beliebige Seiten unter der eigenen Adresse.
|
||||
assert.equal(erkenneTyp(Buffer.from('<!doctype html><script>alert(1)</script>')), null);
|
||||
assert.equal(erkenneTyp(Buffer.from('kurz')), null);
|
||||
const sb = createSoundboard(ordner());
|
||||
assert.throws(() => sb.hinzufuegen('x', Buffer.from('<html><body>hallo welt</body></html>')), /Kein Ton/);
|
||||
assert.equal(sb.liste().length, 0);
|
||||
});
|
||||
|
||||
test('zu gross wird abgelehnt, bevor etwas auf der Platte landet', () => {
|
||||
const dir = ordner();
|
||||
const sb = createSoundboard(dir);
|
||||
const riesig = Buffer.concat([MP3, Buffer.alloc(MAX_BYTES)]);
|
||||
assert.throws(() => sb.hinzufuegen('lied', riesig), /Zu gross/);
|
||||
assert.deepEqual(fs.readdirSync(dir), []);
|
||||
});
|
||||
|
||||
test('hochladen, abholen, nach Neustart noch da', () => {
|
||||
const dir = ordner();
|
||||
const sb = createSoundboard(dir);
|
||||
const s = sb.hinzufuegen('Tusch', MP3);
|
||||
assert.equal(s.typ, 'audio/mpeg');
|
||||
assert.ok(s.datei.endsWith('.mp3'));
|
||||
assert.deepEqual(sb.datei(s.id)?.daten, MP3);
|
||||
|
||||
const nachNeustart = createSoundboard(dir);
|
||||
assert.equal(nachNeustart.liste()[0]?.name, 'Tusch');
|
||||
assert.deepEqual(nachNeustart.datei(s.id)?.daten, MP3);
|
||||
});
|
||||
|
||||
test('aendern nimmt nur, was erlaubt ist', () => {
|
||||
const sb = createSoundboard(ordner());
|
||||
const s = sb.hinzufuegen('Tusch', MP3);
|
||||
const neu = sb.aendern(s.id, {
|
||||
name: ' Ba\ndum tss ', emoji: '🥁', farbe: 'url(javascript:x)', laut: 3,
|
||||
})!;
|
||||
assert.equal(neu.name, 'Ba dum tss');
|
||||
assert.equal(neu.emoji, '🥁');
|
||||
assert.equal(neu.farbe, s.farbe, 'freie Farbwerte waeren CSS-Einschleusung');
|
||||
assert.equal(neu.laut, 1);
|
||||
assert.equal(sb.aendern(s.id, { name: ' ' })?.name, 'Ba dum tss', 'leerer Name behaelt den alten');
|
||||
});
|
||||
|
||||
test('verschieben und loeschen', () => {
|
||||
const dir = ordner();
|
||||
const sb = createSoundboard(dir);
|
||||
const a = sb.hinzufuegen('A', MP3);
|
||||
const b = sb.hinzufuegen('B', WAV);
|
||||
sb.hinzufuegen('C', M4A);
|
||||
assert.equal(sb.verschieben(b.id, -1), true);
|
||||
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'A', 'C']);
|
||||
assert.equal(sb.verschieben(b.id, -1), false, 'vor dem ersten ist nichts');
|
||||
|
||||
assert.equal(sb.entfernen(a.id), true);
|
||||
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'C']);
|
||||
assert.equal(fs.existsSync(path.join(dir, a.datei)), false, 'die Datei geht mit');
|
||||
assert.equal(sb.entfernen(a.id), false);
|
||||
});
|
||||
|
||||
test('Name aus dem Dateinamen', () => {
|
||||
assert.equal(nameAusDatei('ba_dum_tss.mp3'), 'ba dum tss');
|
||||
assert.equal(nameAusDatei('.mp3'), 'Sound');
|
||||
assert.equal(nameAusDatei('Aufnahme 12.m4a'), 'Aufnahme 12');
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// Die Sounds fuer das Pult.
|
||||
//
|
||||
// Hochgeladen wird vom Handy aus, abgespielt in OBS ueber die Browser-Source
|
||||
// /sounds. Dazwischen liegt nur dieser Ordner:
|
||||
//
|
||||
// <db>/sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge
|
||||
// <db>/sounds/<id>.<endung> die Datei selbst
|
||||
//
|
||||
// Bewusst neben der Datenbank und nicht in ihr: ein Ordner mit Dateien laesst
|
||||
// sich von Hand durchsehen und sichern, und die Datenbank bleibt klein. Und
|
||||
// bewusst nicht unter /opt/abdulive: das wird bei jedem Ausrollen ersetzt.
|
||||
//
|
||||
// Der Dateityp wird an den ersten Bytes erkannt, nicht an dem, was der Browser
|
||||
// behauptet. Ausgeliefert wird nur, was hier als Ton erkannt wurde - sonst
|
||||
// waere das eine Ablage fuer beliebige Dateien unter der eigenen Adresse.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type Sound = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
/** Eine der FARBEN unten. Freie Werte gaebe es nur fuer CSS-Einschleusung. */
|
||||
farbe: string;
|
||||
/** 0 bis 1. Multipliziert mit der Lautstaerke der Browser-Source. */
|
||||
laut: number;
|
||||
datei: string;
|
||||
typ: string;
|
||||
bytes: number;
|
||||
seit: string;
|
||||
};
|
||||
|
||||
export const FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'] as const;
|
||||
|
||||
/** Groesser ist kein Soundeffekt mehr, sondern ein Lied. */
|
||||
export const MAX_BYTES = 8 * 1024 * 1024;
|
||||
export const MAX_SOUNDS = 120;
|
||||
|
||||
const ENDUNG: Record<string, string> = {
|
||||
'audio/mpeg': '.mp3',
|
||||
'audio/wav': '.wav',
|
||||
'audio/ogg': '.ogg',
|
||||
'audio/webm': '.webm',
|
||||
'audio/mp4': '.m4a',
|
||||
};
|
||||
|
||||
/**
|
||||
* Tontyp an den ersten Bytes erkennen. `null` heisst: kein Ton, den OBS'
|
||||
* Browser sicher abspielt.
|
||||
*/
|
||||
export function erkenneTyp(b: Buffer): string | null {
|
||||
if (b.length < 12) return null;
|
||||
const ascii = (von: number, bis: number): string => b.subarray(von, bis).toString('latin1');
|
||||
if (ascii(0, 3) === 'ID3') return 'audio/mpeg';
|
||||
// MPEG-Rahmen ohne ID3-Kopf: 11 gesetzte Synchronbits.
|
||||
if (b[0] === 0xff && (b[1]! & 0xe0) === 0xe0) return 'audio/mpeg';
|
||||
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WAVE') return 'audio/wav';
|
||||
if (ascii(0, 4) === 'OggS') return 'audio/ogg';
|
||||
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'audio/webm';
|
||||
// MP4/M4A: das Kaestchen `ftyp` an Stelle 4. So nimmt auch das iPhone auf.
|
||||
if (ascii(4, 8) === 'ftyp') return 'audio/mp4';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Name aus dem Dateinamen: ohne Endung, ohne Unterstriche, nicht zu lang. */
|
||||
export function nameAusDatei(datei: string): string {
|
||||
const ohne = datei.replace(/\.[A-Za-z0-9]{1,5}$/, '').replace(/[_]+/g, ' ').trim();
|
||||
return saeubern(ohne) || 'Sound';
|
||||
}
|
||||
|
||||
function saeubern(s: string, max = 40): string {
|
||||
// Steuerzeichen raus. Angezeigt wird ueber textContent, aber ein
|
||||
// Zeilenumbruch im Namen zerreisst trotzdem jede Kachel.
|
||||
return Array.from(s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim())
|
||||
.slice(0, max).join('');
|
||||
}
|
||||
|
||||
function emojiOk(s: string): string {
|
||||
// Ein Emoji kann aus mehreren Codepoints bestehen (Hautfarbe, Familien).
|
||||
// Zehn reichen fuer alles, was man auf eine Kachel setzt.
|
||||
return Array.from(saeubern(s, 10)).slice(0, 10).join('');
|
||||
}
|
||||
|
||||
export type Soundboard = {
|
||||
liste: () => Sound[];
|
||||
holen: (id: string) => Sound | undefined;
|
||||
datei: (id: string) => { daten: Buffer; typ: string } | undefined;
|
||||
hinzufuegen: (name: string, daten: Buffer) => Sound;
|
||||
aendern: (id: string, was: Partial<Pick<Sound, 'name' | 'emoji' | 'farbe' | 'laut'>>) => Sound | undefined;
|
||||
entfernen: (id: string) => boolean;
|
||||
/** Um eine Stelle nach vorn (-1) oder hinten (+1). */
|
||||
verschieben: (id: string, um: number) => boolean;
|
||||
};
|
||||
|
||||
export function createSoundboard(ordner: string): Soundboard {
|
||||
const index = path.join(ordner, 'sounds.json');
|
||||
const sounds: Sound[] = lesen();
|
||||
|
||||
function lesen(): Sound[] {
|
||||
try {
|
||||
const roh = JSON.parse(fs.readFileSync(index, 'utf8')) as { sounds?: Sound[] };
|
||||
return Array.isArray(roh.sounds) ? roh.sounds : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function schreiben(): void {
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
// Erst daneben schreiben, dann umbenennen: stirbt der Prozess mittendrin,
|
||||
// bleibt die alte Liste ganz statt einer halben neuen.
|
||||
const tmp = `${index}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify({ sounds }, null, 2));
|
||||
fs.renameSync(tmp, index);
|
||||
}
|
||||
|
||||
const holen = (id: string): Sound | undefined => sounds.find((s) => s.id === id);
|
||||
|
||||
return {
|
||||
liste: () => sounds.map((s) => ({ ...s })),
|
||||
holen,
|
||||
|
||||
datei(id) {
|
||||
const s = holen(id);
|
||||
if (!s) return undefined;
|
||||
try {
|
||||
return { daten: fs.readFileSync(path.join(ordner, s.datei)), typ: s.typ };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
hinzufuegen(name, daten) {
|
||||
if (daten.length > MAX_BYTES) {
|
||||
throw new Error(`Zu gross: höchstens ${MAX_BYTES / 1024 / 1024} MB.`);
|
||||
}
|
||||
if (sounds.length >= MAX_SOUNDS) throw new Error(`Höchstens ${MAX_SOUNDS} Sounds.`);
|
||||
const typ = erkenneTyp(daten);
|
||||
if (!typ) throw new Error('Kein Ton, den OBS abspielt. Erlaubt sind MP3, WAV, OGG, WebM und M4A.');
|
||||
|
||||
const id = crypto.randomBytes(6).toString('hex');
|
||||
const datei = `${id}${ENDUNG[typ]}`;
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
fs.writeFileSync(path.join(ordner, datei), daten);
|
||||
const s: Sound = {
|
||||
id, name: saeubern(name) || 'Sound', emoji: '', farbe: FARBEN[sounds.length % FARBEN.length]!,
|
||||
laut: 0.8, datei, typ, bytes: daten.length, seit: new Date().toISOString(),
|
||||
};
|
||||
sounds.push(s);
|
||||
schreiben();
|
||||
return { ...s };
|
||||
},
|
||||
|
||||
aendern(id, was) {
|
||||
const s = holen(id);
|
||||
if (!s) return undefined;
|
||||
if (was.name !== undefined) s.name = saeubern(was.name) || s.name;
|
||||
if (was.emoji !== undefined) s.emoji = emojiOk(was.emoji);
|
||||
if (was.farbe !== undefined && (FARBEN as readonly string[]).includes(was.farbe)) s.farbe = was.farbe;
|
||||
if (was.laut !== undefined && Number.isFinite(was.laut)) {
|
||||
s.laut = Math.round(Math.max(0, Math.min(1, was.laut)) * 100) / 100;
|
||||
}
|
||||
schreiben();
|
||||
return { ...s };
|
||||
},
|
||||
|
||||
entfernen(id) {
|
||||
const i = sounds.findIndex((s) => s.id === id);
|
||||
if (i === -1) return false;
|
||||
const [weg] = sounds.splice(i, 1);
|
||||
schreiben();
|
||||
try { fs.unlinkSync(path.join(ordner, weg!.datei)); } catch { /* schon weg */ }
|
||||
return true;
|
||||
},
|
||||
|
||||
verschieben(id, um) {
|
||||
const i = sounds.findIndex((s) => s.id === id);
|
||||
const ziel = i + Math.sign(um);
|
||||
if (i === -1 || ziel < 0 || ziel >= sounds.length) return false;
|
||||
[sounds[i], sounds[ziel]] = [sounds[ziel]!, sounds[i]!];
|
||||
schreiben();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Die Testknoepfe sind das einzige Werkzeug, mit dem sich das Overlay
|
||||
// ausserhalb eines Streams pruefen laesst. Geht hier etwas kaputt, merkt man
|
||||
// es ausgerechnet dann nicht, wenn man es am noetigsten braucht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||
|
||||
test('zu jedem Knopf gibt es ein Ereignis', () => {
|
||||
// Ein Knopf ohne Ereignis tut beim Druecken nichts und sieht dabei aus wie
|
||||
// ein kaputtes Overlay.
|
||||
for (const k of TEST_KNOEPFE) {
|
||||
if (k.art === 'chat-loeschen') continue; // braucht eine Nachricht, siehe unten
|
||||
assert.ok(testEvent(k.art), `${k.art} liefert kein Ereignis`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Knopf kommt nur einmal vor', () => {
|
||||
const arten = TEST_KNOEPFE.map((k) => k.art);
|
||||
assert.equal(new Set(arten).size, arten.length);
|
||||
});
|
||||
|
||||
test('die Einblendungen tragen die Felder, die die Seite anzeigt', () => {
|
||||
const raid = testEvent('raid') as { kind: string; type: string; amount: number };
|
||||
assert.equal(raid.kind, 'alert');
|
||||
assert.equal(raid.type, 'raid');
|
||||
assert.ok(raid.amount > 0, 'ohne Zuschauerzahl steht "mit undefined Zuschauern" im Bild');
|
||||
|
||||
const gift = testEvent('subgift') as { recipient?: string };
|
||||
assert.ok(gift.recipient, 'ohne Beschenkten fehlt die zweite Zeile');
|
||||
|
||||
const resub = testEvent('resub') as { months?: number };
|
||||
assert.ok(resub.months && resub.months > 0);
|
||||
});
|
||||
|
||||
test('"letzte löschen" braucht eine Nachricht und trifft genau die', () => {
|
||||
assert.equal(testEvent('chat-loeschen'), null, 'ohne Chat gibt es nichts zu loeschen');
|
||||
assert.deepEqual(testEvent('chat-loeschen', 'abc'), { kind: 'clear', messageId: 'abc' });
|
||||
});
|
||||
|
||||
test('"Chat leeren" loescht alles, nicht nur eine Nachricht', () => {
|
||||
assert.deepEqual(testEvent('chat-leeren'), { kind: 'clear' });
|
||||
});
|
||||
|
||||
test('Chatnachrichten haben eine eigene ID', () => {
|
||||
// Zwei Nachrichten mit derselben ID wuerden sich beim Loeschen gegenseitig
|
||||
// mitnehmen.
|
||||
const a = testEvent('chat') as { id: string };
|
||||
const b = testEvent('chat') as { id: string };
|
||||
assert.notEqual(a.id, b.id);
|
||||
});
|
||||
|
||||
test('das Emote im Test ist im Text auch enthalten', () => {
|
||||
// Sonst wird nichts ersetzt und der Knopf prueft die Emote-Anzeige nicht.
|
||||
const ev = testEvent('chat-emote') as { text: string; emotes: Array<{ name: string }> };
|
||||
assert.equal(ev.emotes.length, 1);
|
||||
assert.ok(ev.text.includes(ev.emotes[0]!.name));
|
||||
});
|
||||
|
||||
test('gesperrt ist nur, was einen falschen Stand setzt - nicht der Weg zurueck', () => {
|
||||
// Eine Einblendung blitzt auf und ist weg. Ein erfundener Stream-Zustand
|
||||
// bleibt in der Statusleiste stehen, bis die naechste Abfrage ihn
|
||||
// geraderueckt - und das dauert bis zu einer Minute.
|
||||
//
|
||||
// "Zuruecksetzen" darf dabei nie gesperrt sein: sonst kommt man aus einem
|
||||
// erfundenen Zustand ohne Neustart nicht mehr heraus, und auf einem Hub ohne
|
||||
// Broadcaster-Token raeumt ueberhaupt niemand hinterher auf.
|
||||
const gesperrt = TEST_KNOEPFE.filter((k) => k.nurOffline).map((k) => k.art);
|
||||
assert.deepEqual(gesperrt, ['live-an']);
|
||||
|
||||
const zurueck = TEST_KNOEPFE.find((k) => k.art === 'live-aus');
|
||||
assert.equal(zurueck?.nurOffline, undefined, 'der Weg zurueck bleibt immer offen');
|
||||
});
|
||||
|
||||
test('der Stand-Knopf setzt einen anzeigbaren Stream', () => {
|
||||
const an = testEvent('live-an') as
|
||||
{ kind: string; live: boolean; title: string; startedAt: string | null };
|
||||
assert.equal(an.kind, 'stream');
|
||||
assert.equal(an.live, true);
|
||||
assert.ok(an.title.length > 0);
|
||||
assert.ok(an.startedAt && Date.parse(an.startedAt) < Date.now(), 'Laufzeit muss positiv sein');
|
||||
|
||||
const aus = testEvent('live-aus') as { live: boolean; startedAt: string | null };
|
||||
assert.equal(aus.live, false);
|
||||
assert.equal(aus.startedAt, null);
|
||||
});
|
||||
|
||||
test('unbekannte Knoepfe liefern nichts, statt etwas zu erfinden', () => {
|
||||
assert.equal(testEvent('gibtsnicht' as TestArt), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Kanalpunkte
|
||||
|
||||
test('eine Belohnung wird nie als Abo eingeblendet', () => {
|
||||
// Genau das stand hier einmal: die Konfetti-Belohnung hat die Abo-
|
||||
// Einblendung wiederverwendet und "Neues Abo" ins Bild geschrieben. Eine
|
||||
// Falschmeldung im Stream, die in der Aufzeichnung bleibt.
|
||||
const e = testEvent('reward') as { kind: string; type: string; reward?: string };
|
||||
assert.equal(e.kind, 'alert');
|
||||
assert.equal(e.type, 'reward');
|
||||
assert.notEqual(e.type, 'sub');
|
||||
assert.ok(e.reward, 'ohne Titel weiss niemand, welche Belohnung gemeint ist');
|
||||
});
|
||||
|
||||
test('Konfetti ist ein eigenes Ereignis, keine Nebenwirkung einer Einblendung', () => {
|
||||
// So muss die Effektseite nicht wissen, welche Belohnung was ausloest.
|
||||
const e = testEvent('reward-konfetti') as { kind: string; staerke: number };
|
||||
assert.equal(e.kind, 'konfetti');
|
||||
assert.ok(e.staerke > 0);
|
||||
});
|
||||
|
||||
test('der Vorlese-Knopf schickt Text, den die Seite auch sprechen kann', () => {
|
||||
const e = testEvent('tts') as { kind: string; text: string; name: string };
|
||||
assert.equal(e.kind, 'tts');
|
||||
assert.ok(e.text.length > 0);
|
||||
assert.ok(e.name.length > 0);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// Erfundene Ereignisse zum Ausprobieren, für die Knöpfe auf der
|
||||
// Übersichtsseite und für den Vorführmodus.
|
||||
//
|
||||
// Warum das gebraucht wird: ein Raid kommt vielleicht einmal die Woche vor.
|
||||
// Darauf zu warten, um zu sehen, ob die Einblendung passt, ist keine
|
||||
// Arbeitsweise - und mitten im Stream herauszufinden, dass der Text abgeschnitten
|
||||
// wird, ist die schlechteste aller Möglichkeiten.
|
||||
//
|
||||
// Die Knopfliste und die Ereignisse stehen hier zusammen, damit die Seite die
|
||||
// Knöpfe daraus bauen kann. Ein Knopf, den es gibt, hat damit immer auch ein
|
||||
// Ereignis dahinter.
|
||||
|
||||
import { ulid } from '#shared/events.ts';
|
||||
import type { ChatEvent, OverlayEvent } from './bus.ts';
|
||||
|
||||
export const NAMEN = [
|
||||
'kevin_ohne_c', 'Melli', 'HansWurst42', 'dieNina', 'Tobi', 'Schnitzelfan',
|
||||
'pixelpudel', 'Rewe_Kassiererin', 'Olaf', 'zwiebeljule',
|
||||
];
|
||||
|
||||
export const SPRUECHE = [
|
||||
'moin', 'was geht', 'der Boss hat 3 Phasen oder?', 'hahaha',
|
||||
'rechts! RECHTS!', 'ich guck seit ner Stunde zu, richtig gut',
|
||||
'kann jemand den Titel lesen', 'das war knapp', 'gg',
|
||||
'mein Internet ist heute wieder Müll', 'hast du den Trank noch?',
|
||||
'erster', 'wie lange streamst du heute noch',
|
||||
];
|
||||
|
||||
/**
|
||||
* Globale Twitch-Emotes mit ihren echten IDs - nachgeschlagen, nicht geraten,
|
||||
* damit die Bilder wirklich laden. KEKW fehlt hier absichtlich: das ist ein
|
||||
* BTTV-Emote und hat bei Twitch keine ID.
|
||||
*/
|
||||
export const EMOTES = [
|
||||
{ id: '425618', name: 'LUL', count: 1 },
|
||||
{ id: '25', name: 'Kappa', count: 1 },
|
||||
{ id: '354', name: '4Head', count: 1 },
|
||||
];
|
||||
|
||||
export const FARBEN = ['#ff4fa3', '#4fd1c5', '#a970ff', '#ffc53d', '#4ade80', null];
|
||||
|
||||
export function zufall<T>(list: readonly T[]): T {
|
||||
return list[Math.floor(Math.random() * list.length)]!;
|
||||
}
|
||||
|
||||
export type TestArt =
|
||||
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
|
||||
| 'reward' | 'reward-konfetti' | 'tts'
|
||||
| 'chat' | 'chat-emote' | 'chat-gif' | 'chat-erstmals' | 'chat-bits' | 'chat-lang'
|
||||
| 'chat-loeschen' | 'chat-leeren'
|
||||
| 'hype' | 'live-an' | 'live-aus';
|
||||
|
||||
export type TestKnopf = {
|
||||
art: TestArt;
|
||||
label: string;
|
||||
gruppe: 'Einblendungen' | 'Kanalpunkte' | 'Chat' | 'Effekte' | 'Stand';
|
||||
/**
|
||||
* Verändert den Zustand, den das Overlay dauerhaft anzeigt, statt nur einmal
|
||||
* aufzublitzen. Läuft gerade ein Stream, steht danach etwas Falsches im Bild,
|
||||
* bis die nächste Abfrage es geraderückt - solche Knöpfe sind gesperrt.
|
||||
*/
|
||||
nurOffline?: boolean;
|
||||
};
|
||||
|
||||
export const TEST_KNOEPFE: TestKnopf[] = [
|
||||
{ art: 'sub', label: 'Abo', gruppe: 'Einblendungen' },
|
||||
{ art: 'resub', label: 'Abo verlängert', gruppe: 'Einblendungen' },
|
||||
{ art: 'subgift', label: 'Abo verschenkt', gruppe: 'Einblendungen' },
|
||||
{ art: 'massgift', label: '5 Abos verschenkt', gruppe: 'Einblendungen' },
|
||||
{ art: 'raid', label: 'Raid', gruppe: 'Einblendungen' },
|
||||
{ art: 'cheer', label: 'Bits', gruppe: 'Einblendungen' },
|
||||
{ art: 'first', label: 'Erstchatter', gruppe: 'Einblendungen' },
|
||||
{ art: 'clip', label: 'Clip gespeichert', gruppe: 'Einblendungen' },
|
||||
{ art: 'follow', label: 'Follow', gruppe: 'Einblendungen' },
|
||||
|
||||
{ art: 'reward', label: 'Belohnung eingelöst', gruppe: 'Kanalpunkte' },
|
||||
{ art: 'reward-konfetti', label: 'Belohnung mit Konfetti', gruppe: 'Kanalpunkte' },
|
||||
{ art: 'tts', label: 'Vorlesen', gruppe: 'Kanalpunkte' },
|
||||
|
||||
{ art: 'chat', label: 'Nachricht', gruppe: 'Chat' },
|
||||
{ art: 'chat-emote', label: 'mit Emote', gruppe: 'Chat' },
|
||||
{ art: 'chat-gif', label: 'mit GIF', gruppe: 'Chat' },
|
||||
{ art: 'chat-erstmals', label: 'von einem Erstchatter', gruppe: 'Chat' },
|
||||
{ art: 'chat-bits', label: 'mit Bits', gruppe: 'Chat' },
|
||||
{ art: 'chat-lang', label: 'sehr lang', gruppe: 'Chat' },
|
||||
{ art: 'chat-loeschen', label: 'letzte löschen', gruppe: 'Chat' },
|
||||
{ art: 'chat-leeren', label: 'Chat leeren', gruppe: 'Chat' },
|
||||
|
||||
{ art: 'hype', label: 'Emote-Regen', gruppe: 'Effekte' },
|
||||
|
||||
{ art: 'live-an', label: 'Stream läuft', gruppe: 'Stand', nurOffline: true },
|
||||
// Absichtlich NICHT gesperrt: das ist der Weg aus einem erfundenen Zustand
|
||||
// wieder heraus. Wäre der Knopf gesperrt, sobald "Stream läuft" gedrückt
|
||||
// wurde, käme man ohne Neustart nicht mehr zurück - und auf einem Hub ohne
|
||||
// Broadcaster-Token räumt auch keine Abfrage hinterher auf.
|
||||
{ art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' },
|
||||
];
|
||||
|
||||
function chat(over: Partial<ChatEvent> = {}): ChatEvent {
|
||||
const name = zufall(NAMEN);
|
||||
return {
|
||||
kind: 'chat',
|
||||
id: ulid(),
|
||||
userId: `test-${NAMEN.indexOf(name)}`,
|
||||
login: name.toLowerCase(),
|
||||
name,
|
||||
text: zufall(SPRUECHE),
|
||||
color: zufall(FARBEN),
|
||||
emotes: [],
|
||||
gifs: [],
|
||||
first: false,
|
||||
mod: Math.random() < 0.15,
|
||||
sub: Math.random() < 0.3,
|
||||
broadcaster: false,
|
||||
bits: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Ereignis zu einem Knopf.
|
||||
*
|
||||
* `letzteChatId` ist die neueste Nachricht im Rückblick des Busses - nur so
|
||||
* kann "letzte löschen" etwas treffen, das auch wirklich im Bild steht.
|
||||
*/
|
||||
export function testEvent(art: TestArt, letzteChatId?: string): OverlayEvent | null {
|
||||
const name = zufall(NAMEN);
|
||||
const alarm = (rest: Record<string, unknown>): OverlayEvent =>
|
||||
({ kind: 'alert', id: ulid(), name, ...rest } as OverlayEvent);
|
||||
|
||||
switch (art) {
|
||||
case 'sub': return alarm({ type: 'sub', tier: '1000' });
|
||||
case 'resub': return alarm({
|
||||
type: 'resub', months: 1 + Math.floor(Math.random() * 30),
|
||||
message: 'endlich wieder dabei, zwei Monate Pause waren genug',
|
||||
});
|
||||
case 'subgift': return alarm({ type: 'subgift', recipient: zufall(NAMEN) });
|
||||
case 'massgift': return alarm({ type: 'massgift', amount: 5 });
|
||||
case 'raid': return alarm({ type: 'raid', amount: 5 + Math.floor(Math.random() * 60) });
|
||||
case 'cheer': return alarm({
|
||||
type: 'cheer', amount: 100 * (1 + Math.floor(Math.random() * 10)),
|
||||
message: 'das musste sein',
|
||||
});
|
||||
case 'first': return alarm({ type: 'first' });
|
||||
case 'clip': return alarm({ type: 'clip' });
|
||||
case 'follow': return alarm({ type: 'follow' });
|
||||
case 'reward': return alarm({
|
||||
type: 'reward', reward: 'Nachricht anheften', amount: 500,
|
||||
message: 'guck mal links oben',
|
||||
});
|
||||
case 'reward-konfetti': return { kind: 'konfetti', staerke: 1 };
|
||||
case 'tts': return {
|
||||
kind: 'tts', id: ulid(), name,
|
||||
text: 'Das hier wird vorgelesen, sobald du es freigibst.',
|
||||
};
|
||||
|
||||
case 'chat': return chat();
|
||||
case 'chat-emote': {
|
||||
const e = zufall(EMOTES);
|
||||
return chat({ text: `${zufall(SPRUECHE)} ${e.name}`, emotes: [e] });
|
||||
}
|
||||
case 'chat-gif': {
|
||||
// Genau so, wie Twitch es schickt: der Text ist nur ein Platzhalter,
|
||||
// das Bild steckt im Tag. Echte Adresse, damit der Knopf auch wirklich
|
||||
// etwas anzeigt.
|
||||
const text = '[Idk GIF by Respective]';
|
||||
return chat({
|
||||
text,
|
||||
gifs: [{
|
||||
id: 'Pnd7qLFuGCOntZcNKB',
|
||||
url: 'https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif',
|
||||
from: 0, to: [...text].length - 1,
|
||||
}],
|
||||
});
|
||||
}
|
||||
case 'chat-erstmals': return chat({ first: true, text: 'moin, bin neu hier' });
|
||||
case 'chat-bits': return chat({ bits: 500, text: 'nimm die 500 Bits' });
|
||||
case 'chat-lang': return chat({
|
||||
// Der Fall, den man sonst erst im Stream sieht: eine Nachricht, die
|
||||
// den Kasten sprengt.
|
||||
text: 'also ich hab das gestern auch probiert und da ist mir aufgefallen ' +
|
||||
'dass der Boss in Phase zwei immer nach links ausweicht, aber nur wenn ' +
|
||||
'man vorher nicht geblockt hat, und das steht in keinem Guide drin',
|
||||
});
|
||||
case 'chat-loeschen':
|
||||
return letzteChatId ? { kind: 'clear', messageId: letzteChatId } : null;
|
||||
case 'chat-leeren': return { kind: 'clear' };
|
||||
|
||||
case 'hype': return { kind: 'hype', score: 4 + Math.random() * 4, rate: 45 };
|
||||
|
||||
case 'live-an': return {
|
||||
kind: 'stream', live: true,
|
||||
title: 'Probelauf des Overlays', category: 'Just Chatting',
|
||||
viewers: 12 + Math.floor(Math.random() * 30),
|
||||
startedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
|
||||
};
|
||||
case 'live-aus': return {
|
||||
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||
};
|
||||
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
+150
-2
@@ -1,5 +1,153 @@
|
||||
# @abdulive/overlay-web
|
||||
|
||||
Browser-Sources fuer OBS: Alerts, Effekte, Community-Hinweis, Umfrage, Raid-Panel.
|
||||
Die Browser-Sources für OBS. Fünf Seiten, alle mit durchsichtigem Hintergrund,
|
||||
alle an demselben Ereignisstrom.
|
||||
|
||||
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
||||
Kein Build, kein Framework, keine Abhängigkeit: OBS lädt die Dateien direkt,
|
||||
und jede Zwischenstufe wäre ein Ding, das mitten im Stream kaputtgehen kann.
|
||||
Gemeinsam sind nur [`theme.css`](public/theme.css) (Farben, Schrift, Kontur)
|
||||
und [`client.js`](public/client.js) (Verbindung, Escaping, Emotes).
|
||||
|
||||
## Die Seiten
|
||||
|
||||
| Adresse | Größe | Was sie zeigt |
|
||||
|---|---|---|
|
||||
| `/alerts` | 1920×1080 | Abos, Raids, Bits, Erstchatter. Eine nach der anderen, nie übereinander. |
|
||||
| `/chat` | 460×900 | Chatnachrichten mit Emotes, Abzeichen, Farben. |
|
||||
| `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. |
|
||||
| `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. |
|
||||
| `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. |
|
||||
| `/sounds` | 400×120 | Spielt die Sounds vom Pult. Unsichtbar, **mit Ton**. |
|
||||
|
||||
Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`).
|
||||
|
||||
## In OBS
|
||||
|
||||
Je Seite eine Browser-Source anlegen, Adresse eintragen, Breite und Höhe
|
||||
setzen. Zwei Häkchen sind wichtig:
|
||||
|
||||
- **"Quelle beim Unsichtbarwerden deaktivieren" aus.** Sonst verpasst das
|
||||
Overlay alles, was passiert, während eine andere Szene im Bild ist, und
|
||||
baut sich bei jedem Szenenwechsel neu auf.
|
||||
- **"Browser beim Szenenwechsel aktualisieren" aus.** Eine laufende
|
||||
Einblendung würde sonst mitten im Wechsel abgeschnitten.
|
||||
|
||||
## Einstellungen über die Adresse
|
||||
|
||||
Es gibt keine Konfigurationsdatei. Was einstellbar ist, hängt hinten an der
|
||||
URL - so lässt es sich in OBS ändern, ohne den Dienst anzufassen.
|
||||
|
||||
| Seite | Parameter | Vorgabe |
|
||||
|---|---|---|
|
||||
| alle | `probe=1` | dunkler Hintergrund zum Ansehen im Browser |
|
||||
| `/alerts` | `sekunden` | 7 - wie lange eine Einblendung steht |
|
||||
| `/alerts` | `erstchatter=0` | Erstchatter nicht einblenden |
|
||||
| `/chat` | `max` | 14 - so viele Zeilen gleichzeitig |
|
||||
| `/chat` | `sekunden` | 120 - danach verblasst eine Zeile (0 = nie) |
|
||||
| `/chat` | `befehle=1` | auch `!`-Befehle zeigen |
|
||||
| `/status` | `kanal` | abdulive_ |
|
||||
| `/effects` | `max` | 260 - Obergrenze für Teilchen |
|
||||
| `/starting` | `text`, `unter` | "Gleich geht es los", leer |
|
||||
|
||||
## Zum Ausprobieren
|
||||
|
||||
Auf der Übersichtsseite (`/`) steht unter **Ausprobieren** für jede Einblendung,
|
||||
jede Chat-Variante und jeden Effekt ein Knopf. Er löst ein erfundenes Ereignis
|
||||
aus, das nur an die Browser-Sources geht - nichts davon landet in der Datenbank,
|
||||
auf Discord oder bei Twitch.
|
||||
|
||||
Am besten die zu prüfende Seite über „ansehen" in einem zweiten Fenster offen
|
||||
haben. Dabei hilft `?probe=1`: das legt einen dunklen Grund unter die sonst
|
||||
durchsichtige Seite.
|
||||
|
||||
Zwei Dinge sind gesperrt, solange ein Stream läuft: „Stream läuft" und die
|
||||
Vorführung. Beide setzen einen Zustand, der in der Statusleiste stehen bliebe,
|
||||
bis die nächste Twitch-Abfrage ihn geraderückt. „Stand zurücksetzen" ist
|
||||
absichtlich nie gesperrt - das ist der Weg aus einem erfundenen Zustand wieder
|
||||
heraus.
|
||||
|
||||
Dieselben Ereignisse im Dauerbetrieb, auch ohne laufenden Dienst:
|
||||
|
||||
```
|
||||
abdulive overlay --demo
|
||||
```
|
||||
|
||||
Erfundener Chat, alle neun Sekunden eine Einblendung, ab und zu eine
|
||||
Chat-Spitze. Ohne das ließe sich eine Raid-Einblendung nur prüfen, indem man
|
||||
auf einen echten Raid wartet.
|
||||
|
||||
## Was fehlt
|
||||
|
||||
- **Follows.** Kommen nicht über IRC, dafür braucht es EventSub. Bis dahin
|
||||
bleibt das Overlay bei Follows stumm; Abos, Raids und Bits kommen an.
|
||||
- **BTTV- und 7TV-Emotes.** Die hängen an einer zweiten API. Twitchs eigene
|
||||
Emotes werden angezeigt.
|
||||
- **Töne bei Einblendungen.** Es liegen keine Audiodateien im Repo, und ein
|
||||
Overlay, das ungefragt Krach macht, ist schlimmer als eines, das still ist.
|
||||
Sounds gibt es nur auf Knopfdruck, über das Pult.
|
||||
|
||||
## Das Pult (`/pult`)
|
||||
|
||||
Keine Browser-Source, sondern eine App fürs Handy: wie ein Stream Deck, nur
|
||||
ohne Hardware. Mit Passwort, wie die Übersicht. Im Browser des Handys
|
||||
„Zum Startbildschirm hinzufügen“ (iPhone) bzw. „App installieren“ (Android),
|
||||
dann startet es ohne Adressleiste. `manifest.webmanifest` und `sw.js` machen
|
||||
das möglich; der Service Worker hält nur die Hülle vor und beantwortet nie
|
||||
einen Knopfdruck aus dem Speicher.
|
||||
|
||||
| Reiter | Was |
|
||||
|---|---|
|
||||
| Sounds | Tasten fürs Soundboard. „+ Sound“ lädt MP3, WAV, OGG, WebM oder M4A vom Handy hoch (bis 8 MB). „Bearbeiten“: Name, Emoji, Farbe, Lautstärke, Reihenfolge, Löschen, am Handy anhören. „Stopp“ bricht alle Sounds ab. |
|
||||
| Szenen | OBS-Szenen umschalten, Clip aus dem Wiedergabepuffer speichern, Toneingänge stumm schalten. Braucht den Stream-PC (siehe unten). |
|
||||
| Stream | Liegestütze zählen, Vorlesen freigeben, „Sofort still“. |
|
||||
| Test | Dieselben Testknöpfe wie auf der Übersicht. |
|
||||
|
||||
Die Sonne oben rechts hält den Bildschirm an (Wake Lock), solange das Pult
|
||||
offen ist.
|
||||
|
||||
**Sounds** liegen auf dem Hub neben der Datenbank (`<db>/sounds/`), nicht im
|
||||
Repo und nicht unter `/opt/abdulive` - sie überleben jedes Ausrollen. Der
|
||||
Dateityp wird an den ersten Bytes erkannt; was kein Ton ist, wird abgelehnt.
|
||||
Gespielt wird in OBS über die Browser-Source `/sounds`, „Audio über OBS
|
||||
steuern“ an. Ist sie nicht verbunden, sagt das Pult es in Gelb. Höchstens vier
|
||||
Sounds gleichzeitig (`?max=`), `?laut=` dämpft alle.
|
||||
|
||||
**Szenen** gehen über den Stream-PC, weil der Hub OBS nicht erreicht. Der PC
|
||||
wählt sich mit eigenem Schlüssel beim Hub ein (`apps/ingest/src/fern.ts` und
|
||||
`apps/overlay-server/src/fern.ts`). Einmal auf dem Stream-PC:
|
||||
|
||||
```
|
||||
abdulive fern:verbinden https://abdu.dimension47.de <schlüssel>
|
||||
```
|
||||
|
||||
Der fertige Befehl steht auf der Übersicht unter „Stream-PC verbinden“.
|
||||
Danach `abdulive ingest` neu starten. Jeder Knopf wartet auf die Antwort von
|
||||
OBS und sagt ehrlich, wenn es nicht ging.
|
||||
|
||||
Die App-Symbole zeichnet `node scripts/make-icons.ts`.
|
||||
|
||||
## Vorlesen (`/tts`)
|
||||
|
||||
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
||||
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
||||
|
||||
Gesprochen wird im Hub mit Piper, nicht im Browser. Die eingebaute
|
||||
Sprachausgabe von OBS' Browser-Quelle sah keine deutsche Stimme und las
|
||||
amerikanisch; welche Stimmen es gibt, hängt am Rechner, der streamt. Piper
|
||||
läuft lokal im LXC, kostet nichts, und kein Zuschauertext verlässt das Haus.
|
||||
Die Seite holt sich die fertige WAV unter `/tts/audio/<id>` und spielt sie
|
||||
über ein `<audio>` ab, das sicher über OBS' Tonspur läuft.
|
||||
|
||||
Kann der Hub nicht sprechen, springt `speechSynthesis` ein, aber nur mit
|
||||
einer deutschen Stimme. Gibt es keine, wird die Nachricht nur angezeigt,
|
||||
mit rotem Hinweis.
|
||||
|
||||
Stimme und Sprecher: `rewards.tts.stimme` und `rewards.tts.sprecher` in der
|
||||
Konfiguration, eingerichtet mit `scripts/install-piper.sh`.
|
||||
|
||||
Einstellungen: `?laut=1` (0 bis 1). Besser im OBS-Mixer regeln.
|
||||
`?sprache=de-DE` und `?tempo=1` gelten nur für den Notnagel im Browser.
|
||||
|
||||
Mit `freigabe: true` wird nur vorgelesen, was auf der Übersichtsseite
|
||||
freigegeben wurde. Nach einem Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
||||
vorzulesen ist schlimmer, als sie zu verpassen.
|
||||
@@ -0,0 +1,222 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Einblendungen</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Einblendungen laufen nacheinander, nie uebereinander. Zwei Alarme
|
||||
* gleichzeitig sind unlesbar, und bei einem Massengeschenk kommen zehn
|
||||
* Stueck in derselben Sekunde. */
|
||||
.buehne {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.karte {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 620px;
|
||||
max-width: 1100px;
|
||||
padding: 34px 54px 38px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0 34px;
|
||||
border-radius: 26px;
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(18px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, .14);
|
||||
box-shadow: var(--schatten), var(--glanz), 0 0 90px -20px var(--ton);
|
||||
/* Die Kante in der Akzentfarbe ist das, was die Einblendung dem Ereignis
|
||||
* zuordnet, bevor man ein Wort gelesen hat. */
|
||||
border-left: 8px solid var(--ton);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.karte.an { animation: herein .75s cubic-bezier(.16, 1.2, .3, 1) both; }
|
||||
.karte.weg { animation: hinaus .45s cubic-bezier(.5, 0, .75, 0) both; }
|
||||
|
||||
@keyframes herein {
|
||||
from { opacity: 0; transform: translateY(46px) scale(.9); filter: blur(8px); }
|
||||
to { opacity: 1; transform: none; filter: none; }
|
||||
}
|
||||
@keyframes hinaus {
|
||||
from { opacity: 1; transform: none; }
|
||||
to { opacity: 0; transform: translateY(-34px) scale(.96); }
|
||||
}
|
||||
|
||||
.zeichen {
|
||||
font-size: 92px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, .6));
|
||||
animation: huepfen .9s cubic-bezier(.2, 1.6, .4, 1) .12s both;
|
||||
}
|
||||
@keyframes huepfen {
|
||||
0% { transform: scale(0) rotate(-25deg); }
|
||||
70% { transform: scale(1.18) rotate(6deg); }
|
||||
100% { transform: scale(1) rotate(0); }
|
||||
}
|
||||
|
||||
.kopf {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ton);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.name {
|
||||
font-size: 58px;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.zusatz { font-size: 26px; color: var(--schrift-leise); margin-top: 6px; }
|
||||
.notiz {
|
||||
margin-top: 14px;
|
||||
font-size: 22px;
|
||||
color: var(--schrift-leise);
|
||||
max-width: 44ch;
|
||||
/* Zuschauertext. Drei Zeilen, dann Schluss - sonst schiebt jemand mit
|
||||
* einem Roman die Karte aus dem Bild. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notiz .emote { height: 1.4em; vertical-align: -.3em; }
|
||||
|
||||
/* Der Balken, der unter der Karte ablaeuft: zeigt, wie lange sie noch steht. */
|
||||
.uhr {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
height: 4px;
|
||||
background: var(--ton);
|
||||
transform-origin: left;
|
||||
animation: ablaufen var(--dauer) linear both;
|
||||
opacity: .8;
|
||||
}
|
||||
@keyframes ablaufen { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="buehne"><div id="platz"></div></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var platz = document.getElementById('platz');
|
||||
var dauer = Overlay.param('sekunden', 7) * 1000;
|
||||
var warteschlange = [];
|
||||
var laeuft = false;
|
||||
|
||||
/* Ein Eintrag je Art: Farbe, Zeichen, Ueberschrift und die beiden
|
||||
* Textzeilen. Alles an einem Ort, damit eine neue Art eine Zeile ist. */
|
||||
var ARTEN = {
|
||||
sub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Neues Abo' },
|
||||
resub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Abo verlängert' },
|
||||
subgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abo verschenkt' },
|
||||
massgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abos verschenkt' },
|
||||
raid: { ton: 'var(--tuerkis)', zeichen: '🚀', kopf: 'Raid' },
|
||||
cheer: { ton: 'var(--gold)', zeichen: '✨', kopf: 'Bits' },
|
||||
follow: { ton: 'var(--gruen)', zeichen: '⭐', kopf: 'Neuer Follow' },
|
||||
first: { ton: 'var(--gruen)', zeichen: '👋', kopf: 'Zum ersten Mal hier' },
|
||||
clip: { ton: 'var(--tuerkis)', zeichen: '🎬', kopf: 'Clip gespeichert' },
|
||||
// Eigene Art, eigene Farbe, eigene Ueberschrift. Eine eingeloeste
|
||||
// Belohnung darf nicht aussehen wie ein Abo.
|
||||
reward: { ton: 'var(--gold)', zeichen: '🎟️', kopf: 'Belohnung eingelöst' },
|
||||
};
|
||||
|
||||
/** Die zweite Zeile. Deutsch mit richtigen Pluralformen - "1 Monate"
|
||||
* liest sich wie ein Fehler, und das ist es auch. */
|
||||
function zusatz(ev) {
|
||||
switch (ev.type) {
|
||||
case 'resub':
|
||||
return ev.months ? ev.months + (ev.months === 1 ? ' Monat' : ' Monate') + ' dabei' : '';
|
||||
case 'subgift':
|
||||
return ev.recipient ? 'an ' + Overlay.esc(ev.recipient) : '';
|
||||
case 'massgift':
|
||||
return (ev.amount || 1) + (ev.amount === 1 ? ' Abo' : ' Abos') + ' an den Chat';
|
||||
case 'raid':
|
||||
return 'mit ' + Overlay.zahl(ev.amount || 0) +
|
||||
((ev.amount === 1) ? ' Zuschauer' : ' Zuschauern');
|
||||
case 'cheer':
|
||||
return Overlay.zahl(ev.amount || 0) + ' Bits';
|
||||
case 'reward':
|
||||
// Der Titel der Belohnung ist die Information, nicht die Punktzahl.
|
||||
return Overlay.esc(ev.reward || '') +
|
||||
(ev.amount ? ' · ' + Overlay.zahl(ev.amount) + ' Punkte' : '');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function zeige(ev) {
|
||||
var art = ARTEN[ev.type];
|
||||
if (!art) return naechste();
|
||||
|
||||
var karte = document.createElement('div');
|
||||
karte.className = 'karte an';
|
||||
karte.style.setProperty('--ton', art.ton);
|
||||
karte.style.setProperty('--dauer', (dauer / 1000) + 's');
|
||||
|
||||
var text = zusatz(ev);
|
||||
// Die Nachricht nur dort, wo Zuschauer wirklich eine schreiben.
|
||||
var notiz = (ev.message && (ev.type === 'resub' || ev.type === 'cheer' ||
|
||||
ev.type === 'sub' || ev.type === 'reward'))
|
||||
? '<div class="notiz">' + Overlay.emoteHtml(ev.message, []) + '</div>' : '';
|
||||
|
||||
karte.innerHTML =
|
||||
'<div class="zeichen">' + art.zeichen + '</div>' +
|
||||
'<div>' +
|
||||
'<div class="kopf">' + art.kopf + '</div>' +
|
||||
'<div class="name kontur">' + Overlay.esc(ev.name || 'Jemand') + '</div>' +
|
||||
(text ? '<div class="zusatz">' + text + '</div>' : '') +
|
||||
notiz +
|
||||
'</div>' +
|
||||
'<div class="uhr"></div>';
|
||||
|
||||
// Der Lichtstreifen liegt auf der Karte, nicht im Markup - so laeuft er
|
||||
// genau einmal und stoert das Raster nicht.
|
||||
karte.classList.add('streifen');
|
||||
platz.appendChild(karte);
|
||||
|
||||
setTimeout(function () {
|
||||
karte.classList.remove('an');
|
||||
karte.classList.add('weg');
|
||||
setTimeout(function () { karte.remove(); naechste(); }, 460);
|
||||
}, dauer);
|
||||
}
|
||||
|
||||
function naechste() {
|
||||
var ev = warteschlange.shift();
|
||||
if (!ev) { laeuft = false; return; }
|
||||
laeuft = true;
|
||||
zeige(ev);
|
||||
}
|
||||
|
||||
Overlay.on('alert', function (ev) {
|
||||
// Erstchatter sind haeufig und sollen den Bildschirm nicht sprengen -
|
||||
// wer sie nicht will, haengt ?erstchatter=0 an die Adresse.
|
||||
if (ev.type === 'first' && !Overlay.param('erstchatter', true)) return;
|
||||
warteschlange.push(ev);
|
||||
// Eine Warteschlange, die bei einem Bot-Angriff auf 200 waechst, laeuft
|
||||
// eine halbe Stunde lang durch. Lieber die aeltesten fallen lassen.
|
||||
if (warteschlange.length > 12) warteschlange.splice(0, warteschlange.length - 12);
|
||||
if (!laeuft) naechste();
|
||||
});
|
||||
|
||||
// Nach dem Neuverbinden NICHT die alten Alarme nachspielen: ein Abo von
|
||||
// vor zehn Minuten noch einmal einzublenden ist schlimmer als es zu
|
||||
// verpassen. Der Schnappschuss wird hier bewusst ignoriert.
|
||||
Overlay.on('state', function () { });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Chat</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Von unten nach oben, wie im Chat selbst: die neueste Nachricht steht
|
||||
* unten, wo das Auge sie erwartet. Deshalb column-reverse und nicht ein
|
||||
* Scrollcontainer - der haette einen Balken und wuerde mitwandern. */
|
||||
#liste {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zeile {
|
||||
padding: 9px 14px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(18, 14, 29, .66);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, .45);
|
||||
font-size: 19px;
|
||||
line-height: 1.38;
|
||||
word-break: break-word;
|
||||
animation: herein .42s cubic-bezier(.16, 1.1, .3, 1) both;
|
||||
transition: opacity .6s, transform .6s;
|
||||
}
|
||||
@keyframes herein {
|
||||
from { opacity: 0; transform: translateX(-26px) scale(.97); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.zeile.geht { opacity: 0; transform: translateX(-20px); }
|
||||
|
||||
.name { font-weight: 700; margin-right: .45em; }
|
||||
.text .emote { height: 1.55em; vertical-align: -.4em; margin: 0 .05em; }
|
||||
|
||||
/* Ein GIF ist kein Emote: es steht fuer sich, in eigener Zeile, und wird
|
||||
* begrenzt. Ohne Deckel schiebt ein 500 Pixel breites GIF die ganze Spalte
|
||||
* auf - in einer 460 Pixel schmalen Browser-Source faellt das sofort auf. */
|
||||
.text .gif {
|
||||
display: block;
|
||||
margin: 6px 0 2px;
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
width: auto;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, .35);
|
||||
}
|
||||
|
||||
/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten
|
||||
* ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */
|
||||
.abzeichen {
|
||||
display: inline-block;
|
||||
width: .5em; height: .5em;
|
||||
border-radius: 50%;
|
||||
margin-right: .4em;
|
||||
vertical-align: .08em;
|
||||
}
|
||||
.abzeichen.mod { background: var(--gruen); }
|
||||
.abzeichen.abo { background: var(--lila); }
|
||||
.abzeichen.host { background: var(--rot); }
|
||||
|
||||
/* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in
|
||||
* dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante
|
||||
* tragen das jetzt der Farbverlauf und die Zeile darueber allein, deshalb ist
|
||||
* der Verlauf kraeftiger als vorher. */
|
||||
.zeile.erstmals {
|
||||
background: linear-gradient(90deg, rgba(74, 222, 128, .24), rgba(18, 14, 29, .66) 65%);
|
||||
}
|
||||
.zeile.erstmals::before {
|
||||
content: "zum ersten Mal hier";
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gruen);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.zeile.bits { background: linear-gradient(90deg, rgba(255, 197, 61, .22), rgba(18, 14, 29, .66) 65%); }
|
||||
.zeile.bits .name { color: var(--gold) !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="liste"></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var liste = document.getElementById('liste');
|
||||
var maximal = Overlay.param('max', 14);
|
||||
var lebensdauer = Overlay.param('sekunden', 120) * 1000;
|
||||
var befehleZeigen = Overlay.param('befehle', false);
|
||||
|
||||
/* Twitchs Farben werden auf dunklem Grund teils unlesbar - dunkelblau auf
|
||||
* Schwarz ist nicht zu entziffern. Deshalb wird die Helligkeit angehoben
|
||||
* statt die Farbe zu verwerfen: die Farbe ist Wiedererkennung. */
|
||||
function lesbar(hex) {
|
||||
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) return '#c9a7ff';
|
||||
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16),
|
||||
b = parseInt(hex.slice(5, 7), 16);
|
||||
var hell = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
if (hell >= 0.5) return hex;
|
||||
var f = 0.5 / Math.max(hell, 0.08);
|
||||
var an = function (v) { return Math.min(255, Math.round(v * f)); };
|
||||
return '#' + [an(r), an(g), an(b)].map(function (v) {
|
||||
return v.toString(16).padStart(2, '0');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function entferne(el) {
|
||||
// Doppelt aufrufbar: das Zeitlimit und das Nachruecken koennen dieselbe
|
||||
// Zeile treffen.
|
||||
if (!el || !el.parentNode || el.classList.contains('geht')) return;
|
||||
el.classList.add('geht');
|
||||
setTimeout(function () { el.remove(); }, 620);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeilen, die ueber die Obergrenze hinausgehen, ausblenden.
|
||||
*
|
||||
* Gezaehlt wird nur, was noch nicht auf dem Weg nach draussen ist: eine
|
||||
* ausblendende Zeile haengt noch eine halbe Sekunde im DOM. Wer stattdessen
|
||||
* `liste.children.length` zaehlt und in einer while-Schleife entfernt,
|
||||
* bekommt eine Endlosschleife - die Laenge sinkt ja erst nach der
|
||||
* Animation. In OBS friert die Quelle damit beim vierzehnten Chatbeitrag
|
||||
* ein, und im Browser sieht man einen haengenden Tab.
|
||||
*/
|
||||
function aufraeumen() {
|
||||
var bleibend = [];
|
||||
var zeilen = liste.children;
|
||||
for (var i = 0; i < zeilen.length; i++) {
|
||||
if (!zeilen[i].classList.contains('geht')) bleibend.push(zeilen[i]);
|
||||
}
|
||||
// index 0 ist die neueste Zeile, also faellt das Ende zuerst.
|
||||
for (var j = maximal; j < bleibend.length; j++) entferne(bleibend[j]);
|
||||
}
|
||||
|
||||
function zeige(ev) {
|
||||
if (!befehleZeigen && ev.text.charAt(0) === '!') return;
|
||||
|
||||
var el = document.createElement('div');
|
||||
el.className = 'zeile' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : '');
|
||||
el.dataset.id = ev.id;
|
||||
el.dataset.user = ev.userId;
|
||||
|
||||
var abzeichen = '';
|
||||
if (ev.broadcaster) abzeichen += '<span class="abzeichen host"></span>';
|
||||
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>';
|
||||
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>';
|
||||
|
||||
el.innerHTML =
|
||||
abzeichen +
|
||||
'<span class="name" style="color:' + lesbar(ev.color) + '">' +
|
||||
Overlay.esc(ev.name) + '</span>' +
|
||||
'<span class="text">' + Overlay.nachrichtHtml(ev.text, ev.gifs, ev.emotes) + '</span>';
|
||||
|
||||
// prepend, weil column-reverse die Liste ohnehin umdreht.
|
||||
liste.insertBefore(el, liste.firstChild);
|
||||
aufraeumen();
|
||||
|
||||
if (lebensdauer > 0) setTimeout(function () { entferne(el); }, lebensdauer);
|
||||
}
|
||||
|
||||
Overlay.on('chat', zeige);
|
||||
|
||||
// Loeschen durch einen Mod muss durchschlagen: eine geloeschte Beleidigung,
|
||||
// die noch zwei Minuten im Bild steht, steht auch in der Aufzeichnung.
|
||||
Overlay.on('clear', function (ev) {
|
||||
var alle = Array.prototype.slice.call(liste.children);
|
||||
for (var i = 0; i < alle.length; i++) {
|
||||
var el = alle[i];
|
||||
if (!ev.messageId && !ev.userId) entferne(el);
|
||||
else if (ev.messageId && el.dataset.id === ev.messageId) entferne(el);
|
||||
else if (ev.userId && el.dataset.user === ev.userId) entferne(el);
|
||||
}
|
||||
});
|
||||
|
||||
// Nach dem Neuverbinden den letzten Stand zeigen, damit die Quelle nicht
|
||||
// leer im Bild steht, bis jemand schreibt.
|
||||
Overlay.on('state', function (ev) {
|
||||
liste.innerHTML = '';
|
||||
var letzte = ev.chat.slice(-maximal);
|
||||
for (var i = 0; i < letzte.length; i++) zeige(letzte[i]);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,172 @@
|
||||
/* Gemeinsamer Anschluss aller Browser-Sources an den Ereignisstrom.
|
||||
*
|
||||
* Bewusst kein Modul und kein Build: OBS laedt diese Datei direkt, und jede
|
||||
* Zwischenstufe waere ein Ding, das im Stream kaputtgehen kann.
|
||||
*
|
||||
* Die drei Dinge, an denen solche Overlays sonst scheitern:
|
||||
*
|
||||
* 1. Die Quelle bleibt nach einem Neustart des Dienstes tot. -> EventSource
|
||||
* verbindet von allein neu, und ein Wachhund greift ein, wenn selbst das
|
||||
* haengt (OBS haelt tote Verbindungen erstaunlich lange fuer lebendig).
|
||||
* 2. Die Quelle ist nach dem Neuverbinden leer. -> Der Server schickt als
|
||||
* erstes einen Schnappschuss, den die Seiten als `state` bekommen.
|
||||
* 3. Chat-Text landet ungeprueft im DOM. -> `Overlay.esc` und `emoteHtml`
|
||||
* sind die einzigen Wege, auf denen fremder Text hier hineinkommt.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var params = new URLSearchParams(location.search);
|
||||
var handlers = {};
|
||||
var quelle = null;
|
||||
var letzteNachricht = Date.now();
|
||||
|
||||
/** Ohne Nachricht in dieser Zeit gilt die Verbindung als tot. Der Server
|
||||
* schickt alle 15 s einen Herzschlag, also sind 45 s drei verpasste. */
|
||||
var TOT_NACH_MS = 45000;
|
||||
|
||||
function verbinde() {
|
||||
if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } }
|
||||
// `quelle` sagt dem Server, welche Seite zuhoert. Er zaehlt damit nur -
|
||||
// das Pult will wissen, ob die Sound-Quelle in OBS ueberhaupt offen ist.
|
||||
var quelleName = location.pathname.replace(/^\/+|\.html$/g, '') || 'index';
|
||||
var url = '/events?quelle=' + encodeURIComponent(quelleName) +
|
||||
(params.get('key') ? '&key=' + encodeURIComponent(params.get('key')) : '');
|
||||
quelle = new EventSource(url);
|
||||
letzteNachricht = Date.now();
|
||||
|
||||
quelle.onopen = function () {
|
||||
document.body.classList.remove('offline');
|
||||
letzteNachricht = Date.now();
|
||||
};
|
||||
quelle.onerror = function () {
|
||||
document.body.classList.add('offline');
|
||||
};
|
||||
quelle.onmessage = function (e) {
|
||||
letzteNachricht = Date.now();
|
||||
document.body.classList.remove('offline');
|
||||
var ev;
|
||||
try { ev = JSON.parse(e.data); } catch (err) { return; }
|
||||
if (ev.kind === 'beat') return;
|
||||
var liste = handlers[ev.kind] || [];
|
||||
for (var i = 0; i < liste.length; i++) {
|
||||
try { liste[i](ev); } catch (err) { console.error('[overlay]', ev.kind, err); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
if (Date.now() - letzteNachricht > TOT_NACH_MS) {
|
||||
console.warn('[overlay] nichts mehr gehoert, verbinde neu');
|
||||
verbinde();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
window.Overlay = {
|
||||
/** Auf eine Ereignisart hoeren. Mehrere Hoerer je Art sind erlaubt. */
|
||||
on: function (kind, fn) {
|
||||
(handlers[kind] = handlers[kind] || []).push(fn);
|
||||
return window.Overlay;
|
||||
},
|
||||
|
||||
/** Einstellung aus der Adresse, z. B. ?sekunden=60. */
|
||||
param: function (name, fallback) {
|
||||
var v = params.get(name);
|
||||
if (v === null || v === '') return fallback;
|
||||
if (typeof fallback === 'number') {
|
||||
var n = Number(v);
|
||||
return isFinite(n) ? n : fallback;
|
||||
}
|
||||
if (typeof fallback === 'boolean') return v !== '0' && v !== 'false' && v !== 'nein';
|
||||
return v;
|
||||
},
|
||||
|
||||
/** Der einzige erlaubte Weg, fremden Text ins DOM zu bringen. */
|
||||
esc: function (s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Text mit eingesetzten Emotes. Erst maskieren, dann ersetzen - in der
|
||||
* anderen Reihenfolge waere jeder Emote-Name ein Einfallstor.
|
||||
*
|
||||
* Twitch liefert nur Name und ID, nicht die Position. Ersetzt wird
|
||||
* deshalb der Name als ganzes Wort. BTTV und 7TV sind nicht dabei; die
|
||||
* haengen an einer zweiten API und waeren ein eigenes Stueck Arbeit.
|
||||
*/
|
||||
emoteHtml: function (text, emotes) {
|
||||
var html = window.Overlay.esc(text);
|
||||
if (!emotes || !emotes.length) return html;
|
||||
for (var i = 0; i < emotes.length; i++) {
|
||||
var e = emotes[i];
|
||||
if (!e.id || !e.name) continue;
|
||||
var sicher = window.Overlay.esc(e.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
var src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||
encodeURIComponent(e.id) + '/default/dark/2.0';
|
||||
html = html.replace(new RegExp('(^|\\s)' + sicher + '(?=\\s|$)', 'g'),
|
||||
'$1<img class="emote" src="' + src + '" alt="' + window.Overlay.esc(e.name) + '">');
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
/**
|
||||
* Nachrichtentext mit eingesetzten GIFs und Emotes.
|
||||
*
|
||||
* GIFs kommen anders als Emotes mit einem Zeichenbereich: im Text steht
|
||||
* nur ein Platzhalter wie "[Idk GIF by Respective]", und der gehoert
|
||||
* ersetzt. Wer nur den Text anzeigt, zeigt genau diese eckige Klammer.
|
||||
*/
|
||||
nachrichtHtml: function (text, gifs, emotes) {
|
||||
if (!gifs || !gifs.length) return window.Overlay.emoteHtml(text, emotes);
|
||||
|
||||
// Nach Zeichen zerlegen, nicht nach Bytes: die Bereiche zaehlen
|
||||
// Zeichen, und ein Emoji im Text wuerde die Rechnung sonst verschieben.
|
||||
var chars = Array.from(text);
|
||||
var sortiert = gifs.slice().sort(function (a, b) { return a.from - b.from; });
|
||||
var html = '';
|
||||
var pos = 0;
|
||||
|
||||
for (var i = 0; i < sortiert.length; i++) {
|
||||
var g = sortiert[i];
|
||||
if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') continue;
|
||||
if (g.from > pos) {
|
||||
html += window.Overlay.emoteHtml(chars.slice(pos, g.from).join(''), emotes);
|
||||
}
|
||||
html += '<img class="gif" src="' + window.Overlay.esc(g.url) + '" alt="GIF"' +
|
||||
' onerror="this.replaceWith(document.createTextNode(this.alt))">';
|
||||
pos = Math.max(pos, g.to + 1);
|
||||
}
|
||||
if (pos < chars.length) {
|
||||
html += window.Overlay.emoteHtml(chars.slice(pos).join(''), emotes);
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
/** "2 Std 14 Min" aus einem ISO-Zeitpunkt. */
|
||||
laufzeit: function (seit) {
|
||||
var ms = Date.now() - Date.parse(seit);
|
||||
if (!isFinite(ms) || ms < 0) return '';
|
||||
var m = Math.floor(ms / 60000);
|
||||
var h = Math.floor(m / 60);
|
||||
return h > 0 ? h + ':' + String(m % 60).padStart(2, '0') + ' Std'
|
||||
: m + ' Min';
|
||||
},
|
||||
|
||||
/** Zahl mit Tausenderpunkt, wie man sie in Deutschland liest. */
|
||||
zahl: function (n) {
|
||||
return Number(n || 0).toLocaleString('de-DE');
|
||||
},
|
||||
};
|
||||
|
||||
// Der Punkt unten rechts, der nur bei Verlust erscheint.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (params.get('probe')) document.body.classList.add('probe');
|
||||
var d = document.createElement('div');
|
||||
d.className = 'getrennt';
|
||||
d.textContent = 'keine Verbindung';
|
||||
document.body.appendChild(d);
|
||||
verbinde();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,194 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Effekte</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Ein Canvas fuer alles. Hunderte DOM-Knoten, die sich pro Bild bewegen,
|
||||
* kosten in OBS spuerbar Leistung - und Leistung, die hier draufgeht, fehlt
|
||||
* beim Encoder. */
|
||||
canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* Der kurze Lichtschein bei einer Chat-Spitze. Liegt hinter den Teilchen und
|
||||
* ist das, was den Effekt "teuer" wirken laesst. */
|
||||
#schein {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: radial-gradient(ellipse at 50% 60%,
|
||||
rgba(169, 112, 255, .32), transparent 62%);
|
||||
}
|
||||
#schein.an { animation: blitz 1.6s ease-out both; }
|
||||
@keyframes blitz {
|
||||
0% { opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="schein"></div>
|
||||
<canvas id="bild"></canvas>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var canvas = document.getElementById('bild');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var schein = document.getElementById('schein');
|
||||
var teilchen = [];
|
||||
var bilder = {}; // Emote-ID -> Image
|
||||
var letzteEmotes = []; // was gerade im Chat benutzt wird
|
||||
var laeuft = false;
|
||||
|
||||
/** Obergrenze. Ein Raid mit 200 Leuten darf den Encoder nicht umbringen. */
|
||||
var MAX = Overlay.param('max', 260);
|
||||
|
||||
function passeAn() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
passeAn();
|
||||
window.addEventListener('resize', passeAn);
|
||||
|
||||
function bild(id) {
|
||||
if (bilder[id]) return bilder[id];
|
||||
var img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||
encodeURIComponent(id) + '/default/dark/3.0';
|
||||
bilder[id] = img;
|
||||
return img;
|
||||
}
|
||||
|
||||
/** Emote-Regen: faellt von oben, dreht sich leicht, verblasst unten. */
|
||||
function regen(anzahl) {
|
||||
var quellen = letzteEmotes.slice(-12);
|
||||
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
|
||||
var e = quellen.length ? quellen[(Math.random() * quellen.length) | 0] : null;
|
||||
teilchen.push({
|
||||
art: e ? 'emote' : 'punkt',
|
||||
img: e ? bild(e) : null,
|
||||
farbe: farbe(),
|
||||
x: Math.random() * canvas.width,
|
||||
y: -60 - Math.random() * canvas.height * 0.5,
|
||||
vx: (Math.random() - 0.5) * 0.8,
|
||||
vy: 2.2 + Math.random() * 3.4,
|
||||
g: 0.02,
|
||||
dreh: (Math.random() - 0.5) * 0.06,
|
||||
winkel: (Math.random() - 0.5) * 0.7,
|
||||
groesse: 34 + Math.random() * 30,
|
||||
leben: 1,
|
||||
zerfall: 0.0016 + Math.random() * 0.0016,
|
||||
});
|
||||
}
|
||||
starte();
|
||||
}
|
||||
|
||||
var PALETTE = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d', '#4ade80'];
|
||||
function farbe() { return PALETTE[(Math.random() * PALETTE.length) | 0]; }
|
||||
|
||||
/** Konfetti: schiesst aus der unteren Mitte nach oben und faellt zurueck. */
|
||||
function konfetti(anzahl) {
|
||||
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
|
||||
var winkel = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
|
||||
var tempo = 9 + Math.random() * 11;
|
||||
teilchen.push({
|
||||
art: 'schnipsel',
|
||||
farbe: farbe(),
|
||||
x: canvas.width / 2 + (Math.random() - 0.5) * 340,
|
||||
y: canvas.height + 14,
|
||||
vx: Math.cos(winkel) * tempo,
|
||||
vy: Math.sin(winkel) * tempo,
|
||||
g: 0.28,
|
||||
dreh: (Math.random() - 0.5) * 0.4,
|
||||
winkel: Math.random() * Math.PI,
|
||||
groesse: 9 + Math.random() * 9,
|
||||
leben: 1,
|
||||
zerfall: 0.0055 + Math.random() * 0.004,
|
||||
});
|
||||
}
|
||||
starte();
|
||||
}
|
||||
|
||||
function starte() {
|
||||
if (laeuft) return;
|
||||
laeuft = true;
|
||||
requestAnimationFrame(schritt);
|
||||
}
|
||||
|
||||
function schritt() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (var i = teilchen.length - 1; i >= 0; i--) {
|
||||
var t = teilchen[i];
|
||||
t.vy += t.g;
|
||||
t.x += t.vx;
|
||||
t.y += t.vy;
|
||||
t.winkel += t.dreh;
|
||||
t.leben -= t.zerfall;
|
||||
|
||||
if (t.leben <= 0 || t.y > canvas.height + 120) { teilchen.splice(i, 1); continue; }
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, t.leben));
|
||||
ctx.translate(t.x, t.y);
|
||||
ctx.rotate(t.winkel);
|
||||
|
||||
if (t.art === 'emote' && t.img && t.img.complete && t.img.naturalWidth) {
|
||||
ctx.drawImage(t.img, -t.groesse / 2, -t.groesse / 2, t.groesse, t.groesse);
|
||||
} else if (t.art === 'schnipsel') {
|
||||
ctx.fillStyle = t.farbe;
|
||||
// Schmales Rechteck statt Quadrat: sieht in Drehung nach Papier aus.
|
||||
ctx.fillRect(-t.groesse / 2, -t.groesse / 4, t.groesse, t.groesse / 2);
|
||||
} else {
|
||||
ctx.fillStyle = t.farbe;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, t.groesse / 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (teilchen.length) requestAnimationFrame(schritt);
|
||||
else { laeuft = false; ctx.clearRect(0, 0, canvas.width, canvas.height); }
|
||||
}
|
||||
|
||||
// ---- Ausloeser
|
||||
|
||||
Overlay.on('chat', function (ev) {
|
||||
for (var i = 0; i < ev.emotes.length; i++) {
|
||||
if (letzteEmotes.indexOf(ev.emotes[i].id) === -1) letzteEmotes.push(ev.emotes[i].id);
|
||||
}
|
||||
if (letzteEmotes.length > 24) letzteEmotes.splice(0, letzteEmotes.length - 24);
|
||||
});
|
||||
|
||||
Overlay.on('hype', function (ev) {
|
||||
schein.classList.remove('an');
|
||||
void schein.offsetWidth;
|
||||
schein.classList.add('an');
|
||||
// Je hoeher die Punktzahl, desto mehr - aber gedeckelt, sonst steht bei
|
||||
// einem echten Ausbruch das Bild voll und man sieht das Spiel nicht mehr.
|
||||
regen(Math.min(110, 34 + Math.round((ev.score || 1) * 9)));
|
||||
});
|
||||
|
||||
// Konfetti als eigenes Ereignis: welche Belohnung Konfetti ausloest,
|
||||
// entscheidet der Hub - diese Seite muss davon nichts wissen.
|
||||
Overlay.on('konfetti', function (ev) {
|
||||
konfetti(Math.round(90 * (ev.staerke || 1)));
|
||||
});
|
||||
|
||||
Overlay.on('alert', function (ev) {
|
||||
if (ev.type === 'raid') konfetti(Math.min(180, 70 + (ev.amount || 0) * 2));
|
||||
else if (ev.type === 'massgift') konfetti(Math.min(180, 60 + (ev.amount || 0) * 10));
|
||||
else if (ev.type === 'sub' || ev.type === 'resub' || ev.type === 'subgift') konfetti(80);
|
||||
else if (ev.type === 'cheer' && (ev.amount || 0) >= 100) konfetti(60);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,227 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Kamerarahmen</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Rahmen um die Facecam, dazu die Werte der alten Statusleiste auf einer
|
||||
* Platte darunter.
|
||||
*
|
||||
* Die Kamera liegt bündig oben links, 480x270 (docs/obs-layout.md). Genau
|
||||
* dieser Bereich wird für die Shorts ausgeschnitten, und das Gameplay ab
|
||||
* x=515. Deshalb liegt hier NICHTS über der Kamera: der Rahmen nutzt nur den
|
||||
* Streifen rechts (480 bis 515) und den freien Platz darunter. Wer den
|
||||
* Rahmen nach innen zieht, hat ihn in jedem Short.
|
||||
*
|
||||
* Oben und links gibt es keinen Rahmen - dort ist der Bildrand.
|
||||
*
|
||||
* In OBS: Browser-Quelle 520x380, Position 0,0, über der Kamera. */
|
||||
:root {
|
||||
--cam-w: 480px;
|
||||
--cam-h: 270px;
|
||||
--dicke: 7px;
|
||||
--abstand: 4px; /* Luft zwischen Kamera und Rahmen */
|
||||
--platte-h: 74px;
|
||||
--verlauf: linear-gradient(135deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
|
||||
}
|
||||
|
||||
/* Der Rahmen: ein L um die rechte und untere Kante, außen abgerundet.
|
||||
* Gezeichnet als Fläche mit ausgespartem Inneren, damit der Farbverlauf
|
||||
* ohne Stoß um die Ecke läuft. */
|
||||
#rahmen {
|
||||
position: fixed;
|
||||
left: 0; top: 0;
|
||||
width: calc(var(--cam-w) + var(--abstand) + var(--dicke));
|
||||
height: calc(var(--cam-h) + var(--abstand) + var(--dicke));
|
||||
border-bottom-right-radius: 22px;
|
||||
background: var(--verlauf);
|
||||
background-size: 300% 300%;
|
||||
animation: wandern 10s linear infinite;
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) top left / calc(var(--cam-w) + var(--abstand)) calc(var(--cam-h) + var(--abstand)) no-repeat,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
filter: drop-shadow(0 0 10px rgba(169, 112, 255, .45));
|
||||
transition: filter .4s;
|
||||
}
|
||||
@keyframes wandern { to { background-position: 300% 300%; } }
|
||||
|
||||
/* Platte unter der Kamera. Hängt am Rahmen, damit beides als ein Stück
|
||||
* gelesen wird und nicht als zwei Overlays, die zufällig nah beieinander
|
||||
* liegen. */
|
||||
#platte {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: calc(var(--cam-h) + var(--abstand) + var(--dicke));
|
||||
width: calc(var(--cam-w) + var(--abstand) + var(--dicke));
|
||||
height: var(--platte-h);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 18px 0 16px;
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 16px;
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
}
|
||||
|
||||
.links { min-width: 0; }
|
||||
.zeile1 { display: flex; align-items: center; gap: 10px; }
|
||||
.punkt {
|
||||
width: 11px; height: 11px; flex: none;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
}
|
||||
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
|
||||
@keyframes klopfen {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
|
||||
70% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
}
|
||||
.kanal { font-size: 23px; font-weight: 700; letter-spacing: -.01em; line-height: 1.1; }
|
||||
#kategorie {
|
||||
font-size: 15px; color: var(--schrift-leise); margin-top: 3px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.werte { display: flex; gap: 18px; }
|
||||
.wert { text-align: right; }
|
||||
.wert .zahl {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 6px;
|
||||
font: 700 22px/1.1 var(--font-zahl);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wert svg { width: 17px; height: 17px; opacity: .8; }
|
||||
.wert .beschriftung {
|
||||
font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
.zahl.neu { animation: aufleuchten .8s ease-out; }
|
||||
@keyframes aufleuchten {
|
||||
0% { color: var(--tuerkis); transform: translateY(-3px); }
|
||||
100% { color: inherit; transform: none; }
|
||||
}
|
||||
|
||||
/* Nicht live: der Rahmen wird ruhig und grau. So sieht man in der
|
||||
* Vorschau sofort, ob der Hub den Stream erkannt hat. */
|
||||
body:not(.live) #rahmen {
|
||||
background: linear-gradient(135deg, #3a3550, #57507a);
|
||||
animation: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Aufleuchten bei Abo, Raid, Bits: der Rahmen glüht kurz in der Farbe des
|
||||
* Ereignisses. Die große Einblendung macht alerts.html - das hier ist nur
|
||||
* das Echo an der Kamera, damit der Blick dorthin geht, wo Abdu reagiert. */
|
||||
body.puls #rahmen { animation: wandern 1.2s linear infinite; }
|
||||
body.puls-lila #rahmen { filter: drop-shadow(0 0 22px var(--lila)); }
|
||||
body.puls-pink #rahmen { filter: drop-shadow(0 0 22px var(--pink)); }
|
||||
body.puls-gold #rahmen { filter: drop-shadow(0 0 22px var(--gold)); }
|
||||
body.puls-tuerkis #rahmen { filter: drop-shadow(0 0 22px var(--tuerkis)); }
|
||||
|
||||
/* Nur zum Ansehen im Browser (?probe=1): ein Platzhalter, wo die Kamera
|
||||
* sitzt. In OBS liegt dort das echte Bild. */
|
||||
#kamera-probe {
|
||||
display: none;
|
||||
position: fixed; left: 0; top: 0;
|
||||
width: var(--cam-w); height: var(--cam-h);
|
||||
background:
|
||||
radial-gradient(40% 55% at 50% 60%, #4a3b6b, transparent 70%),
|
||||
linear-gradient(160deg, #2b2440, #17131f);
|
||||
color: var(--schrift-leise);
|
||||
font: 500 14px/1 var(--font-zahl);
|
||||
display: none; place-items: center;
|
||||
}
|
||||
body.probe #kamera-probe { display: grid; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="kamera-probe">Facecam 480 × 270</div>
|
||||
<div id="rahmen"></div>
|
||||
<div id="platte">
|
||||
<div class="links">
|
||||
<div class="zeile1">
|
||||
<span class="punkt"></span>
|
||||
<span class="kanal kontur" id="kanal">abdulive_</span>
|
||||
</div>
|
||||
<div id="kategorie">offline</div>
|
||||
</div>
|
||||
<div class="werte">
|
||||
<div class="wert">
|
||||
<span class="zahl" id="zuschauer">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg><span>-</span>
|
||||
</span>
|
||||
<span class="beschriftung">Zuschauer</span>
|
||||
</div>
|
||||
<div class="wert">
|
||||
<span class="zahl" id="laufzeit"><span>-</span></span>
|
||||
<span class="beschriftung">live seit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="getrennt">keine Verbindung</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (Overlay.param('probe', false)) document.body.classList.add('probe');
|
||||
|
||||
var kategorie = document.getElementById('kategorie');
|
||||
var zuschauer = document.getElementById('zuschauer');
|
||||
var laufzeit = document.getElementById('laufzeit');
|
||||
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
|
||||
var seit = null;
|
||||
|
||||
function setze(el, wert) {
|
||||
var ziel = el.lastElementChild;
|
||||
if (ziel.textContent === wert) return;
|
||||
ziel.textContent = wert;
|
||||
el.classList.remove('neu');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('neu');
|
||||
}
|
||||
|
||||
function stand(ev) {
|
||||
document.body.classList.toggle('live', !!ev.live);
|
||||
// Die Kategorie statt des Titels: der Titel ist meist zu lang für 490 px
|
||||
// und steht ohnehin auf der Twitch-Seite direkt unter dem Bild.
|
||||
kategorie.textContent = ev.live ? (ev.category || 'live') : 'offline';
|
||||
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
|
||||
seit = ev.live ? ev.startedAt : null;
|
||||
tick();
|
||||
}
|
||||
function tick() { laufzeit.lastElementChild.textContent = seit ? Overlay.laufzeit(seit) : '-'; }
|
||||
setInterval(tick, 10000);
|
||||
|
||||
var FARBE = {
|
||||
sub: 'lila', resub: 'lila', subgift: 'pink', massgift: 'pink',
|
||||
raid: 'tuerkis', cheer: 'gold', follow: 'tuerkis', reward: 'gold',
|
||||
};
|
||||
var pulsTimer = null;
|
||||
function puls(farbe) {
|
||||
var b = document.body;
|
||||
b.className = b.className.replace(/\bpuls(-\w+)?\b/g, '').trim();
|
||||
void b.offsetWidth;
|
||||
b.classList.add('puls', 'puls-' + farbe);
|
||||
clearTimeout(pulsTimer);
|
||||
pulsTimer = setTimeout(function () {
|
||||
b.className = b.className.replace(/\bpuls(-\w+)?\b/g, '').trim();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
Overlay.on('stream', stand);
|
||||
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||
Overlay.on('alert', function (ev) { puls(FARBE[ev.type] || 'lila'); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,233 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Liegestütze</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Liegestütze aus Kanalpunkten: groß die offenen, darunter die in diesem
|
||||
* Stream schon gemachten.
|
||||
*
|
||||
* Eine Einlösung zählt hoch und zeigt kurz, wer es war. Wenn Abdu auf der
|
||||
* Übersichtsseite herunterzählt, blinkt die Zahl grün und "gemacht" wächst -
|
||||
* ohne Namen, damit das nicht wie eine Einlösung aussieht.
|
||||
*
|
||||
* In OBS: Browser-Quelle 420x200. Einstellungen in der Adresse:
|
||||
* ?verstecken=1 ausblenden, solange nichts offen und nichts gemacht ist
|
||||
* ?gemacht=0 die Zeile "gemacht" weglassen
|
||||
* ?titel=... andere Beschriftung als "Liegestütze" */
|
||||
#karte {
|
||||
position: absolute;
|
||||
left: 12px; bottom: 12px;
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 16px 26px 16px 18px;
|
||||
overflow: hidden;
|
||||
transition: opacity .5s, transform .5s, box-shadow .4s, border-color .4s;
|
||||
}
|
||||
#karte.weg { opacity: 0; transform: translateY(14px); }
|
||||
|
||||
.symbol {
|
||||
width: 58px; height: 58px; flex: none;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--lila), var(--pink));
|
||||
box-shadow: 0 6px 18px rgba(169, 112, 255, .45);
|
||||
}
|
||||
.symbol svg { width: 36px; height: 36px; }
|
||||
|
||||
.text { min-width: 0; }
|
||||
#titel {
|
||||
font: 600 13px/1 var(--font-zahl);
|
||||
letter-spacing: .16em; text-transform: uppercase;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
#zahl {
|
||||
display: block;
|
||||
font: 800 54px/1 var(--font);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -.02em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
#zahl.erledigt { font-size: 30px; color: var(--gruen); padding: 8px 0 6px; }
|
||||
|
||||
#gemacht {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-top: 7px;
|
||||
font: 600 16px/1 var(--font-zahl);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
#gemacht b { color: var(--gruen); font-weight: 800; }
|
||||
#gemacht svg { width: 15px; height: 15px; color: var(--gruen); }
|
||||
#gemacht.aus { display: none; }
|
||||
#gemacht.mehr b { animation: gemacht-mehr .9s ease-out; display: inline-block; }
|
||||
@keyframes gemacht-mehr {
|
||||
0% { transform: scale(1.35); }
|
||||
100% { transform: none; }
|
||||
}
|
||||
|
||||
/* Einlösung: Karte glüht gold, Zahl springt. */
|
||||
#karte.mehr { border-color: var(--gold); box-shadow: var(--schatten), 0 0 28px rgba(255, 197, 61, .55); }
|
||||
#zahl.springen { animation: springen .6s cubic-bezier(.3, 1.6, .5, 1); }
|
||||
@keyframes springen {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.22); color: var(--gold); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Korrektur nach unten: kurz grün, das heißt "gemacht". */
|
||||
#zahl.weniger { animation: weniger .9s ease-out; }
|
||||
@keyframes weniger {
|
||||
0% { color: var(--gruen); transform: translateY(-4px); }
|
||||
100% { color: inherit; transform: none; }
|
||||
}
|
||||
|
||||
/* "+5 von Name", steigt über der Karte auf. */
|
||||
#plus {
|
||||
position: absolute;
|
||||
left: 12px; bottom: 140px;
|
||||
max-width: 396px;
|
||||
padding: 8px 14px;
|
||||
font: 700 20px/1.1 var(--font);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
opacity: 0;
|
||||
}
|
||||
#plus b { color: var(--gold); margin-right: 6px; }
|
||||
#plus.zeigen { animation: aufsteigen 3.6s ease-out both; }
|
||||
@keyframes aufsteigen {
|
||||
0% { opacity: 0; transform: translateY(16px); }
|
||||
12% { opacity: 1; transform: none; }
|
||||
80% { opacity: 1; transform: none; }
|
||||
100% { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="plus" class="glas"></div>
|
||||
<div id="karte" class="glas">
|
||||
<div class="symbol" aria-hidden="true">
|
||||
<!-- Figur im Liegestütz: Kopf, Rücken, Arme, Boden. -->
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="#fff" stroke-width="3.4"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="17" r="4" fill="#fff" stroke="none"/>
|
||||
<path d="M15 21 L41 29"/>
|
||||
<path d="M17 22 L15 33"/>
|
||||
<path d="M41 29 L42 33"/>
|
||||
<path d="M6 37 H44" stroke-width="2.4" opacity=".7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text">
|
||||
<div id="titel" class="kontur">Liegestütze</div>
|
||||
<span id="zahl" class="kontur">0</span>
|
||||
<div id="gemacht" class="kontur">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5"/>
|
||||
</svg>
|
||||
<span><b>0</b> gemacht</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var karte = document.getElementById('karte');
|
||||
var zahl = document.getElementById('zahl');
|
||||
var plus = document.getElementById('plus');
|
||||
var gemacht = document.getElementById('gemacht');
|
||||
var gemachtZahl = gemacht.querySelector('b');
|
||||
if (!Overlay.param('gemacht', true)) gemacht.classList.add('aus');
|
||||
var verstecken = Overlay.param('verstecken', false);
|
||||
document.getElementById('titel').textContent = Overlay.param('titel', 'Liegestütze');
|
||||
|
||||
var angezeigt = null; // was gerade im Bild steht
|
||||
var gemachtBisher = null;
|
||||
var lauf = null; // laufendes Hochzählen
|
||||
var glimmen = null;
|
||||
|
||||
function schreibe(n) {
|
||||
if (n === 0 && !verstecken) {
|
||||
zahl.textContent = 'erledigt';
|
||||
zahl.classList.add('erledigt');
|
||||
} else {
|
||||
zahl.textContent = Overlay.zahl(n);
|
||||
zahl.classList.remove('erledigt');
|
||||
}
|
||||
}
|
||||
|
||||
function sichtbarkeit() {
|
||||
karte.classList.toggle('weg', verstecken && angezeigt === 0 && !gemachtBisher);
|
||||
}
|
||||
|
||||
function zeigeGemacht(g) {
|
||||
if (typeof g !== 'number') return;
|
||||
var alt = gemachtBisher;
|
||||
gemachtBisher = g;
|
||||
gemachtZahl.textContent = Overlay.zahl(g);
|
||||
if (alt !== null && g > alt) nochmal(gemacht, 'mehr');
|
||||
}
|
||||
|
||||
function nochmal(el, klasse) {
|
||||
el.classList.remove(klasse);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(klasse);
|
||||
}
|
||||
|
||||
/** Zählt sichtbar von der alten zur neuen Zahl hoch. Bei großen Sprüngen
|
||||
* nicht jede einzelne Zahl, sonst dauert ein Setzen auf 500 ewig. */
|
||||
function hochzaehlen(von, bis) {
|
||||
clearInterval(lauf);
|
||||
var schritte = Math.min(bis - von, 20);
|
||||
var i = 0;
|
||||
lauf = setInterval(function () {
|
||||
i++;
|
||||
schreibe(i >= schritte ? bis : Math.round(von + (bis - von) * i / schritte));
|
||||
if (i >= schritte) clearInterval(lauf);
|
||||
}, 45);
|
||||
}
|
||||
|
||||
function stand(n, einloesung) {
|
||||
if (typeof n !== 'number') return;
|
||||
var alt = angezeigt;
|
||||
angezeigt = n;
|
||||
|
||||
// Erster Stand nach dem Verbinden: einfach hinschreiben, ohne Effekt.
|
||||
if (alt === null || alt === n) { clearInterval(lauf); schreibe(n); return; }
|
||||
|
||||
if (n > alt) {
|
||||
hochzaehlen(alt, n);
|
||||
nochmal(zahl, 'springen');
|
||||
karte.classList.add('mehr');
|
||||
clearTimeout(glimmen);
|
||||
glimmen = setTimeout(function () { karte.classList.remove('mehr'); }, 2500);
|
||||
} else {
|
||||
clearInterval(lauf);
|
||||
schreibe(n);
|
||||
nochmal(zahl, 'weniger');
|
||||
}
|
||||
|
||||
if (einloesung && einloesung.name) {
|
||||
plus.innerHTML = '<b>+' + Overlay.esc(einloesung.dazu) + '</b>' +
|
||||
'von ' + Overlay.esc(einloesung.name);
|
||||
nochmal(plus, 'zeigen');
|
||||
}
|
||||
}
|
||||
|
||||
Overlay.on('state', function (ev) {
|
||||
if (!ev.liegestuetze) return;
|
||||
stand(ev.liegestuetze.offen);
|
||||
zeigeGemacht(ev.liegestuetze.gemacht);
|
||||
sichtbarkeit();
|
||||
});
|
||||
Overlay.on('liegestuetze', function (ev) {
|
||||
stand(ev.offen, ev.dazu ? { dazu: ev.dazu, name: ev.name } : null);
|
||||
zeigeGemacht(ev.gemacht);
|
||||
sichtbarkeit();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "abdulive Pult",
|
||||
"short_name": "Pult",
|
||||
"description": "Soundboard, Szenen, Liegestütze und Vorlesen für den Stream von abdulive_.",
|
||||
"lang": "de",
|
||||
"id": "/pult",
|
||||
"start_url": "/pult",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0d0b14",
|
||||
"theme_color": "#0d0b14",
|
||||
"icons": [
|
||||
{ "src": "/icons/pult-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/pult-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/* Das Pult. Fuers Handy gebaut, nicht fuer OBS: anders als die
|
||||
* Browser-Sources hat es einen deckenden Hintergrund und will angefasst
|
||||
* werden. Farben wie in theme.css, damit Pult und Overlay zusammengehoeren.
|
||||
*
|
||||
* Grundregel fuer alles, was man im Stream mit einem Daumen trifft: eine
|
||||
* Taste ist mindestens 48 px hoch, und was passiert ist, sieht man an der
|
||||
* Taste selbst - nicht nur in einer Meldung am Rand. */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--grund: #0d0b14;
|
||||
--flaeche: #171327;
|
||||
--flaeche-2: #1f1a33;
|
||||
--linie: #2c2642;
|
||||
--linie-stark: #3a3352;
|
||||
--schrift: #ece9f6;
|
||||
--leise: #9b94b3;
|
||||
|
||||
--lila: #a970ff;
|
||||
--lila-tief: #6441a5;
|
||||
--tuerkis: #4fd1c5;
|
||||
--pink: #ff4fa3;
|
||||
--gold: #ffc53d;
|
||||
--gruen: #4ade80;
|
||||
--rot: #ef4444;
|
||||
--blau: #60a5fa;
|
||||
--grau: #8b84a3;
|
||||
|
||||
--rund: 16px;
|
||||
--kopf-h: 56px;
|
||||
--reiter-h: 64px;
|
||||
--oben: env(safe-area-inset-top, 0px);
|
||||
--unten: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
--font: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
||||
--font-zahl: "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--grund); }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--grund);
|
||||
color: var(--schrift);
|
||||
font: 16px/1.45 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
a { color: var(--lila); }
|
||||
code {
|
||||
font: .85em "Cascadia Mono", ui-monospace, Consolas, monospace;
|
||||
background: var(--flaeche-2); padding: .1em .35em; border-radius: 6px;
|
||||
}
|
||||
svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2;
|
||||
stroke-linecap: round; stroke-linejoin: round; flex: none; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ------------------------------------------------------------ Kopf */
|
||||
|
||||
.kopf {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
height: calc(var(--kopf-h) + var(--oben));
|
||||
padding: var(--oben) 16px 0;
|
||||
background: rgba(13, 11, 20, .88);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--linie);
|
||||
}
|
||||
.kanal { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.marke { font: 600 17px/1 var(--font-zahl); letter-spacing: -.01em; }
|
||||
.marke b { color: var(--lila); font-weight: 700; }
|
||||
|
||||
.live {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
font: 600 13px/1.2 var(--font-zahl); font-variant-numeric: tabular-nums;
|
||||
background: rgba(239, 68, 68, .16); color: #ffb3b3;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.live i { width: 8px; height: 8px; border-radius: 50%; background: var(--rot);
|
||||
animation: puls 1.6s ease-in-out infinite; flex: none; }
|
||||
.live.aus { background: var(--flaeche-2); color: var(--leise); }
|
||||
.live.aus i { background: var(--grau); animation: none; }
|
||||
|
||||
.kopf-rechts { display: flex; align-items: center; gap: 8px; }
|
||||
.rund {
|
||||
display: grid; place-items: center; width: 40px; height: 40px;
|
||||
border-radius: 50%; border: 1px solid var(--linie); background: transparent;
|
||||
color: var(--leise); cursor: pointer;
|
||||
}
|
||||
.rund[aria-pressed="true"] { color: var(--gold); border-color: rgba(255, 197, 61, .45);
|
||||
background: rgba(255, 197, 61, .1); }
|
||||
.netz { display: grid; place-items: center; width: 24px; height: 40px; }
|
||||
.netz i { width: 10px; height: 10px; border-radius: 50%; background: var(--rot);
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, .15); }
|
||||
.netz.an i { background: var(--gruen); box-shadow: 0 0 0 4px rgba(74, 222, 128, .14); }
|
||||
|
||||
.banner {
|
||||
position: sticky; top: calc(var(--kopf-h) + var(--oben)); z-index: 19;
|
||||
padding: 10px 16px; font-size: 14px;
|
||||
background: #3b1d1d; color: #ffd0d0; border-bottom: 1px solid #5c2a2a;
|
||||
}
|
||||
|
||||
@keyframes puls { 50% { opacity: .35; } }
|
||||
|
||||
/* ------------------------------------------------------------ Inhalt */
|
||||
|
||||
main {
|
||||
padding: 16px 16px calc(var(--reiter-h) + var(--unten) + 88px);
|
||||
max-width: 980px; margin: 0 auto;
|
||||
}
|
||||
.tab { display: none; }
|
||||
.tab.an { display: block; animation: rein .18s ease-out; }
|
||||
@keyframes rein { from { opacity: 0; transform: translateY(4px); } }
|
||||
|
||||
.leiste { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.luecke { flex: 1; }
|
||||
|
||||
.abschnitt {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin: 26px 0 10px; font: 600 13px/1 var(--font); letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--leise);
|
||||
}
|
||||
.abschnitt:first-child { margin-top: 4px; }
|
||||
.klein { font-size: 13px; color: var(--leise); margin: 10px 0; }
|
||||
.klein.mitte { text-align: center; }
|
||||
|
||||
.karte {
|
||||
background: var(--flaeche); border: 1px solid var(--linie);
|
||||
border-radius: var(--rund); padding: 16px; margin-bottom: 16px;
|
||||
}
|
||||
.karte .abschnitt { margin-top: 0; }
|
||||
|
||||
.hinweis {
|
||||
border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; font-size: 14px;
|
||||
background: var(--flaeche); border: 1px solid var(--linie);
|
||||
}
|
||||
.hinweis.warn { background: rgba(255, 197, 61, .08); border-color: rgba(255, 197, 61, .4);
|
||||
color: #ffe2a0; }
|
||||
.hinweis.warn a { color: var(--gold); }
|
||||
|
||||
/* ------------------------------------------------------------ Knoepfe */
|
||||
|
||||
.knopf {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-height: 44px; padding: 0 16px;
|
||||
font: 600 15px/1 var(--font); color: var(--schrift);
|
||||
background: var(--flaeche-2); border: 1px solid var(--linie-stark);
|
||||
border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none;
|
||||
text-decoration: none; touch-action: manipulation;
|
||||
transition: transform .08s, background .15s, border-color .15s;
|
||||
}
|
||||
.knopf:active { transform: scale(.96); }
|
||||
.knopf:disabled { opacity: .4; cursor: not-allowed; transform: none; }
|
||||
.knopf.primaer { background: var(--lila-tief); border-color: var(--lila); color: #fff; }
|
||||
.knopf.warnung { color: #ffb3b3; border-color: rgba(239, 68, 68, .5); background: rgba(239, 68, 68, .1); }
|
||||
.knopf.gross { min-height: 52px; padding: 0 22px; font-size: 16px; }
|
||||
.knopf.breit { width: 100%; }
|
||||
.knopf[aria-pressed="true"] { background: var(--schrift); color: var(--grund); border-color: var(--schrift); }
|
||||
.knopf.wartet { opacity: .7; pointer-events: none; }
|
||||
.knopf:focus-visible, .kachel:focus-visible, .szene:focus-visible, .rund:focus-visible,
|
||||
.reiter button:focus-visible { outline: 2px solid var(--lila); outline-offset: 2px; }
|
||||
|
||||
/* ------------------------------------------------------------ Soundraster */
|
||||
|
||||
.raster {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 640px) { .raster { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 12px; } }
|
||||
|
||||
.kachel {
|
||||
--c: var(--lila);
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
||||
aspect-ratio: 1; padding: 10px 8px; min-width: 0;
|
||||
border-radius: 18px; cursor: pointer; overflow: hidden;
|
||||
color: #fff; font: 600 14px/1.2 var(--font); text-align: center;
|
||||
background:
|
||||
radial-gradient(120% 90% at 30% 0%, color-mix(in oklab, var(--c) 42%, transparent), transparent 70%),
|
||||
color-mix(in oklab, var(--c) 14%, var(--flaeche));
|
||||
border: 1px solid color-mix(in oklab, var(--c) 45%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
|
||||
touch-action: manipulation; user-select: none; -webkit-user-select: none;
|
||||
transition: transform .08s ease-out;
|
||||
}
|
||||
.kachel:active { transform: scale(.93); }
|
||||
.kachel .emoji { font-size: 34px; line-height: 1; }
|
||||
.kachel .name {
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; overflow-wrap: anywhere; max-width: 100%;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.kachel.ohne-emoji .name { font-size: 16px; -webkit-line-clamp: 3; }
|
||||
.kachel::after {
|
||||
content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
|
||||
background: color-mix(in oklab, var(--c) 70%, #fff); opacity: 0;
|
||||
}
|
||||
.kachel.knall::after { animation: knall .6s ease-out; }
|
||||
@keyframes knall { 0% { opacity: .55; } 100% { opacity: 0; } }
|
||||
.kachel.knall { animation: wippen .35s ease-out; }
|
||||
@keyframes wippen { 40% { transform: scale(1.04); } }
|
||||
|
||||
.raster.bearbeiten .kachel { animation: zittern .5s ease-in-out infinite alternate; }
|
||||
.raster.bearbeiten .kachel:nth-child(2n) { animation-delay: -.25s; }
|
||||
@keyframes zittern { from { transform: rotate(-.8deg); } to { transform: rotate(.8deg); } }
|
||||
.raster.bearbeiten .kachel::before {
|
||||
content: "✎"; position: absolute; top: 6px; right: 8px; font-size: 14px; opacity: .85;
|
||||
}
|
||||
|
||||
.kachel[data-farbe="lila"], .farbe[data-farbe="lila"] { --c: var(--lila); }
|
||||
.kachel[data-farbe="tuerkis"], .farbe[data-farbe="tuerkis"] { --c: var(--tuerkis); }
|
||||
.kachel[data-farbe="pink"], .farbe[data-farbe="pink"] { --c: var(--pink); }
|
||||
.kachel[data-farbe="gold"], .farbe[data-farbe="gold"] { --c: var(--gold); }
|
||||
.kachel[data-farbe="gruen"], .farbe[data-farbe="gruen"] { --c: var(--gruen); }
|
||||
.kachel[data-farbe="rot"], .farbe[data-farbe="rot"] { --c: var(--rot); }
|
||||
.kachel[data-farbe="blau"], .farbe[data-farbe="blau"] { --c: var(--blau); }
|
||||
.kachel[data-farbe="grau"], .farbe[data-farbe="grau"] { --c: var(--grau); }
|
||||
|
||||
.kachel.laedt { opacity: .55; pointer-events: none; }
|
||||
.kachel.laedt .name::after { content: " …"; }
|
||||
|
||||
.leer { text-align: center; padding: 32px 12px; color: var(--leise); }
|
||||
.leer h2 { color: var(--schrift); font-size: 20px; margin: 18px 0 6px; }
|
||||
.leer p { max-width: 34ch; margin: 0 auto 20px; }
|
||||
.leer-bild { display: inline-grid; grid-template-columns: repeat(3, 34px); gap: 6px; }
|
||||
.leer-bild span { aspect-ratio: 1; border-radius: 9px; border: 1.5px dashed var(--linie-stark); }
|
||||
.leer-bild span:nth-child(2) { border-color: var(--lila); background: rgba(169, 112, 255, .12); }
|
||||
.leer-bild span:nth-child(6) { border-color: var(--tuerkis); background: rgba(79, 209, 197, .1); }
|
||||
|
||||
/* ------------------------------------------------------------ Szenen */
|
||||
|
||||
.szenen { display: grid; gap: 8px; }
|
||||
@media (min-width: 640px) { .szenen { grid-template-columns: repeat(2, 1fr); } }
|
||||
.szene {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
min-height: 64px; padding: 0 18px; width: 100%;
|
||||
font: 600 17px/1.2 var(--font); color: var(--schrift); text-align: left;
|
||||
background: var(--flaeche); border: 1px solid var(--linie-stark); border-radius: var(--rund);
|
||||
cursor: pointer; touch-action: manipulation; transition: transform .08s, background .15s;
|
||||
}
|
||||
.szene:active { transform: scale(.98); }
|
||||
.szene .szene-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.szene .marke-an {
|
||||
font: 700 11px/1 var(--font); letter-spacing: .1em; text-transform: uppercase;
|
||||
padding: 5px 8px; border-radius: 6px; background: rgba(255, 255, 255, .16); display: none;
|
||||
}
|
||||
.szene.aktiv { background: var(--lila-tief); border-color: var(--lila); }
|
||||
.szene.aktiv .marke-an { display: inline-block; }
|
||||
.szene.wartet { opacity: .6; }
|
||||
|
||||
.knopf.clip {
|
||||
width: 100%; min-height: 64px; font-size: 17px; border-radius: var(--rund);
|
||||
background: rgba(255, 79, 163, .14); border-color: rgba(255, 79, 163, .55); color: #ffd1e8;
|
||||
}
|
||||
.knopf.clip svg { width: 24px; height: 24px; }
|
||||
|
||||
.ton { display: grid; gap: 8px; }
|
||||
.ton-zeile {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
min-height: 56px; padding: 0 14px 0 16px;
|
||||
font: 500 15px/1.2 var(--font); color: var(--schrift); text-align: left;
|
||||
background: var(--flaeche); border: 1px solid var(--linie); border-radius: 14px; cursor: pointer;
|
||||
}
|
||||
.ton-zeile .ton-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ton-zeile .ton-stand {
|
||||
font: 600 12px/1 var(--font); letter-spacing: .06em; text-transform: uppercase;
|
||||
padding: 7px 10px; border-radius: 8px; background: rgba(74, 222, 128, .14); color: var(--gruen);
|
||||
}
|
||||
.ton-zeile.stumm { border-color: rgba(239, 68, 68, .45); }
|
||||
.ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; }
|
||||
.ton-zeile.stumm svg { color: #ffb3b3; }
|
||||
|
||||
.leer-karte { text-align: center; padding: 28px 18px; }
|
||||
.leer-karte h2 { font-size: 18px; margin: 0 0 8px; }
|
||||
.leer-karte p { color: var(--leise); margin: 0; font-size: 14px; }
|
||||
|
||||
/* ------------------------------------------------------------ Stream */
|
||||
|
||||
.lz-zahlen { display: flex; gap: 28px; margin: 4px 0 16px; }
|
||||
.lz-zahlen div { display: flex; flex-direction: column; }
|
||||
.lz-zahlen b { font: 700 52px/1 var(--font-zahl); font-variant-numeric: tabular-nums; }
|
||||
.lz-zahlen span { color: var(--leise); font-size: 13px; margin-top: 4px; }
|
||||
.lz-zahlen .gemacht b { color: var(--gruen); }
|
||||
.lz-knoepfe { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.lz-knoepfe .knopf { min-height: 56px; font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.zaehler {
|
||||
display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px;
|
||||
border-radius: 999px; background: var(--pink); color: #fff; font-size: 12px; letter-spacing: 0;
|
||||
}
|
||||
.wartend { display: grid; gap: 10px; margin-bottom: 12px; }
|
||||
.wartend .leer-zeile { color: var(--leise); font-size: 14px; }
|
||||
.nachricht { background: var(--flaeche-2); border-radius: 12px; padding: 12px; }
|
||||
.nachricht .wer { font-weight: 600; color: var(--tuerkis); font-size: 14px; }
|
||||
.nachricht .was { margin: 4px 0 10px; overflow-wrap: anywhere; }
|
||||
.nachricht .reihe { display: flex; gap: 8px; }
|
||||
.nachricht .reihe .knopf { flex: 1; }
|
||||
|
||||
.knopf.panik {
|
||||
width: 100%; min-height: 64px; margin-top: 8px; font-size: 18px; border-radius: var(--rund);
|
||||
background: var(--rot); border-color: var(--rot); color: #fff;
|
||||
}
|
||||
.knopf.panik svg { width: 24px; height: 24px; }
|
||||
|
||||
/* ------------------------------------------------------------ Test */
|
||||
|
||||
.gruppe { margin-bottom: 4px; }
|
||||
.gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
||||
.gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; }
|
||||
|
||||
/* ------------------------------------------------------------ Reiter */
|
||||
|
||||
.reiter {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
height: calc(var(--reiter-h) + var(--unten));
|
||||
padding-bottom: var(--unten);
|
||||
background: rgba(18, 14, 29, .94);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-top: 1px solid var(--linie);
|
||||
}
|
||||
.reiter button {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
||||
background: none; border: 0; color: var(--leise); cursor: pointer;
|
||||
font: 500 12px/1 var(--font); touch-action: manipulation;
|
||||
}
|
||||
.reiter button svg { width: 24px; height: 24px; }
|
||||
.reiter button.an { color: var(--schrift); }
|
||||
.reiter button.an svg { color: var(--lila); }
|
||||
.reiter .punkt { position: absolute; top: 10px; left: calc(50% + 10px); width: 9px; height: 9px;
|
||||
border-radius: 50%; background: var(--pink); border: 2px solid var(--grund); }
|
||||
|
||||
/* ------------------------------------------------------------ Meldung */
|
||||
|
||||
.meldung {
|
||||
position: fixed; left: 50%; z-index: 40;
|
||||
bottom: calc(var(--reiter-h) + var(--unten) + 14px);
|
||||
transform: translate(-50%, 12px);
|
||||
max-width: min(92vw, 460px); padding: 12px 16px; border-radius: 12px;
|
||||
background: #241f35; border: 1px solid var(--linie-stark); color: var(--schrift);
|
||||
font-size: 15px; box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
|
||||
opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.meldung.an { opacity: 1; transform: translate(-50%, 0); }
|
||||
.meldung.ok { border-color: rgba(74, 222, 128, .5); }
|
||||
.meldung.fehler { border-color: rgba(255, 197, 61, .6); color: #ffe2a0; }
|
||||
|
||||
/* ------------------------------------------------------------ Blatt */
|
||||
|
||||
.blatt {
|
||||
width: 100%; max-width: 560px; margin: auto auto 0; padding: 0;
|
||||
border: 1px solid var(--linie-stark); border-bottom: 0; border-radius: 22px 22px 0 0;
|
||||
background: var(--flaeche); color: var(--schrift);
|
||||
}
|
||||
@media (min-width: 640px) { .blatt { margin: auto; border-radius: 22px; border-bottom: 1px solid var(--linie-stark); } }
|
||||
.blatt::backdrop { background: rgba(5, 4, 9, .65); backdrop-filter: blur(2px); }
|
||||
.blatt[open] { animation: hoch .22s ease-out; }
|
||||
@keyframes hoch { from { transform: translateY(40px); opacity: 0; } }
|
||||
.blatt form { padding: 10px 18px calc(18px + var(--unten)); display: grid; gap: 16px; }
|
||||
.griff { width: 40px; height: 4px; border-radius: 4px; background: var(--linie-stark); margin: 0 auto; }
|
||||
.blatt-kopf { display: flex; gap: 10px; }
|
||||
.blatt input:not([type=range]) {
|
||||
font: inherit; color: var(--schrift); background: var(--flaeche-2);
|
||||
border: 1px solid var(--linie-stark); border-radius: 12px; min-height: 52px; padding: 0 14px;
|
||||
}
|
||||
.blatt input:focus { outline: 2px solid var(--lila); outline-offset: 0; }
|
||||
.emoji-feld { width: 64px; text-align: center; font-size: 26px !important; padding: 0 !important; }
|
||||
.name-feld { flex: 1; min-width: 0; font-size: 17px !important; }
|
||||
.schnell { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
|
||||
.schnell button {
|
||||
flex: none; width: 42px; height: 42px; border-radius: 10px; font-size: 22px;
|
||||
background: var(--flaeche-2); border: 1px solid var(--linie); cursor: pointer;
|
||||
}
|
||||
.feld { display: grid; gap: 10px; }
|
||||
.feld-name { font-size: 13px; color: var(--leise); display: flex; justify-content: space-between; }
|
||||
.feld-name b { color: var(--schrift); font-variant-numeric: tabular-nums; }
|
||||
.farben { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.farbe {
|
||||
--c: var(--lila);
|
||||
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
|
||||
background: var(--c); border: 3px solid var(--flaeche); box-shadow: 0 0 0 1px var(--linie-stark);
|
||||
}
|
||||
.farbe[aria-checked="true"] { box-shadow: 0 0 0 2px var(--schrift); }
|
||||
input[type=range] { width: 100%; accent-color: var(--lila); height: 32px; }
|
||||
.blatt-reihe { display: flex; gap: 8px; align-items: center; }
|
||||
.blatt-reihe #b-hoeren { flex: 1; }
|
||||
|
||||
/* ------------------------------------------------------------ Abgemeldet */
|
||||
|
||||
.abgemeldet {
|
||||
position: fixed; inset: 0; z-index: 50; display: grid; place-items: center;
|
||||
background: rgba(13, 11, 20, .96); text-align: center; padding: 24px;
|
||||
}
|
||||
.abgemeldet p { color: var(--leise); max-width: 32ch; margin: 8px auto 22px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0d0b14">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Pult">
|
||||
<title>Pult · abdulive</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/icons/pult-192.png" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/pult.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="kopf">
|
||||
<div class="kanal">
|
||||
<span class="marke">abdu<b>live</b></span>
|
||||
<span id="live" class="live aus"><i></i><span id="live-text">offline</span></span>
|
||||
</div>
|
||||
<div class="kopf-rechts">
|
||||
<button id="wach" class="rund" type="button" aria-pressed="false" title="Bildschirm anlassen">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v2M12 19v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M3 12h2M19 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
</button>
|
||||
<span id="netz" class="netz" title="Verbindung zum Hub"><i></i></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
|
||||
<main>
|
||||
<!-- ======================================================== Sounds -->
|
||||
<section id="tab-sounds" class="tab an" aria-labelledby="nav-sounds">
|
||||
<div class="leiste">
|
||||
<button id="stopp" class="knopf warnung" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
Stopp
|
||||
</button>
|
||||
<span class="luecke"></span>
|
||||
<button id="bearbeiten" class="knopf" type="button" aria-pressed="false">Bearbeiten</button>
|
||||
<label class="knopf primaer" for="datei">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Sound
|
||||
</label>
|
||||
<input id="datei" type="file" accept="audio/*,.mp3,.wav,.ogg,.m4a,.webm" multiple hidden>
|
||||
</div>
|
||||
<div id="quelle-fehlt" class="hinweis warn" hidden>
|
||||
In OBS ist keine Sound-Quelle verbunden – die Zuschauer hören nichts. Browser-Source
|
||||
<code>/sounds</code> anlegen, die Adresse steht auf der <a href="/">Übersicht</a>.
|
||||
</div>
|
||||
<div id="raster" class="raster" aria-live="polite"></div>
|
||||
<div id="sounds-leer" class="leer" hidden>
|
||||
<div class="leer-bild" aria-hidden="true">
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<h2>Noch keine Sounds</h2>
|
||||
<p>MP3, WAV, OGG oder M4A vom Handy hochladen. Jeder Sound wird eine Taste;
|
||||
ein Druck spielt ihn im Stream.</p>
|
||||
<label class="knopf primaer gross" for="datei">Ersten Sound hinzufügen</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================================================== Szenen -->
|
||||
<section id="tab-szenen" class="tab" aria-labelledby="nav-szenen">
|
||||
<div id="obs-fehlt" class="karte leer-karte" hidden>
|
||||
<h2 id="obs-fehlt-titel">Stream-PC nicht verbunden</h2>
|
||||
<p id="obs-fehlt-text"></p>
|
||||
</div>
|
||||
<div id="obs-da" hidden>
|
||||
<h3 class="abschnitt">Szenen</h3>
|
||||
<div id="szenen" class="szenen"></div>
|
||||
|
||||
<h3 class="abschnitt">Clip</h3>
|
||||
<button id="clip" class="knopf clip" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h3l2-3h6l2 3h3v12H4z"/><circle cx="12" cy="13" r="3.5"/></svg>
|
||||
<span>Clip speichern</span>
|
||||
</button>
|
||||
<p id="clip-hinweis" class="klein"></p>
|
||||
|
||||
<h3 class="abschnitt">Ton</h3>
|
||||
<div id="ton" class="ton"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================================================== Stream -->
|
||||
<section id="tab-stream" class="tab" aria-labelledby="nav-stream">
|
||||
<div id="lz-karte" class="karte" hidden>
|
||||
<h3 class="abschnitt">Liegestütze</h3>
|
||||
<div class="lz-zahlen">
|
||||
<div><b id="lz-offen">0</b><span>offen</span></div>
|
||||
<div class="gemacht"><b id="lz-gemacht">0</b><span>gemacht</span></div>
|
||||
</div>
|
||||
<div class="lz-knoepfe">
|
||||
<button class="knopf" data-um="-10" type="button">−10</button>
|
||||
<button class="knopf" data-um="-5" type="button">−5</button>
|
||||
<button class="knopf" data-um="-1" type="button">−1</button>
|
||||
<button class="knopf" data-um="1" type="button">+1</button>
|
||||
<button class="knopf" data-um="5" type="button">+5</button>
|
||||
<button class="knopf" data-um="10" type="button">+10</button>
|
||||
</div>
|
||||
<p class="klein">Minus zählt als gemacht. Korrekturen ohne Zählung auf der <a href="/">Übersicht</a>.</p>
|
||||
</div>
|
||||
|
||||
<div id="tts-karte" class="karte" hidden>
|
||||
<h3 class="abschnitt">Vorlesen <span id="tts-anzahl" class="zaehler" hidden></span></h3>
|
||||
<div id="wartend" class="wartend"></div>
|
||||
<button id="alle-ablehnen" class="knopf" type="button">Alle ablehnen</button>
|
||||
</div>
|
||||
|
||||
<button id="still" class="knopf panik" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM16 9l5 6M21 9l-5 6"/></svg>
|
||||
Sofort still
|
||||
</button>
|
||||
<p class="klein mitte">Bricht Vorlesen und alle Sounds ab und leert die Warteschlange.</p>
|
||||
</section>
|
||||
|
||||
<!-- ======================================================== Test -->
|
||||
<section id="tab-test" class="tab" aria-labelledby="nav-test">
|
||||
<div id="test-live" class="hinweis warn" hidden>Der Stream läuft. Was du hier auslöst, sehen die Zuschauer.</div>
|
||||
<p class="klein">Erfundene Ereignisse, nur für die Browser-Sources. Nichts davon landet
|
||||
in der Datenbank, auf Discord oder bei Twitch.</p>
|
||||
<div id="test-gruppen"></div>
|
||||
<h3 class="abschnitt">Dauerbetrieb</h3>
|
||||
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
|
||||
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="meldung" class="meldung" role="status" aria-live="polite"></div>
|
||||
|
||||
<nav class="reiter" aria-label="Bereiche">
|
||||
<button id="nav-sounds" data-tab="sounds" class="an" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="2"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2"/></svg>
|
||||
Sounds
|
||||
</button>
|
||||
<button id="nav-szenen" data-tab="szenen" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="13" rx="2"/><path d="M8 21h8"/></svg>
|
||||
Szenen
|
||||
</button>
|
||||
<button id="nav-stream" data-tab="stream" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
|
||||
Stream
|
||||
<i id="nav-tts" class="punkt" hidden></i>
|
||||
</button>
|
||||
<button id="nav-test" data-tab="test" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg>
|
||||
Test
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<dialog id="blatt" class="blatt">
|
||||
<form method="dialog" id="blatt-form">
|
||||
<div class="griff" aria-hidden="true"></div>
|
||||
<div class="blatt-kopf">
|
||||
<input id="b-emoji" class="emoji-feld" maxlength="10" placeholder="🔊" aria-label="Emoji" autocomplete="off">
|
||||
<input id="b-name" class="name-feld" maxlength="40" placeholder="Name" aria-label="Name" autocomplete="off">
|
||||
</div>
|
||||
<div id="b-schnell" class="schnell" aria-label="Emoji-Vorschläge"></div>
|
||||
<div class="feld">
|
||||
<span class="feld-name">Farbe</span>
|
||||
<div id="b-farben" class="farben" role="radiogroup" aria-label="Farbe"></div>
|
||||
</div>
|
||||
<label class="feld">
|
||||
<span class="feld-name">Lautstärke <b id="b-laut-zahl">80 %</b></span>
|
||||
<input id="b-laut" type="range" min="0" max="100" step="5" value="80">
|
||||
</label>
|
||||
<div class="blatt-reihe">
|
||||
<button type="button" id="b-hoeren" class="knopf">Am Handy anhören</button>
|
||||
<button type="button" id="b-vor" class="knopf" aria-label="Nach vorn">◀</button>
|
||||
<button type="button" id="b-zurueck" class="knopf" aria-label="Nach hinten">▶</button>
|
||||
</div>
|
||||
<div class="blatt-reihe">
|
||||
<button type="button" id="b-loeschen" class="knopf warnung">Löschen</button>
|
||||
<span class="luecke"></span>
|
||||
<button type="button" id="b-abbrechen" class="knopf">Abbrechen</button>
|
||||
<button type="submit" id="b-speichern" class="knopf primaer">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="abgemeldet" class="abgemeldet" hidden>
|
||||
<div>
|
||||
<h2>Abgemeldet</h2>
|
||||
<p>Die Anmeldung ist abgelaufen oder das Passwort wurde geändert.</p>
|
||||
<a class="knopf primaer gross" href="/pult">Neu anmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pult.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,638 @@
|
||||
/* Das Pult: Soundboard, Szenen, Liegestuetze, Vorlesen - am Handy.
|
||||
*
|
||||
* Zwei Wege zum Hub:
|
||||
* - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei
|
||||
* offene Pulte und die Uebersichtsseite nicht auseinanderlaufen;
|
||||
* - einfache POSTs fuer jeden Druck. Jede Antwort sagt, ob es geklappt
|
||||
* hat, und genau das steht danach an der Taste.
|
||||
*
|
||||
* Fremder Text (Soundnamen, Szenen, Chat) kommt nur ueber textContent ins
|
||||
* DOM, nie ueber innerHTML. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'];
|
||||
var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
|
||||
|
||||
var zustand = {
|
||||
sounds: [],
|
||||
obs: { pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false },
|
||||
live: false,
|
||||
bearbeiten: false,
|
||||
ttsAn: false,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------ Hilfen
|
||||
|
||||
function el(tag, klasse, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (klasse) e.className = klasse;
|
||||
if (text !== undefined) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function tippen() {
|
||||
if (navigator.vibrate) { try { navigator.vibrate(12); } catch (e) { /* egal */ } }
|
||||
}
|
||||
|
||||
var meldungTimer;
|
||||
function melden(text, art) {
|
||||
var m = $('meldung');
|
||||
if (!text) return;
|
||||
m.textContent = text;
|
||||
m.className = 'meldung an' + (art ? ' ' + art : '');
|
||||
clearTimeout(meldungTimer);
|
||||
meldungTimer = setTimeout(function () { m.className = 'meldung'; }, art === 'fehler' ? 4500 : 2200);
|
||||
}
|
||||
|
||||
/** POST mit Formular, Antwort als JSON. Wirft nie; Fehler stehen in `ok`. */
|
||||
function rufen(pfad, felder, roh) {
|
||||
var opts = { method: 'POST', credentials: 'same-origin' };
|
||||
if (roh) {
|
||||
opts.body = roh;
|
||||
} else {
|
||||
opts.headers = { 'content-type': 'application/x-www-form-urlencoded' };
|
||||
opts.body = new URLSearchParams(felder || {});
|
||||
}
|
||||
return fetch(pfad, opts).then(function (res) {
|
||||
return res.json().catch(function () { return { text: 'Fehler ' + res.status }; })
|
||||
.then(function (body) {
|
||||
if (res.status === 401) abgemeldet();
|
||||
body.ok = res.ok && body.ok !== false;
|
||||
return body;
|
||||
});
|
||||
}, function () {
|
||||
return { ok: false, text: 'Keine Verbindung zum Hub.' };
|
||||
});
|
||||
}
|
||||
|
||||
function holen(pfad) {
|
||||
return fetch(pfad, { credentials: 'same-origin' }).then(function (res) {
|
||||
if (res.status === 401) { abgemeldet(); throw new Error('abgemeldet'); }
|
||||
if (!res.ok) throw new Error('Fehler ' + res.status);
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
function abgemeldet() { $('abgemeldet').hidden = false; }
|
||||
|
||||
/** Taste waehrend eines Rufs sperren, damit ein Doppeltipp nicht doppelt schaltet. */
|
||||
function mitWarten(knopf, versprechen) {
|
||||
knopf.classList.add('wartet');
|
||||
return versprechen.then(function (r) { knopf.classList.remove('wartet'); return r; });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Reiter
|
||||
|
||||
function zeigeReiter(name) {
|
||||
var reiter = document.querySelectorAll('.reiter button');
|
||||
for (var i = 0; i < reiter.length; i++) {
|
||||
reiter[i].classList.toggle('an', reiter[i].dataset.tab === name);
|
||||
}
|
||||
var tabs = document.querySelectorAll('.tab');
|
||||
for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name);
|
||||
try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ }
|
||||
if (name === 'stream') ladeWartend();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
document.querySelector('.reiter').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-tab]');
|
||||
if (b) { tippen(); zeigeReiter(b.dataset.tab); }
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Kopf
|
||||
|
||||
var laufzeitTimer;
|
||||
function zeigeStream(s) {
|
||||
zustand.live = !!s.live;
|
||||
var live = $('live');
|
||||
live.classList.toggle('aus', !s.live);
|
||||
clearInterval(laufzeitTimer);
|
||||
var schreiben = function () {
|
||||
if (!s.live) { $('live-text').textContent = 'offline'; return; }
|
||||
var teile = ['LIVE'];
|
||||
if (s.startedAt) {
|
||||
var min = Math.max(0, Math.floor((Date.now() - Date.parse(s.startedAt)) / 60000));
|
||||
teile.push(Math.floor(min / 60) + ':' + String(min % 60).padStart(2, '0'));
|
||||
}
|
||||
teile.push((s.viewers || 0).toLocaleString('de-DE') + ' 👁');
|
||||
$('live-text').textContent = teile.join(' · ');
|
||||
};
|
||||
schreiben();
|
||||
if (s.live) laufzeitTimer = setInterval(schreiben, 30000);
|
||||
$('test-live').hidden = !s.live;
|
||||
var gesperrt = document.querySelectorAll('#test-gruppen [data-offline]');
|
||||
for (var i = 0; i < gesperrt.length; i++) gesperrt[i].disabled = !!s.live;
|
||||
var demo = $('demo');
|
||||
if (demo.dataset.art === 'demo-an') demo.disabled = !!s.live;
|
||||
}
|
||||
|
||||
// Bildschirm anlassen. Ein Pult, das nach 30 s schwarz wird, ist mitten
|
||||
// im Stream keins. Der Wunsch wird gemerkt; das Handy gibt die Sperre bei
|
||||
// jedem Wegwischen frei, also wird sie beim Zurueckkommen neu geholt.
|
||||
var sperre = null;
|
||||
var wachGewuenscht = false;
|
||||
try { wachGewuenscht = localStorage.getItem('pult-wach') === '1'; } catch (e) { /* privat */ }
|
||||
function wachHolen() {
|
||||
if (!wachGewuenscht || !('wakeLock' in navigator) || document.visibilityState !== 'visible') {
|
||||
$('wach').setAttribute('aria-pressed', String(!!sperre));
|
||||
return;
|
||||
}
|
||||
navigator.wakeLock.request('screen').then(function (s) {
|
||||
sperre = s;
|
||||
$('wach').setAttribute('aria-pressed', 'true');
|
||||
s.addEventListener('release', function () {
|
||||
sperre = null;
|
||||
$('wach').setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
}, function () { $('wach').setAttribute('aria-pressed', 'false'); });
|
||||
}
|
||||
$('wach').addEventListener('click', function () {
|
||||
tippen();
|
||||
if (!('wakeLock' in navigator)) { melden('Dieser Browser kann den Bildschirm nicht wach halten.', 'fehler'); return; }
|
||||
wachGewuenscht = !sperre;
|
||||
try { localStorage.setItem('pult-wach', wachGewuenscht ? '1' : '0'); } catch (e) { /* privat */ }
|
||||
if (sperre) { sperre.release(); sperre = null; melden('Bildschirm darf wieder ausgehen.'); }
|
||||
else { wachHolen(); melden('Bildschirm bleibt an.', 'ok'); }
|
||||
});
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') { wachHolen(); ladeSounds(); }
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Sounds
|
||||
|
||||
function ladeSounds() {
|
||||
return holen('/soundboard/liste').then(zeigeSounds, function () { /* Meldung kommt woanders */ });
|
||||
}
|
||||
|
||||
function zeigeSounds(body) {
|
||||
if (body.sounds) zustand.sounds = body.sounds;
|
||||
if (typeof body.quellen === 'number') $('quelle-fehlt').hidden = body.quellen > 0;
|
||||
var raster = $('raster');
|
||||
raster.textContent = '';
|
||||
raster.classList.toggle('bearbeiten', zustand.bearbeiten);
|
||||
$('sounds-leer').hidden = zustand.sounds.length > 0;
|
||||
$('bearbeiten').hidden = zustand.sounds.length === 0;
|
||||
if (!zustand.sounds.length && zustand.bearbeiten) setzeBearbeiten(false);
|
||||
|
||||
zustand.sounds.forEach(function (s) {
|
||||
var k = el('button', 'kachel' + (s.emoji ? '' : ' ohne-emoji'));
|
||||
k.type = 'button';
|
||||
k.dataset.id = s.id;
|
||||
k.dataset.farbe = FARBEN.indexOf(s.farbe) !== -1 ? s.farbe : 'lila';
|
||||
if (s.emoji) k.appendChild(el('span', 'emoji', s.emoji));
|
||||
k.appendChild(el('span', 'name', s.name));
|
||||
k.setAttribute('aria-label', (zustand.bearbeiten ? 'Bearbeiten: ' : 'Abspielen: ') + s.name);
|
||||
raster.appendChild(k);
|
||||
});
|
||||
}
|
||||
|
||||
function knall(id) {
|
||||
var k = document.querySelector('.kachel[data-id="' + CSS.escape(id) + '"]');
|
||||
if (!k) return;
|
||||
k.classList.remove('knall');
|
||||
void k.offsetWidth; // Animation neu starten, auch beim Doppeltipp
|
||||
k.classList.add('knall');
|
||||
}
|
||||
|
||||
$('raster').addEventListener('click', function (e) {
|
||||
var k = e.target.closest('.kachel');
|
||||
if (!k) return;
|
||||
tippen();
|
||||
var id = k.dataset.id;
|
||||
if (zustand.bearbeiten) { oeffneBlatt(id); return; }
|
||||
knall(id);
|
||||
rufen('/soundboard/spielen', { id: id }).then(function (r) {
|
||||
if (!r.ok) { melden(r.text, 'fehler'); return; }
|
||||
$('quelle-fehlt').hidden = r.quellen > 0;
|
||||
if (!r.quellen) melden(r.text, 'fehler');
|
||||
});
|
||||
});
|
||||
|
||||
$('stopp').addEventListener('click', function () {
|
||||
tippen();
|
||||
rufen('/soundboard/stopp').then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); });
|
||||
});
|
||||
|
||||
function setzeBearbeiten(an) {
|
||||
zustand.bearbeiten = an;
|
||||
$('bearbeiten').setAttribute('aria-pressed', String(an));
|
||||
$('bearbeiten').textContent = an ? 'Fertig' : 'Bearbeiten';
|
||||
$('raster').classList.toggle('bearbeiten', an);
|
||||
var kacheln = document.querySelectorAll('.kachel');
|
||||
for (var i = 0; i < kacheln.length; i++) {
|
||||
var s = zustand.sounds[i];
|
||||
if (s) kacheln[i].setAttribute('aria-label', (an ? 'Bearbeiten: ' : 'Abspielen: ') + s.name);
|
||||
}
|
||||
}
|
||||
$('bearbeiten').addEventListener('click', function () { tippen(); setzeBearbeiten(!zustand.bearbeiten); });
|
||||
|
||||
// Hochladen: roh im Koerper, eine Datei nach der anderen. Parallel waere
|
||||
// schneller, aber dann kommt die Reihenfolge durcheinander.
|
||||
$('datei').addEventListener('change', function (e) {
|
||||
var dateien = Array.prototype.slice.call(e.target.files || []);
|
||||
e.target.value = '';
|
||||
var weiter = Promise.resolve();
|
||||
dateien.forEach(function (d, i) {
|
||||
weiter = weiter.then(function () {
|
||||
melden((dateien.length > 1 ? (i + 1) + '/' + dateien.length + ' · ' : '') + d.name + ' wird hochgeladen …');
|
||||
return rufen('/soundboard/neu?name=' + encodeURIComponent(d.name), null, d).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
if (r.sounds) zeigeSounds(r);
|
||||
// Der erste Sound geht gleich ins Bearbeiten: ein Emoji macht aus
|
||||
// einer Liste von Dateinamen erst ein Pult.
|
||||
if (r.ok && r.sound && dateien.length === 1) oeffneBlatt(r.sound.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Blatt
|
||||
|
||||
var blatt = $('blatt');
|
||||
var offenId = null;
|
||||
var probe = null;
|
||||
|
||||
(function baueBlatt() {
|
||||
var schnell = $('b-schnell');
|
||||
SCHNELL.forEach(function (em) {
|
||||
var b = el('button', '', em);
|
||||
b.type = 'button';
|
||||
b.addEventListener('click', function () { $('b-emoji').value = em; tippen(); });
|
||||
schnell.appendChild(b);
|
||||
});
|
||||
var farben = $('b-farben');
|
||||
FARBEN.forEach(function (f) {
|
||||
var b = el('button', 'farbe');
|
||||
b.type = 'button';
|
||||
b.dataset.farbe = f;
|
||||
b.setAttribute('role', 'radio');
|
||||
b.setAttribute('aria-label', f);
|
||||
b.addEventListener('click', function () { waehleFarbe(f); tippen(); });
|
||||
farben.appendChild(b);
|
||||
});
|
||||
})();
|
||||
|
||||
function waehleFarbe(f) {
|
||||
var alle = $('b-farben').children;
|
||||
for (var i = 0; i < alle.length; i++) alle[i].setAttribute('aria-checked', String(alle[i].dataset.farbe === f));
|
||||
blatt.dataset.farbe = f;
|
||||
}
|
||||
|
||||
function oeffneBlatt(id) {
|
||||
var s = zustand.sounds.find(function (x) { return x.id === id; });
|
||||
if (!s) return;
|
||||
offenId = id;
|
||||
$('b-name').value = s.name;
|
||||
$('b-emoji').value = s.emoji || '';
|
||||
$('b-laut').value = Math.round((s.laut ?? 0.8) * 100);
|
||||
$('b-laut-zahl').textContent = $('b-laut').value + ' %';
|
||||
waehleFarbe(s.farbe);
|
||||
$('b-loeschen').textContent = 'Löschen';
|
||||
$('b-loeschen').dataset.sicher = '';
|
||||
if (typeof blatt.showModal === 'function') blatt.showModal(); else blatt.setAttribute('open', '');
|
||||
}
|
||||
|
||||
function schliesseBlatt() {
|
||||
if (probe) { probe.pause(); probe = null; }
|
||||
offenId = null;
|
||||
if (blatt.open) blatt.close();
|
||||
}
|
||||
|
||||
$('b-laut').addEventListener('input', function () {
|
||||
$('b-laut-zahl').textContent = $('b-laut').value + ' %';
|
||||
if (probe) probe.volume = Number($('b-laut').value) / 100;
|
||||
});
|
||||
|
||||
$('b-hoeren').addEventListener('click', function () {
|
||||
if (!offenId) return;
|
||||
if (probe) { probe.pause(); probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; return; }
|
||||
probe = new Audio('/soundboard/datei/' + encodeURIComponent(offenId));
|
||||
probe.volume = Number($('b-laut').value) / 100;
|
||||
$('b-hoeren').textContent = 'Anhören stoppen';
|
||||
probe.onended = function () { probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; };
|
||||
probe.play().catch(function () { melden('Abspielen am Handy ging nicht.', 'fehler'); });
|
||||
});
|
||||
|
||||
function verschieben(um) {
|
||||
if (!offenId) return;
|
||||
tippen();
|
||||
rufen('/soundboard/verschieben', { id: offenId, um: String(um) }).then(function (r) {
|
||||
if (r.sounds) zeigeSounds(r);
|
||||
});
|
||||
}
|
||||
$('b-vor').addEventListener('click', function () { verschieben(-1); });
|
||||
$('b-zurueck').addEventListener('click', function () { verschieben(1); });
|
||||
|
||||
$('b-loeschen').addEventListener('click', function () {
|
||||
var b = $('b-loeschen');
|
||||
// Zweimal tippen statt confirm(): ein Dialog des Browsers sieht am Handy
|
||||
// aus wie eine Fehlermeldung, und er haelt die Seite an.
|
||||
if (!b.dataset.sicher) { b.dataset.sicher = '1'; b.textContent = 'Wirklich löschen?'; tippen(); return; }
|
||||
var id = offenId;
|
||||
schliesseBlatt();
|
||||
rufen('/soundboard/loeschen', { id: id }).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
if (r.sounds) zeigeSounds(r);
|
||||
});
|
||||
});
|
||||
|
||||
$('b-abbrechen').addEventListener('click', schliesseBlatt);
|
||||
blatt.addEventListener('close', function () { if (probe) { probe.pause(); probe = null; } });
|
||||
blatt.addEventListener('click', function (e) { if (e.target === blatt) schliesseBlatt(); });
|
||||
|
||||
$('blatt-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var id = offenId;
|
||||
if (!id) return;
|
||||
var felder = {
|
||||
id: id,
|
||||
name: $('b-name').value,
|
||||
emoji: $('b-emoji').value,
|
||||
farbe: blatt.dataset.farbe || 'lila',
|
||||
laut: String(Number($('b-laut').value) / 100),
|
||||
};
|
||||
schliesseBlatt();
|
||||
rufen('/soundboard/aendern', felder).then(function (r) {
|
||||
if (!r.ok) melden(r.text, 'fehler');
|
||||
if (r.sounds) zeigeSounds(r);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ OBS
|
||||
|
||||
function zeigeObs(o) {
|
||||
zustand.obs = o;
|
||||
var fehlt = !o.pc || !o.obs;
|
||||
$('obs-fehlt').hidden = !fehlt;
|
||||
$('obs-da').hidden = fehlt;
|
||||
if (!o.pc) {
|
||||
$('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden';
|
||||
$('obs-fehlt-text').textContent = 'Auf dem Stream-PC muss abdulive ingest laufen und einmal mit ' +
|
||||
'„abdulive fern:verbinden …“ eingerichtet sein. Der Befehl steht auf der Übersicht unter „Stream-PC verbinden“.';
|
||||
} else if (!o.obs) {
|
||||
$('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar';
|
||||
$('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' +
|
||||
'obs-websocket an (Werkzeuge → WebSocket-Servereinstellungen)?';
|
||||
}
|
||||
if (fehlt) return;
|
||||
|
||||
var szenen = $('szenen');
|
||||
szenen.textContent = '';
|
||||
o.szenen.forEach(function (name) {
|
||||
var b = el('button', 'szene' + (name === o.aktuell ? ' aktiv' : ''));
|
||||
b.type = 'button';
|
||||
b.dataset.szene = name;
|
||||
b.appendChild(el('span', 'szene-name', name));
|
||||
b.appendChild(el('span', 'marke-an', 'Im Bild'));
|
||||
szenen.appendChild(b);
|
||||
});
|
||||
if (!o.szenen.length) szenen.appendChild(el('p', 'klein', 'OBS meldet keine Szenen.'));
|
||||
|
||||
$('clip').disabled = !o.puffer;
|
||||
$('clip-hinweis').textContent = o.puffer
|
||||
? 'Sichert die letzten Sekunden aus dem Wiedergabepuffer, wie !clip im Chat – ohne Abklingzeit.'
|
||||
: 'Der Wiedergabepuffer läuft nicht. In OBS unten rechts „Wiedergabepuffer starten“.';
|
||||
|
||||
var ton = $('ton');
|
||||
ton.textContent = '';
|
||||
o.ton.forEach(function (t) {
|
||||
var b = el('button', 'ton-zeile' + (t.stumm ? ' stumm' : ''));
|
||||
b.type = 'button';
|
||||
b.dataset.quelle = t.name;
|
||||
b.dataset.stumm = t.stumm ? '1' : '0';
|
||||
b.innerHTML = t.stumm
|
||||
? '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM16 9l5 6M21 9l-5 6"/></svg>'
|
||||
: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg>';
|
||||
b.appendChild(el('span', 'ton-name', t.name));
|
||||
b.appendChild(el('span', 'ton-stand', t.stumm ? 'stumm' : 'an'));
|
||||
ton.appendChild(b);
|
||||
});
|
||||
if (!o.ton.length) ton.appendChild(el('p', 'klein', 'Keine Toneingänge gefunden.'));
|
||||
}
|
||||
|
||||
function obsRuf(knopf, felder) {
|
||||
tippen();
|
||||
return mitWarten(knopf, rufen('/pult/obs', felder)).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
$('szenen').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.szene');
|
||||
if (!b || b.classList.contains('aktiv')) return;
|
||||
obsRuf(b, { was: 'szene', szene: b.dataset.szene }).then(function (r) {
|
||||
// Sofort umfaerben, nicht erst auf den naechsten Stand warten. Der
|
||||
// Stand vom PC kommt hinterher und rueckt es gerade, falls noetig.
|
||||
if (r.ok) zeigeObs(Object.assign({}, zustand.obs, { aktuell: b.dataset.szene }));
|
||||
});
|
||||
});
|
||||
$('clip').addEventListener('click', function () { obsRuf($('clip'), { was: 'clip' }); });
|
||||
$('ton').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.ton-zeile');
|
||||
if (!b) return;
|
||||
obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Liegestuetze
|
||||
|
||||
function zeigeLz(st) {
|
||||
if (!st) return;
|
||||
$('lz-karte').hidden = false;
|
||||
if (typeof st.offen === 'number') $('lz-offen').textContent = st.offen.toLocaleString('de-DE');
|
||||
if (typeof st.gemacht === 'number') $('lz-gemacht').textContent = st.gemacht.toLocaleString('de-DE');
|
||||
}
|
||||
document.querySelector('.lz-knoepfe').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-um]');
|
||||
if (!b) return;
|
||||
tippen();
|
||||
mitWarten(b, rufen('/liegestuetze', { was: 'aendern', wert: b.dataset.um })).then(function (r) {
|
||||
zeigeLz(r);
|
||||
if (!r.ok) melden(r.text, 'fehler');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Vorlesen
|
||||
|
||||
function zeigeWartend(liste) {
|
||||
var box = $('wartend');
|
||||
box.textContent = '';
|
||||
$('tts-anzahl').hidden = !liste.length;
|
||||
$('tts-anzahl').textContent = String(liste.length);
|
||||
$('nav-tts').hidden = !liste.length;
|
||||
$('alle-ablehnen').hidden = liste.length < 2;
|
||||
if (!liste.length) { box.appendChild(el('div', 'leer-zeile', 'Nichts wartet auf Freigabe.')); return; }
|
||||
liste.forEach(function (w) {
|
||||
var n = el('div', 'nachricht');
|
||||
n.appendChild(el('div', 'wer', w.name));
|
||||
n.appendChild(el('div', 'was', w.text));
|
||||
var reihe = el('div', 'reihe');
|
||||
var ja = el('button', 'knopf primaer', 'Vorlesen');
|
||||
ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = w.id;
|
||||
var nein = el('button', 'knopf', 'Ablehnen');
|
||||
nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = w.id;
|
||||
reihe.appendChild(ja); reihe.appendChild(nein);
|
||||
n.appendChild(reihe);
|
||||
box.appendChild(n);
|
||||
});
|
||||
}
|
||||
|
||||
function ladeWartend() {
|
||||
if (!zustand.ttsAn) return;
|
||||
holen('/tts/wartend').then(function (b) { zeigeWartend(b.wartend || []); }, function () { /* still */ });
|
||||
}
|
||||
|
||||
function ttsRuf(was, id) {
|
||||
tippen();
|
||||
return rufen('/tts', { was: was, id: id || '' }).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
if (r.wartend) zeigeWartend(r.wartend);
|
||||
});
|
||||
}
|
||||
$('wartend').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-was]');
|
||||
if (b) ttsRuf(b.dataset.was, b.dataset.id);
|
||||
});
|
||||
$('alle-ablehnen').addEventListener('click', function () { ttsRuf('alle-ablehnen'); });
|
||||
|
||||
// Der Panikschalter. Ohne Vorlesen haelt er wenigstens die Sounds an.
|
||||
$('still').addEventListener('click', function () {
|
||||
tippen();
|
||||
if (zustand.ttsAn) { ttsRuf('still'); return; }
|
||||
rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Test
|
||||
|
||||
function baueTest(knoepfe, demoLaeuft) {
|
||||
var gruppen = $('test-gruppen');
|
||||
gruppen.textContent = '';
|
||||
var namen = [];
|
||||
knoepfe.forEach(function (k) { if (namen.indexOf(k.gruppe) === -1) namen.push(k.gruppe); });
|
||||
namen.forEach(function (g) {
|
||||
var box = el('div', 'gruppe');
|
||||
box.appendChild(el('h3', 'abschnitt', g));
|
||||
var reihe = el('div', 'knoepfe');
|
||||
knoepfe.filter(function (k) { return k.gruppe === g; }).forEach(function (k) {
|
||||
var b = el('button', 'knopf', k.label);
|
||||
b.type = 'button';
|
||||
b.dataset.art = k.art;
|
||||
if (k.nurOffline) b.dataset.offline = '1';
|
||||
reihe.appendChild(b);
|
||||
});
|
||||
box.appendChild(reihe);
|
||||
gruppen.appendChild(box);
|
||||
});
|
||||
setzeDemo(demoLaeuft);
|
||||
}
|
||||
|
||||
function setzeDemo(laeuft) {
|
||||
var d = $('demo');
|
||||
d.dataset.art = laeuft ? 'demo-aus' : 'demo-an';
|
||||
d.textContent = laeuft ? 'Vorführung stoppen' : 'Vorführung starten';
|
||||
d.disabled = !laeuft && zustand.live;
|
||||
}
|
||||
|
||||
function testRuf(knopf) {
|
||||
tippen();
|
||||
mitWarten(knopf, rufen('/test', { art: knopf.dataset.art })).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
if (typeof r.demo === 'boolean') setzeDemo(r.demo);
|
||||
});
|
||||
}
|
||||
$('test-gruppen').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-art]');
|
||||
if (b && !b.disabled) testRuf(b);
|
||||
});
|
||||
$('demo').addEventListener('click', function () { testRuf($('demo')); });
|
||||
|
||||
// ------------------------------------------------------------ Strom
|
||||
|
||||
var quelle = null;
|
||||
var zuletzt = Date.now();
|
||||
|
||||
function verbinde() {
|
||||
if (quelle) { try { quelle.close(); } catch (e) { /* zu */ } }
|
||||
quelle = new EventSource('/events?quelle=pult');
|
||||
zuletzt = Date.now();
|
||||
quelle.onopen = function () { setzeNetz(true); };
|
||||
quelle.onerror = function () {
|
||||
setzeNetz(false);
|
||||
// EventSource sagt nicht, ob es an der Anmeldung liegt. Eine kurze
|
||||
// Frage klaert das, sonst stuende "keine Verbindung" fuer immer da.
|
||||
holen('/soundboard/liste').then(function () { /* nur Netz */ }, function () { /* abgemeldet() hat schon */ });
|
||||
};
|
||||
quelle.onmessage = function (e) {
|
||||
zuletzt = Date.now();
|
||||
setzeNetz(true);
|
||||
var ev;
|
||||
try { ev = JSON.parse(e.data); } catch (err) { return; }
|
||||
switch (ev.kind) {
|
||||
case 'state':
|
||||
zeigeStream(ev.stream);
|
||||
if (ev.obs) zeigeObs(ev.obs);
|
||||
if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze);
|
||||
break;
|
||||
case 'stream': zeigeStream(ev); break;
|
||||
case 'obs': zeigeObs(ev); break;
|
||||
case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break;
|
||||
case 'sound': knall(ev.id); break;
|
||||
case 'sounds-geaendert': ladeSounds(); break;
|
||||
case 'tts': ladeWartend(); break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setzeNetz(an) {
|
||||
$('netz').classList.toggle('an', an);
|
||||
var banner = $('banner');
|
||||
banner.hidden = an;
|
||||
if (!an) banner.textContent = 'Keine Verbindung zum Hub – verbinde neu …';
|
||||
}
|
||||
|
||||
// Wachhund wie in client.js: der Hub schickt alle 15 s einen Herzschlag.
|
||||
// Am Handy reisst die Leitung beim Wegwischen ab, ohne dass es jemand merkt.
|
||||
setInterval(function () {
|
||||
if (Date.now() - zuletzt > 45000) verbinde();
|
||||
}, 10000);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible' && Date.now() - zuletzt > 20000) verbinde();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Start
|
||||
|
||||
holen('/pult/knoepfe').then(function (b) {
|
||||
baueTest(b.knoepfe || [], !!b.demo);
|
||||
zustand.ttsAn = !!b.tts;
|
||||
$('tts-karte').hidden = !b.tts;
|
||||
if (b.liegestuetze) zeigeLz(b.liegestuetze);
|
||||
ladeWartend();
|
||||
}, function () { /* abgemeldet oder weg - zeigt sich oben */ });
|
||||
ladeSounds();
|
||||
verbinde();
|
||||
wachHolen();
|
||||
|
||||
// Die Liste der Wartenden kommt nicht ueber den Strom (dort steht nur, was
|
||||
// vorgelesen wird). Also nachsehen, solange das Pult offen ist.
|
||||
setInterval(function () {
|
||||
if (document.visibilityState === 'visible') ladeWartend();
|
||||
}, 4000);
|
||||
// Ob die Sound-Quelle in OBS offen ist, aendert sich ohne Ereignis.
|
||||
setInterval(function () {
|
||||
if (document.visibilityState === 'visible') ladeSounds();
|
||||
}, 20000);
|
||||
|
||||
// Ein Reiter in der Adresse (Verknuepfung "Szenen" am App-Symbol) geht vor
|
||||
// dem zuletzt benutzten.
|
||||
var gemerkt = location.hash.slice(1) || null;
|
||||
if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } }
|
||||
if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function (e) {
|
||||
console.warn('[pult] Service Worker nicht registriert', e);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,109 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Soundboard</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Eine reine Tonquelle. Im Stream ist nichts zu sehen - ein Sound, der
|
||||
* zusaetzlich seinen Namen ins Bild schreibt, nimmt ihm die Pointe.
|
||||
*
|
||||
* Mit ?probe=1 steht unten, was gerade klingt. Damit laesst sich im Browser
|
||||
* pruefen, ob ein Druck am Handy hier ankommt, auch ohne Lautsprecher. */
|
||||
#liste {
|
||||
position: fixed;
|
||||
left: 12px; right: 12px; bottom: 12px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font: 600 16px/1.2 var(--font-zahl);
|
||||
}
|
||||
body.probe #liste { display: flex; }
|
||||
.laeuft {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--tinte-klar);
|
||||
border-left: 4px solid var(--lila);
|
||||
}
|
||||
.laeuft.fehler { border-left-color: var(--rot); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="liste"></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var liste = document.getElementById('liste');
|
||||
var schluessel = Overlay.param('key', '');
|
||||
/* Lautstaerke der ganzen Quelle. Besser im OBS-Mixer regeln; das hier ist
|
||||
* fuer den Fall, dass alle Sounds gemeinsam zu laut aufgenommen sind. */
|
||||
var gesamt = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
|
||||
/* Mehr gleichzeitig klingt nicht mehr nach Soundboard, sondern nach Unfall.
|
||||
* Der aelteste Sound macht dem neuen Platz. */
|
||||
var maxGleichzeitig = Math.max(1, Overlay.param('max', 4));
|
||||
var laufend = [];
|
||||
|
||||
function zeile(name, fehler) {
|
||||
var d = document.createElement('div');
|
||||
d.className = 'laeuft' + (fehler ? ' fehler' : '');
|
||||
d.textContent = name;
|
||||
liste.appendChild(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
function aufhoeren(eintrag) {
|
||||
var i = laufend.indexOf(eintrag);
|
||||
if (i !== -1) laufend.splice(i, 1);
|
||||
try { eintrag.ton.pause(); } catch (e) { /* egal */ }
|
||||
eintrag.ton.removeAttribute('src');
|
||||
if (eintrag.zeile.parentNode) eintrag.zeile.parentNode.removeChild(eintrag.zeile);
|
||||
}
|
||||
|
||||
function alleStopp() {
|
||||
laufend.slice().forEach(aufhoeren);
|
||||
}
|
||||
|
||||
Overlay.on('sound', function (ev) {
|
||||
while (laufend.length >= maxGleichzeitig) aufhoeren(laufend[0]);
|
||||
|
||||
var ton = new Audio('/soundboard/datei/' + encodeURIComponent(ev.id) +
|
||||
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
|
||||
var laut = typeof ev.laut === 'number' ? ev.laut : 0.8;
|
||||
ton.volume = Math.max(0, Math.min(1, laut * gesamt));
|
||||
var eintrag = { ton: ton, zeile: zeile(ev.name || 'Sound') };
|
||||
laufend.push(eintrag);
|
||||
|
||||
ton.onended = function () { aufhoeren(eintrag); };
|
||||
ton.onerror = function () {
|
||||
console.error('[sounds] nicht abspielbar', ev.name, ton.error && ton.error.code);
|
||||
eintrag.zeile.classList.add('fehler');
|
||||
setTimeout(function () { aufhoeren(eintrag); }, 3000);
|
||||
};
|
||||
// Notbremse: ein Sound, der nie "zu Ende" meldet, belegte sonst fuer
|
||||
// immer einen Platz.
|
||||
setTimeout(function () { aufhoeren(eintrag); }, 5 * 60 * 1000);
|
||||
|
||||
var p = ton.play();
|
||||
// Ein normaler Browser spielt erst nach einem Klick in die Seite. OBS
|
||||
// nicht - wer hier im Browser testet, sieht es an der roten Zeile.
|
||||
if (p && p.catch) p.catch(function (e) {
|
||||
console.error('[sounds] Wiedergabe verweigert', e && e.name);
|
||||
eintrag.zeile.textContent = (ev.name || 'Sound') + ' (erst in die Seite klicken)';
|
||||
eintrag.zeile.classList.add('fehler');
|
||||
setTimeout(function () { aufhoeren(eintrag); }, 3000);
|
||||
});
|
||||
});
|
||||
|
||||
Overlay.on('sound-stopp', alleStopp);
|
||||
// Der Panikschalter gilt auch hier.
|
||||
Overlay.on('still', alleStopp);
|
||||
// Nach dem Neuverbinden nichts nachholen: ein Sound zur falschen Zeit ist
|
||||
// schlimmer als keiner.
|
||||
Overlay.on('state', function () { });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Pausenbild</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Das einzige Overlay mit deckendem Hintergrund: es ersetzt das Bild, statt
|
||||
* darueber zu liegen. Gedacht als eigene OBS-Szene fuer "gleich geht es los",
|
||||
* Pause und Ende. Text ueber ?text= und ?unter=. */
|
||||
body { background: #0a0813; }
|
||||
|
||||
#grund {
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
radial-gradient(42% 42% at 22% 28%, rgba(169, 112, 255, .55), transparent 70%),
|
||||
radial-gradient(38% 38% at 78% 32%, rgba(79, 209, 197, .45), transparent 70%),
|
||||
radial-gradient(45% 45% at 55% 82%, rgba(255, 79, 163, .40), transparent 70%);
|
||||
filter: blur(48px);
|
||||
animation: treiben 28s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes treiben {
|
||||
0% { transform: translate(0, 0) scale(1) rotate(0deg); }
|
||||
50% { transform: translate(-3%, 2.5%) scale(1.1) rotate(4deg); }
|
||||
100% { transform: translate(3%, -2%) scale(1.04) rotate(-3deg); }
|
||||
}
|
||||
|
||||
/* Feines Raster darueber: nimmt dem Verlauf das Billige und gibt dem Bild
|
||||
* eine Struktur, die auch nach der Twitch-Kompression noch da ist. */
|
||||
#raster {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, .045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, .045) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
mask-image: radial-gradient(70% 70% at 50% 50%, #000, transparent);
|
||||
}
|
||||
|
||||
#mitte {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(64px, 8.5vw, 148px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -.035em;
|
||||
/* Unterlaengen brauchen Platz. `background-clip: text` faerbt nur, was
|
||||
* innerhalb der Polsterbox liegt - was darueber hinausragt, bleibt
|
||||
* durchsichtig und sieht abgeschnitten aus. Bei fetter Schrift in dieser
|
||||
* Groesse reicht eine knappe Zeilenhoehe dafuer nicht.
|
||||
*
|
||||
* Nachgemessen am "g" in "Gleich geht es los": mit 1.04 und .06em fehlte
|
||||
* noch ein Stueck vom Bogen. */
|
||||
line-height: 1.18;
|
||||
padding-bottom: .14em;
|
||||
background: linear-gradient(100deg, #fff 18%, var(--lila) 48%, var(--tuerkis) 78%, #fff 96%);
|
||||
background-size: 260% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: schimmern 7s linear infinite;
|
||||
filter: drop-shadow(0 10px 40px rgba(0, 0, 0, .55));
|
||||
}
|
||||
@keyframes schimmern { to { background-position: 260% 0; } }
|
||||
|
||||
#unter {
|
||||
font-size: clamp(20px, 2vw, 30px);
|
||||
color: var(--schrift-leise);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
/* Der Taktgeber: fuenf Balken, die atmen. Ohne bewegtes Element wirkt ein
|
||||
* Standbild wie ein eingefrorener Stream, und Zuschauer laden neu. */
|
||||
#takt { display: flex; gap: 12px; align-items: flex-end; height: 68px; margin-top: 10px; }
|
||||
#takt i {
|
||||
display: block;
|
||||
width: 13px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(180deg, var(--tuerkis), var(--lila));
|
||||
animation: atmen 1.5s ease-in-out infinite;
|
||||
}
|
||||
#takt i:nth-child(1) { height: 40%; animation-delay: 0s; }
|
||||
#takt i:nth-child(2) { height: 75%; animation-delay: .18s; }
|
||||
#takt i:nth-child(3) { height: 100%; animation-delay: .36s; }
|
||||
#takt i:nth-child(4) { height: 65%; animation-delay: .54s; }
|
||||
#takt i:nth-child(5) { height: 35%; animation-delay: .72s; }
|
||||
@keyframes atmen {
|
||||
0%, 100% { transform: scaleY(.35); opacity: .6; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
|
||||
#fuss {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 42px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 44px;
|
||||
font-size: 22px;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
#fuss b { color: var(--schrift); font-weight: 600; }
|
||||
#uhr { font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="grund"></div>
|
||||
<div id="raster"></div>
|
||||
<div id="mitte">
|
||||
<h1 id="gross">Gleich geht es los</h1>
|
||||
<div id="unter"></div>
|
||||
<div id="takt"><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
<div id="fuss">
|
||||
<span id="uhr"></span>
|
||||
<span>twitch.tv/<b id="kanal">abdulive_</b></span>
|
||||
<span id="thema"></span>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.getElementById('gross').textContent = Overlay.param('text', 'Gleich geht es los');
|
||||
document.getElementById('unter').textContent = Overlay.param('unter', '');
|
||||
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
|
||||
|
||||
var uhr = document.getElementById('uhr');
|
||||
function zeit() {
|
||||
uhr.textContent = new Date().toLocaleTimeString('de-DE',
|
||||
{ hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
zeit();
|
||||
setInterval(zeit, 10000);
|
||||
|
||||
// Laeuft der Stream schon, steht unten, worum es geht. Das ist der
|
||||
// Unterschied zwischen einem Pausenbild und einem Standbild.
|
||||
var thema = document.getElementById('thema');
|
||||
function stand(s) {
|
||||
thema.textContent = s.live && s.category ? 'heute: ' + s.category : '';
|
||||
}
|
||||
Overlay.on('stream', stand);
|
||||
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Statusleiste</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
#leiste {
|
||||
position: fixed;
|
||||
left: 28px; right: 28px; top: 22px;
|
||||
height: 92px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 30px;
|
||||
border-radius: 20px;
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
/* Ein Farbverlauf an der Oberkante statt eines Rahmens: er bindet die
|
||||
* Leiste an die Marke, ohne einen Kasten um alles zu ziehen. */
|
||||
overflow: hidden;
|
||||
}
|
||||
#leiste::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
|
||||
background-size: 300% 100%;
|
||||
animation: wandern 9s linear infinite;
|
||||
}
|
||||
@keyframes wandern { to { background-position: 300% 0; } }
|
||||
|
||||
.marke { display: flex; align-items: center; gap: 12px; }
|
||||
.punkt {
|
||||
width: 13px; height: 13px;
|
||||
border-radius: 50%;
|
||||
background: var(--rot);
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, .6);
|
||||
}
|
||||
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
|
||||
body:not(.live) .punkt { background: #555; }
|
||||
@keyframes klopfen {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
|
||||
70% { box-shadow: 0 0 0 16px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
}
|
||||
.kanal { font-size: 26px; font-weight: 700; letter-spacing: -.01em; }
|
||||
|
||||
.mitte { min-width: 0; }
|
||||
#titel {
|
||||
font-size: 25px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
#kategorie { font-size: 17px; color: var(--schrift-leise); margin-top: 2px; }
|
||||
|
||||
.werte { display: flex; gap: 30px; }
|
||||
.wert { text-align: right; min-width: 92px; }
|
||||
.wert .zahl {
|
||||
font-size: 30px; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
display: block;
|
||||
}
|
||||
.wert .beschriftung {
|
||||
font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
/* Kurzes Aufleuchten, wenn sich eine Zahl aendert. Ohne das uebersieht man
|
||||
* jede Veraenderung, weil die Leiste sonst voellig still steht. */
|
||||
.zahl.neu { animation: aufleuchten .8s ease-out; }
|
||||
@keyframes aufleuchten {
|
||||
0% { color: var(--tuerkis); transform: translateY(-3px); }
|
||||
100% { color: inherit; transform: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="leiste">
|
||||
<div class="marke">
|
||||
<span class="punkt"></span>
|
||||
<span class="kanal kontur" id="kanal">abdulive_</span>
|
||||
</div>
|
||||
<div class="mitte">
|
||||
<div id="titel" class="kontur">nicht live</div>
|
||||
<div id="kategorie"></div>
|
||||
</div>
|
||||
<div class="werte">
|
||||
<div class="wert"><span class="zahl" id="zuschauer">-</span>
|
||||
<span class="beschriftung">Zuschauer</span></div>
|
||||
<div class="wert"><span class="zahl" id="laufzeit">-</span>
|
||||
<span class="beschriftung">live seit</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var titel = document.getElementById('titel');
|
||||
var kategorie = document.getElementById('kategorie');
|
||||
var zuschauer = document.getElementById('zuschauer');
|
||||
var laufzeit = document.getElementById('laufzeit');
|
||||
var kanal = document.getElementById('kanal');
|
||||
var seit = null;
|
||||
|
||||
kanal.textContent = Overlay.param('kanal', 'abdulive_');
|
||||
|
||||
function setze(el, wert) {
|
||||
if (el.textContent === wert) return;
|
||||
el.textContent = wert;
|
||||
el.classList.remove('neu');
|
||||
// Neuzeichnen erzwingen, sonst startet die Animation beim zweiten Mal nicht.
|
||||
void el.offsetWidth;
|
||||
el.classList.add('neu');
|
||||
}
|
||||
|
||||
function stand(ev) {
|
||||
document.body.classList.toggle('live', !!ev.live);
|
||||
titel.textContent = ev.live ? (ev.title || 'ohne Titel') : 'nicht live';
|
||||
kategorie.textContent = ev.live ? (ev.category || '') : '';
|
||||
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
|
||||
seit = ev.live ? ev.startedAt : null;
|
||||
tick();
|
||||
}
|
||||
|
||||
function tick() {
|
||||
laufzeit.textContent = seit ? Overlay.laufzeit(seit) : '-';
|
||||
}
|
||||
setInterval(tick, 10000);
|
||||
|
||||
Overlay.on('stream', stand);
|
||||
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Service Worker fuer das Pult.
|
||||
*
|
||||
* Er macht genau zwei Dinge: er ist da, damit sich das Pult als App
|
||||
* installieren laesst, und er haelt die Huelle des Pults vor, damit es ohne
|
||||
* Netz wenigstens aufgeht und "keine Verbindung" sagen kann statt einer
|
||||
* leeren Fehlerseite des Browsers.
|
||||
*
|
||||
* Immer zuerst das Netz: nach dem Ausrollen soll sofort die neue Fassung
|
||||
* laufen, nicht die aus dem Speicher. Alles, was nicht auf der Liste steht -
|
||||
* Ereignisstrom, Knoepfe, Sounds, die Browser-Sources fuer OBS -, fasst er
|
||||
* nicht an. Ein Pult, das einen Szenenwechsel aus dem Speicher beantwortet,
|
||||
* waere schlimmer als gar keins. */
|
||||
|
||||
var SPEICHER = 'pult-v1';
|
||||
var HUELLE = ['/pult', '/pult.css', '/pult.js', '/manifest.webmanifest',
|
||||
'/icons/pult-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
self.addEventListener('install', function () { self.skipWaiting(); });
|
||||
|
||||
self.addEventListener('activate', function (e) {
|
||||
e.waitUntil(caches.keys().then(function (namen) {
|
||||
return Promise.all(namen.filter(function (n) { return n !== SPEICHER; })
|
||||
.map(function (n) { return caches.delete(n); }));
|
||||
}).then(function () { return self.clients.claim(); }));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (e) {
|
||||
var url = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || url.origin !== self.location.origin) return;
|
||||
if (HUELLE.indexOf(url.pathname) === -1) return;
|
||||
|
||||
e.respondWith(fetch(e.request).then(function (res) {
|
||||
// Nur Gelungenes merken. Die Anmeldeseite (401) gehoert nicht in den
|
||||
// Speicher, sonst steht sie beim naechsten Funkloch statt des Pults da.
|
||||
if (res.ok) {
|
||||
var kopie = res.clone();
|
||||
caches.open(SPEICHER).then(function (c) { c.put(url.pathname, kopie); });
|
||||
}
|
||||
return res;
|
||||
}, function () {
|
||||
return caches.match(url.pathname).then(function (r) {
|
||||
return r || new Response('Keine Verbindung zum Hub.', {
|
||||
status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/* Gemeinsames Aussehen aller Browser-Sources.
|
||||
*
|
||||
* Zwei Dinge gelten auf jeder Seite und sind der Grund, warum es diese Datei
|
||||
* gibt:
|
||||
*
|
||||
* 1. Der Hintergrund ist durchsichtig. Eine Browser-Source mit weissem
|
||||
* Hintergrund ist der haeufigste Anfaengerfehler, und man sieht ihn erst
|
||||
* in der Aufzeichnung.
|
||||
* 2. Text liegt ueber bewegtem Bild. Reine Farbe reicht dafuer nicht -
|
||||
* jeder Text bekommt eine dunkle Kontur, sonst verschwindet er im hellen
|
||||
* Gameplay. Deshalb `paint-order` und der Schatten unten.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Twitch-Lila wie im Discord-Bauplan, damit Stream und Server zusammengehoeren. */
|
||||
--lila: #a970ff;
|
||||
--lila-tief: #6441a5;
|
||||
--tuerkis: #4fd1c5;
|
||||
--pink: #ff4fa3;
|
||||
--gold: #ffc53d;
|
||||
--gruen: #4ade80;
|
||||
--rot: #ef4444;
|
||||
|
||||
--tinte: #120e1d;
|
||||
--tinte-klar: rgba(18, 14, 29, .72);
|
||||
--schrift: #f6f3ff;
|
||||
--schrift-leise: #c5bde0;
|
||||
|
||||
--rund: 14px;
|
||||
--schatten: 0 12px 40px rgba(0, 0, 0, .55);
|
||||
--glanz: inset 0 1px 0 rgba(255, 255, 255, .12);
|
||||
|
||||
/* Segoe UI Variable gibt es auf Windows 11 - Abdus Rechner. Der Rest der
|
||||
* Liste ist Rueckfall fuer Entwicklung auf anderen Systemen. */
|
||||
--font: "Segoe UI Variable Display", "Segoe UI", Inter, system-ui, sans-serif;
|
||||
--font-zahl: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
font-family: var(--font);
|
||||
color: var(--schrift);
|
||||
/* OBS rendert mit eigener Skalierung; ohne das wirken Kanten weich. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.kontur {
|
||||
paint-order: stroke fill;
|
||||
-webkit-text-stroke: 4px rgba(0, 0, 0, .55);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, .7);
|
||||
}
|
||||
|
||||
/* Glasplatte: der Traeger fuer alles, was ueber dem Spiel steht. */
|
||||
.glas {
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: var(--rund);
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
}
|
||||
|
||||
/* Der Lichtstreifen, der einmal ueber eine Flaeche wandert. Wird von den
|
||||
* Einblendungen benutzt und ist der Grund, warum eine Einblendung "teuer"
|
||||
* aussieht, ohne dass ein Video dahintersteckt. */
|
||||
@keyframes streifen {
|
||||
from { transform: translateX(-120%) skewX(-18deg); }
|
||||
to { transform: translateX(320%) skewX(-18deg); }
|
||||
}
|
||||
|
||||
.streifen::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 28%;
|
||||
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .3), transparent);
|
||||
animation: streifen 1.5s cubic-bezier(.3, 0, .2, 1) .35s both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pulsieren {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .55; transform: scale(.82); }
|
||||
}
|
||||
|
||||
/* Zum Ansehen im normalen Browser: ?probe=1 legt einen dunklen Grund unter
|
||||
* die Seite. Ohne den steht heller Text auf Weiss, und man sieht nichts -
|
||||
* in OBS liegt das Overlay ueber dem Spiel, im Browser ueber gar nichts. */
|
||||
body.probe {
|
||||
background:
|
||||
radial-gradient(60% 60% at 30% 25%, #2a1f45, transparent 70%),
|
||||
radial-gradient(50% 50% at 75% 70%, #123c3a, transparent 70%),
|
||||
#0b0912;
|
||||
}
|
||||
|
||||
/* Wer im Browser nachsieht, will nicht raten, ob die Verbindung steht.
|
||||
* In OBS ist der Punkt unsichtbar, weil er nur bei Verlust erscheint. */
|
||||
.getrennt {
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
bottom: 8px;
|
||||
font: 500 12px/1 var(--font-zahl);
|
||||
color: var(--rot);
|
||||
background: rgba(0, 0, 0, .7);
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
opacity: 0;
|
||||
transition: opacity .4s;
|
||||
}
|
||||
body.offline .getrennt { opacity: 1; }
|
||||
|
||||
/* Wer mit prefers-reduced-motion arbeitet, bekommt in OBS trotzdem die
|
||||
* Animationen - eine Browser-Source hat keinen Menschen mit Vestibular-
|
||||
* problemen davor. Die Einstellung wird hier bewusst nicht beachtet. */
|
||||
@@ -0,0 +1,184 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Vorlesen</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Die Seite zeigt fast nichts. Sie ist eine Tonquelle mit einer kleinen
|
||||
* Sichtkontrolle daneben - man soll sehen, was gerade gesprochen wird, und
|
||||
* zwar auch als Zuschauer. Wer nur hoert, was vorgelesen wird, kann nicht
|
||||
* nachlesen, wenn er es nicht verstanden hat. */
|
||||
#kasten {
|
||||
position: fixed;
|
||||
left: 16px; right: 16px; bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
transition: opacity .3s, transform .3s;
|
||||
}
|
||||
#kasten.an { opacity: 1; transform: none; }
|
||||
|
||||
.wer {
|
||||
font-size: 13px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--tuerkis);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.was { font-size: 22px; line-height: 1.35; }
|
||||
|
||||
/* Ein Hinweis fuer den Fall, dass eine Nachricht ohne Ton bleibt. Im Stream
|
||||
* soll das auffallen, nicht stillschweigend fehlen. */
|
||||
#fehlt {
|
||||
position: fixed;
|
||||
left: 16px; top: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(239, 68, 68, .9);
|
||||
font: 600 15px/1.3 var(--font-zahl);
|
||||
max-width: 520px;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="fehlt">
|
||||
Kein Ton: der Hub konnte nicht sprechen, und hier gibt es keine deutsche
|
||||
Stimme. Die Nachricht wird nur angezeigt.
|
||||
</div>
|
||||
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var kasten = document.getElementById('kasten');
|
||||
var werEl = document.getElementById('wer');
|
||||
var wasEl = document.getElementById('was');
|
||||
|
||||
var sprache = Overlay.param('sprache', 'de-DE');
|
||||
var tempo = Overlay.param('tempo', 1);
|
||||
var lautstaerke = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
|
||||
var schluessel = Overlay.param('key', '');
|
||||
var fehltEl = document.getElementById('fehlt');
|
||||
var warteschlange = [];
|
||||
var laeuft = false;
|
||||
/* Was gerade klingt, damit der Panikschalter es abbrechen kann. */
|
||||
var aktuell = null;
|
||||
|
||||
/* Gesprochen wird im Hub (Piper), hier wird nur abgespielt. Das hat zwei
|
||||
* Gruende: die eingebaute Sprachausgabe von OBS' Browser sah keine deutsche
|
||||
* Stimme und las amerikanisch, und ein <audio> laeuft sicher ueber OBS'
|
||||
* Tonspur statt am Mixer vorbei.
|
||||
*
|
||||
* Die Browser-Sprachausgabe bleibt nur als Notnagel, wenn der Hub keinen
|
||||
* Ton liefern konnte - und auch dann nur mit einer deutschen Stimme. Eine
|
||||
* englische Stimme, die deutschen Text radebrecht, ist schlimmer als Stille. */
|
||||
function deutscheStimme() {
|
||||
if (typeof window.speechSynthesis === 'undefined' ||
|
||||
typeof window.SpeechSynthesisUtterance === 'undefined') return null;
|
||||
var alle = window.speechSynthesis.getVoices() || [];
|
||||
var kurz = sprache.slice(0, 2).toLowerCase();
|
||||
for (var i = 0; i < alle.length; i++) {
|
||||
if (alle[i].lang && alle[i].lang.toLowerCase().indexOf(kurz) === 0) return alle[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function zeige(name, text) {
|
||||
werEl.textContent = name;
|
||||
wasEl.textContent = text;
|
||||
kasten.classList.add('an');
|
||||
}
|
||||
function verstecke() { kasten.classList.remove('an'); fehltEl.style.display = 'none'; }
|
||||
|
||||
/* Geschaetzte Sprechdauer, als Notbremse und fuer die stumme Anzeige. */
|
||||
function dauerMs(text) { return 3000 + text.length * 90; }
|
||||
|
||||
function naechste() {
|
||||
var n = warteschlange.shift();
|
||||
aktuell = null;
|
||||
if (!n) { laeuft = false; verstecke(); return; }
|
||||
laeuft = true;
|
||||
zeige(n.name, n.text);
|
||||
|
||||
var weiter = false;
|
||||
var gehWeiter = function () {
|
||||
if (weiter) return;
|
||||
weiter = true;
|
||||
setTimeout(naechste, 400);
|
||||
};
|
||||
// Notbremse: haengt die Wiedergabe, steht die Warteschlange sonst fuer
|
||||
// den Rest des Streams. Grosszuegig bemessen.
|
||||
setTimeout(gehWeiter, 8000 + dauerMs(n.text) * 2);
|
||||
|
||||
if (n.audio) {
|
||||
var ton = new Audio('/tts/audio/' + encodeURIComponent(n.id) +
|
||||
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
|
||||
ton.volume = lautstaerke;
|
||||
ton.onended = gehWeiter;
|
||||
ton.onerror = function () {
|
||||
console.error('[tts] Ton nicht abspielbar', ton.error && ton.error.code);
|
||||
gehWeiter();
|
||||
};
|
||||
aktuell = { stopp: function () { try { ton.pause(); } catch (e) { /* egal */ } } };
|
||||
var p = ton.play();
|
||||
// Ein normaler Browser blockiert Ton ohne vorherigen Klick. OBS nicht -
|
||||
// wer die Seite zum Testen im Browser offen hat, sieht es hier.
|
||||
if (p && p.catch) p.catch(function (e) {
|
||||
console.error('[tts] Wiedergabe verweigert', e && e.name);
|
||||
gehWeiter();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var stimme = deutscheStimme();
|
||||
if (!stimme) {
|
||||
console.error('[tts] Kein Ton vom Hub und keine deutsche Stimme - nur Anzeige.');
|
||||
fehltEl.style.display = 'block';
|
||||
setTimeout(gehWeiter, dauerMs(n.text));
|
||||
return;
|
||||
}
|
||||
var rede = new SpeechSynthesisUtterance(n.text);
|
||||
rede.lang = stimme.lang;
|
||||
rede.voice = stimme;
|
||||
rede.rate = tempo;
|
||||
rede.volume = lautstaerke;
|
||||
rede.onend = gehWeiter;
|
||||
rede.onerror = function (e) {
|
||||
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
||||
gehWeiter();
|
||||
};
|
||||
aktuell = { stopp: function () { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } } };
|
||||
window.speechSynthesis.speak(rede);
|
||||
}
|
||||
|
||||
Overlay.on('tts', function (ev) {
|
||||
warteschlange.push({ id: ev.id, name: ev.name, text: ev.text, audio: !!ev.audio });
|
||||
if (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
|
||||
if (!laeuft) naechste();
|
||||
});
|
||||
|
||||
// Panikschalter: sofort still, Warteschlange weg.
|
||||
Overlay.on('still', function () {
|
||||
warteschlange.length = 0;
|
||||
if (aktuell) aktuell.stopp();
|
||||
aktuell = null;
|
||||
laeuft = false;
|
||||
verstecke();
|
||||
});
|
||||
|
||||
// Nach dem Neuverbinden NICHT nachholen: eine Nachricht von vor zehn
|
||||
// Minuten vorzulesen ist schlimmer, als sie zu verpassen.
|
||||
Overlay.on('state', function () { });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: DejaVu fonts
|
||||
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
||||
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
||||
Source: https://dejavu-fonts.github.io/
|
||||
|
||||
Files: *
|
||||
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
||||
Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
DejaVu changes are in public domain.
|
||||
License: bitstream-vera
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
.
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
.
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
.
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
.
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
.
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
.
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Files: debian/*
|
||||
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
||||
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
||||
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
||||
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
||||
License: GPL-2+
|
||||
This program is free software; you can redistribute it
|
||||
and/or modify it under the terms of the GNU General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2 of the License, or (at your option) any later
|
||||
version.
|
||||
.
|
||||
This program is distributed in the hope that it will be
|
||||
useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
PURPOSE. See the GNU General Public License for more
|
||||
details.
|
||||
.
|
||||
You should have received a copy of the GNU General Public
|
||||
License along with this package; if not, write to the Free
|
||||
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
||||
Boston, MA 02110-1301 USA
|
||||
.
|
||||
On Debian systems, the full text of the GNU General Public
|
||||
License version 2 can be found in the file
|
||||
/usr/share/common-licenses/GPL-2'.
|
||||
@@ -22,6 +22,15 @@ ingest:
|
||||
armed: false # erst nach Kalibrierung auf true
|
||||
laughEmotes: [KEKW, LUL, OMEGALUL, PogChamp, Pog]
|
||||
|
||||
# Woher `abdulive update` neue Fassungen holt. Erwartet wird dort
|
||||
# `abdulive.json` und die darin genannte ZIP-Datei.
|
||||
#
|
||||
# Leer = keine automatischen Updates. Liegt die Adresse hinter einer
|
||||
# Anmeldung (privates Gitea), gehoert der Zugriffstoken NICHT hierher,
|
||||
# sondern auf die Einrichtungsseite - siehe ADR-0005.
|
||||
update:
|
||||
url: "https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest"
|
||||
|
||||
obs:
|
||||
url: ws://127.0.0.1:4455
|
||||
replayBufferSeconds: 90
|
||||
@@ -61,16 +70,30 @@ clipper:
|
||||
# Der Hotkey wird in OBS auf "Wiedergabepuffer speichern" gelegt. Es gibt
|
||||
# bewusst keinen eigenen Hotkey-Dienst - siehe ADR-0004, Punkt 4.
|
||||
|
||||
# Go-Live-Ankuendigung auf Discord.
|
||||
#
|
||||
# mode: live -> Post in #ankuendigungen, mit Ping an die Rolle Live-Ping
|
||||
# draft -> derselbe Post in #technik-log, ohne Ping. Zum Zusehen.
|
||||
#
|
||||
# Zum Ausprobieren, ohne auf einen Stream zu warten:
|
||||
# abdulive golive:test (interner Kanal, ohne Ping)
|
||||
# abdulive golive:test --echt (echter Kanal, mit Ping)
|
||||
broadcaster:
|
||||
enabled: true
|
||||
mode: draft # draft | live
|
||||
mode: live
|
||||
# Twitch setzt Titel und Kategorie erst kurz nach dem Start. Wer sofort
|
||||
# postet, kuendigt regelmaessig den Titel des letzten Streams an.
|
||||
delaySecondsAfterOnline: 60
|
||||
# Innerhalb dieser Zeit wird kein zweites Mal angekuendigt. Faengt den Fall
|
||||
# ab, dass Twitch nach einem Aussetzer eine neue Stream-ID vergibt.
|
||||
cooldownHours: 6
|
||||
pingRole: Live-Ping # leer = Post ohne Ping
|
||||
obsScreenshot: true # Vorschaubild aus OBS statt von Twitch
|
||||
platforms:
|
||||
discord: true
|
||||
bluesky: true
|
||||
bluesky: false # noch nicht gebaut
|
||||
mastodon: false
|
||||
instagramStoryImage: true
|
||||
instagramStoryImage: false
|
||||
|
||||
community:
|
||||
greetings:
|
||||
@@ -100,8 +123,62 @@ recap:
|
||||
|
||||
loyalty:
|
||||
enabled: false # spaetere Ausbaustufe
|
||||
# Kanalpunkt-Belohnungen und was sie ausloesen.
|
||||
#
|
||||
# Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein-
|
||||
# schreibung egal) oder ueber die stabile ID; liegt beides vor, gewinnt die ID.
|
||||
# Belohnungen, die hier nicht stehen, werden protokolliert und sonst in Ruhe
|
||||
# gelassen - nicht jede Belohnung geht das Toolkit etwas an.
|
||||
#
|
||||
# Aktionen: konfetti | emote-regen | einblendung | tts | liegestuetze | nichts
|
||||
rewards:
|
||||
enabled: false # aus, bis hier bewusst Belohnungen stehen
|
||||
belohnungen: []
|
||||
# Beispiel:
|
||||
# belohnungen:
|
||||
# - titel: Konfetti
|
||||
# aktion: konfetti
|
||||
# - titel: Nachricht vorlesen
|
||||
# aktion: tts
|
||||
# maxZeichen: 150
|
||||
# - titel: 5 Liegestütze
|
||||
# aktion: liegestuetze
|
||||
# anzahl: 5 # so viele kommen je Einloesung dazu
|
||||
tts:
|
||||
# Vorgabe ist die Freigabe von Hand, und das aus gutem Grund: Kanalpunkte
|
||||
# sammeln sich beim Zugucken von allein an und sind damit kaum eine
|
||||
# Huerde. Auf `false` gestellt hat man eine Vorlesefunktion fuer Fremde
|
||||
# im eigenen Stream. Siehe docs/projects/chat-control.md.
|
||||
freigabe: true
|
||||
maxZeichen: 200
|
||||
# Zu langer Text wird abgelehnt, nicht gekuerzt - ein halb vorgelesener
|
||||
# Satz ist schlimmer als gar keiner.
|
||||
blockliste: []
|
||||
proPersonProStunde: 3
|
||||
# Gesprochen wird im Hub mit Piper, nicht im Browser - dort haengt die
|
||||
# Stimme am Rechner, der streamt. Name einer Datei unter models/piper/
|
||||
# (ohne .onnx) oder ein absoluter Pfad. Einrichten: scripts/install-piper.sh
|
||||
stimme: de_DE-thorsten-medium
|
||||
# Nur bei Stimmen mit mehreren Sprechern, z. B. de_DE-mls-medium (0-235).
|
||||
# Die klang im Stream allerdings verwaschen.
|
||||
# sprecher: 0
|
||||
|
||||
# Overlay-Server. Zugleich die einzige Tuer nach aussen, wenn der Dienst
|
||||
# zentral laeuft: Uebersicht, Einrichtung und OAuth-Rueckruf haengen daran.
|
||||
overlay:
|
||||
port: 4791
|
||||
enabled: true
|
||||
|
||||
# Oeffentliche Adresse, ohne Schraegstrich am Ende.
|
||||
#
|
||||
# Leer = der Dienst laeuft lokal und bindet an 127.0.0.1. Ist etwas gesetzt,
|
||||
# bindet er an 0.0.0.0 (der Reverse-Proxy sitzt in einem anderen Container)
|
||||
# und der OAuth-Rueckruf von Twitch geht an <publicUrl>/callback - genau so
|
||||
# muss er auch in der Twitch-App hinterlegt sein.
|
||||
#
|
||||
# Die Umgebungsvariable PUBLIC_URL gewinnt gegen diesen Wert.
|
||||
publicUrl: ""
|
||||
|
||||
dashboard:
|
||||
port: 4792
|
||||
ingestServer:
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Stand: Scheiben S0 und S1 aus docs/07-mvp-plan.md.
|
||||
#
|
||||
# Nach `docker compose up -d` laeuft der Ingest-Daemon und die Einrichtung
|
||||
# ist unter http://localhost:4792/setup erreichbar. Zugangsdaten werden dort
|
||||
# eingegeben, nicht hier - siehe docs/decisions/ADR-0005-zugangsdaten.md.
|
||||
#
|
||||
# OBS_REPLAY_DIR in .env auf den Wiedergabepuffer-Ordner setzen, z. B.
|
||||
# OBS_REPLAY_DIR=C:/Users/abdu/Videos/replay
|
||||
|
||||
services:
|
||||
toolkit:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
image: abdulive/toolkit:dev
|
||||
container_name: abdulive-toolkit
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
command: ["ingest"]
|
||||
|
||||
# OBS laeuft auf dem Host, nicht im Container
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
ports:
|
||||
# Ein Server fuer Einrichtung, OAuth-Rueckruf, Sichtungsseite und
|
||||
# Dashboard - siehe docs/decisions/ADR-0005-zugangsdaten.md
|
||||
- "127.0.0.1:4792:4792"
|
||||
- "127.0.0.1:4791:4791" # Overlay-Server (ab M3)
|
||||
|
||||
volumes:
|
||||
# Konfiguration: nur lesend
|
||||
- ./config:/app/config:ro
|
||||
# OBS-Wiedergabepuffer: nur lesend, wird zum Verarbeiten einmal kopiert
|
||||
- ${OBS_REPLAY_DIR:-./data/replay}:/replay:ro
|
||||
# Fertige Clips landen wieder auf der Windows-Seite
|
||||
- ./data/clips:/app/data/clips
|
||||
# DB, Modelle und Arbeitsdateien in benannten Volumes - NICHT als
|
||||
# Bind-Mount, siehe docs/decisions/ADR-0004-docker.md
|
||||
- abdulive-db:/db
|
||||
- abdulive-models:/models
|
||||
- abdulive-work:/work
|
||||
|
||||
# Damit das Encoding dem Stream nicht die CPU wegnimmt
|
||||
cpus: "3.0"
|
||||
mem_limit: 3g
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:4792/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
abdulive-db:
|
||||
abdulive-models:
|
||||
abdulive-work:
|
||||
@@ -1,59 +0,0 @@
|
||||
# Scheiben S0 und S1 aus docs/07-mvp-plan.md.
|
||||
#
|
||||
# Das Projekt hat KEINE npm-Abhaengigkeiten: Node 24 bringt SQLite
|
||||
# (node:sqlite), WebSocket und fetch mit und fuehrt TypeScript direkt aus
|
||||
# (Type Stripping). Deshalb gibt es weder eine deps-Stufe noch einen
|
||||
# Build-Schritt. Siehe ADR-0001 und ADR-0004.
|
||||
|
||||
# ---------- whisper.cpp bauen ----------
|
||||
FROM debian:bookworm-slim AS whisper
|
||||
ARG WHISPER_VERSION=v1.7.4
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth 1 --branch ${WHISPER_VERSION} \
|
||||
https://github.com/ggerganov/whisper.cpp /src
|
||||
WORKDIR /src
|
||||
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release -DWHISPER_BUILD_TESTS=OFF \
|
||||
-DWHISPER_BUILD_EXAMPLES=ON \
|
||||
&& cmake --build build --config Release -j"$(nproc)" \
|
||||
&& mkdir -p /out \
|
||||
&& cp build/bin/whisper-cli /out/ \
|
||||
&& find build -name 'libwhisper.so*' -o -name 'libggml*.so' | xargs -r cp -P -t /out/
|
||||
|
||||
# ---------- Laufzeit ----------
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg fonts-dejavu-core ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=whisper /out/ /usr/local/lib/whisper/
|
||||
RUN ln -s /usr/local/lib/whisper/whisper-cli /usr/local/bin/whisper-cli \
|
||||
&& echo /usr/local/lib/whisper > /etc/ld.so.conf.d/whisper.conf \
|
||||
&& ldconfig
|
||||
|
||||
WORKDIR /app
|
||||
# package.json zuerst: sie enthaelt die "imports"-Map, ueber die #core/... aufgeloest wird
|
||||
COPY package.json ./
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/ ./apps/
|
||||
COPY docker/ ./docker/
|
||||
|
||||
# Datenbank und Sprachmodelle liegen in benannten Volumes, nicht im
|
||||
# Bind-Mount - siehe docs/decisions/ADR-0004-docker.md
|
||||
ENV WHISPER_BIN=/usr/local/bin/whisper-cli \
|
||||
MODEL_DIR=/models \
|
||||
DB_DIR=/db \
|
||||
WORK_DIR=/work \
|
||||
CLIPS_DIR=/app/data/clips \
|
||||
REPLAY_DIR=/replay \
|
||||
CONFIG_DIR=/app/config \
|
||||
NODE_ENV=production \
|
||||
NODE_OPTIONS=--disable-warning=ExperimentalWarning \
|
||||
BIND_HOST=0.0.0.0
|
||||
|
||||
EXPOSE 4790 4791 4792
|
||||
|
||||
ENTRYPOINT ["node", "/app/docker/entrypoint.ts"]
|
||||
CMD ["help"]
|
||||
@@ -1,438 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Einstiegspunkt des Container-Images.
|
||||
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { loadLayout, paths, type Rect } from '#core/config.ts';
|
||||
import { probe, run as runMedia } from '#media/ffmpeg.ts';
|
||||
import { micTrackArgs, thumbnailArgs, verticalArgs, verticalFilter } from '#media/vertical.ts';
|
||||
import { transcribe } from '#media/whisper.ts';
|
||||
import { buildAss, buildSrt, toLines } from '#media/subtitles.ts';
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { openDb } from '#db/index.ts';
|
||||
import { secretStatus } from '#twitch/secrets.ts';
|
||||
|
||||
const WHISPER = process.env.WHISPER_BIN || 'whisper-cli';
|
||||
const OBS_HOST = process.env.OBS_WS_HOST || 'host.docker.internal';
|
||||
const OBS_PORT = Number(process.env.OBS_WS_PORT || 4455);
|
||||
|
||||
const MODELS: Record<string, string> = {
|
||||
'ggml-small.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
|
||||
'ggml-medium.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
|
||||
'ggml-base.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
|
||||
};
|
||||
|
||||
const C = process.stdout.isTTY
|
||||
? { ok: '\x1b[32m', warn: '\x1b[33m', bad: '\x1b[31m', dim: '\x1b[90m', off: '\x1b[0m' }
|
||||
: { ok: '', warn: '', bad: '', dim: '', off: '' };
|
||||
|
||||
let hardFailures = 0;
|
||||
let warnings = 0;
|
||||
|
||||
function report(state: 'ok' | 'warn' | 'bad', label: string, detail = ''): void {
|
||||
const mark = { ok: `${C.ok} OK ${C.off}`, warn: `${C.warn} WARN ${C.off}`, bad: `${C.bad} FEHLT${C.off}` }[state];
|
||||
if (state === 'bad') hardFailures++;
|
||||
if (state === 'warn') warnings++;
|
||||
console.log(`${mark} ${label.padEnd(34)} ${C.dim}${detail}${C.off}`);
|
||||
}
|
||||
|
||||
function section(title: string): void {
|
||||
console.log(`\n${title}`);
|
||||
console.log('-'.repeat(Math.max(title.length, 40)));
|
||||
}
|
||||
|
||||
function run(cmd: string, args: string[]): string {
|
||||
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
function runAsync(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) =>
|
||||
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function canWrite(dir: string): Promise<boolean> {
|
||||
const probe = path.join(dir, `.probe-${process.pid}`);
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(probe, 'x');
|
||||
await fs.unlink(probe);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function tcpProbe(host: string, port: number, timeout = 2500): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
const done = (r: boolean) => { sock.destroy(); resolve(r); };
|
||||
sock.setTimeout(timeout);
|
||||
sock.once('connect', () => done(true));
|
||||
sock.once('timeout', () => done(false));
|
||||
sock.once('error', () => done(false));
|
||||
sock.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- check-env
|
||||
|
||||
async function checkEnv(): Promise<void> {
|
||||
console.log('\nabdulive-toolkit - Selbsttest der Umgebung');
|
||||
|
||||
section('Laufzeit');
|
||||
const major = Number(process.versions.node.split('.')[0]);
|
||||
report(major >= 24 ? 'ok' : 'bad', 'Node.js', `v${process.versions.node}`);
|
||||
try {
|
||||
const { DatabaseSync } = await import('node:sqlite');
|
||||
new DatabaseSync(':memory:').close();
|
||||
report('ok', 'node:sqlite (eingebaut)');
|
||||
} catch (err) {
|
||||
report('bad', 'node:sqlite', (err as Error).message.slice(0, 50));
|
||||
}
|
||||
report(typeof WebSocket !== 'undefined' ? 'ok' : 'bad', 'WebSocket (eingebaut)');
|
||||
|
||||
section('Medienwerkzeuge');
|
||||
try {
|
||||
const v = run('ffmpeg', ['-version']).split('\n')[0] ?? '';
|
||||
report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40));
|
||||
const cfg = run('ffmpeg', ['-hide_banner', '-buildconf']);
|
||||
report(cfg.includes('--enable-libx264') ? 'ok' : 'bad', ' libx264 (H.264-Encoder)');
|
||||
report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)');
|
||||
} catch { report('bad', 'ffmpeg', 'nicht im PATH'); }
|
||||
try { run('ffprobe', ['-version']); report('ok', 'ffprobe'); }
|
||||
catch { report('bad', 'ffprobe', 'nicht im PATH'); }
|
||||
try {
|
||||
run(WHISPER, ['--help']);
|
||||
report('ok', 'whisper-cli', WHISPER);
|
||||
} catch (err) {
|
||||
const text = `${(err as { stdout?: string }).stdout ?? ''}${(err as { stderr?: string }).stderr ?? ''}`;
|
||||
report(text.includes('whisper') || text.includes('usage') ? 'ok' : 'bad', 'whisper-cli',
|
||||
text.includes('whisper') ? WHISPER : 'nicht lauffaehig');
|
||||
}
|
||||
|
||||
section('Sprachmodell');
|
||||
let anyModel = false;
|
||||
for (const name of Object.keys(MODELS)) {
|
||||
try {
|
||||
const st = await fs.stat(path.join(paths.models, name));
|
||||
report('ok', name, `${(st.size / 1024 / 1024).toFixed(0)} MB`);
|
||||
anyModel = true;
|
||||
} catch { /* nicht vorhanden */ }
|
||||
}
|
||||
if (!anyModel) report('warn', 'kein Modell vorhanden', 'holen mit: fetch-model ggml-small.bin');
|
||||
|
||||
section('Verzeichnisse');
|
||||
for (const [label, dir] of [
|
||||
['Datenbank (Volume)', paths.db],
|
||||
['Arbeitsverzeichnis (Volume)', paths.work],
|
||||
['Sprachmodelle (Volume)', paths.models],
|
||||
['Clips (Bind-Mount)', paths.clips],
|
||||
] as const) {
|
||||
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
|
||||
}
|
||||
for (const [label, dir, hard] of [
|
||||
['OBS-Wiedergabepuffer', paths.replay, false],
|
||||
['Konfiguration', paths.config, true],
|
||||
] as const) {
|
||||
try {
|
||||
const n = (await fs.readdir(dir)).length;
|
||||
report('ok', label, `${dir} lesbar, ${n} Eintraege`);
|
||||
} catch { report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar - Mount gesetzt?`); }
|
||||
}
|
||||
|
||||
section('Datenbank');
|
||||
try {
|
||||
const db = openDb();
|
||||
const n = (db.prepare('SELECT COUNT(*) AS n FROM schema_migrations').get() as { n: number }).n;
|
||||
report('ok', 'Schema', `${n} Migration(en) angewendet`);
|
||||
} catch (err) {
|
||||
report('bad', 'Schema', (err as Error).message.slice(0, 60));
|
||||
}
|
||||
|
||||
section('Layout (Facecam-Ausschnitt)');
|
||||
try {
|
||||
const layout = loadLayout();
|
||||
const prof = layout.profiles?.['default'];
|
||||
if (!prof) throw new Error('Profil "default" fehlt');
|
||||
const sum = prof.cam.band.h + prof.game.band.h;
|
||||
report(sum === layout.target.height ? 'ok' : 'bad', 'Baender ergeben Zielhoehe',
|
||||
`${prof.cam.band.h} + ${prof.game.band.h} = ${sum} (soll ${layout.target.height})`);
|
||||
for (const [name, c] of [['Facecam', prof.cam.crop], ['Gameplay', prof.game.crop]] as const) {
|
||||
const inside = c.x >= 0 && c.y >= 0 &&
|
||||
c.x + c.w <= layout.source.width && c.y + c.h <= layout.source.height;
|
||||
report(inside ? 'ok' : 'bad', `${name}-Ausschnitt im Bild`, `${c.w}x${c.h} bei ${c.x},${c.y}`);
|
||||
}
|
||||
for (const [name, c, b] of [
|
||||
['Facecam', prof.cam.crop, prof.cam.band], ['Gameplay', prof.game.crop, prof.game.band],
|
||||
] as const) {
|
||||
const dev = Math.abs((c.w / c.h) / (b.w / b.h) - 1) * 100;
|
||||
report(dev < 1 ? 'ok' : 'warn', `${name}-Seitenverhaeltnis`,
|
||||
`Abweichung ${dev.toFixed(2)} % (>1 % verzerrt sichtbar)`);
|
||||
}
|
||||
} catch (err) {
|
||||
report('bad', 'config/layout.yaml', (err as Error).message);
|
||||
}
|
||||
|
||||
section('OBS');
|
||||
const obsUp = await tcpProbe(OBS_HOST, OBS_PORT);
|
||||
report(obsUp ? 'ok' : 'warn', 'obs-websocket erreichbar',
|
||||
`${OBS_HOST}:${OBS_PORT}${obsUp ? '' : ' - OBS laeuft nicht oder WebSocket-Server ist aus'}`);
|
||||
|
||||
section('Zugangsdaten');
|
||||
// Meldet nur, OB etwas hinterlegt ist - nie was. Siehe ADR-0005.
|
||||
const st = secretStatus();
|
||||
for (const role of ['broadcaster', 'bot'] as const) {
|
||||
const a = st.apps[role];
|
||||
report(a.clientId && a.hasSecret ? 'ok' : 'warn', `${role}: App-Daten`,
|
||||
a.clientId ? (a.hasSecret ? 'Client-ID und Secret hinterlegt' : 'Secret fehlt') : 'nichts hinterlegt');
|
||||
report(st.tokens[role].connected ? 'ok' : 'warn', `${role}: Twitch verbunden`,
|
||||
st.tokens[role].connected ? `als ${st.tokens[role].login}` : 'Einrichtung unter /setup');
|
||||
}
|
||||
report(st.obs ? 'ok' : 'warn', 'OBS-Passwort', st.obs ? 'hinterlegt' : 'Einrichtung unter /setup');
|
||||
|
||||
console.log('');
|
||||
if (hardFailures > 0) {
|
||||
console.log(`${C.bad}${hardFailures} harte Fehler${C.off}, ${warnings} Warnungen. Noch nicht einsatzbereit.`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`${C.ok}Keine harten Fehler${C.off}, ${warnings} Warnungen.` +
|
||||
(warnings ? ' Warnungen ohne Einrichtung sind normal.' : ''));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fetch-model
|
||||
|
||||
async function fetchModel(name = 'ggml-small.bin'): Promise<void> {
|
||||
const url = MODELS[name];
|
||||
if (!url) {
|
||||
console.error(`Unbekanntes Modell "${name}". Bekannt: ${Object.keys(MODELS).join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(paths.models, { recursive: true });
|
||||
const target = path.join(paths.models, name);
|
||||
try {
|
||||
const st = await fs.stat(target);
|
||||
console.log(`${name} liegt schon da (${(st.size / 1024 / 1024).toFixed(0)} MB).`);
|
||||
return;
|
||||
} catch { /* laden */ }
|
||||
|
||||
console.log(`Lade ${name} ...`);
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok || !res.body) throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
|
||||
const total = Number(res.headers.get('content-length') || 0);
|
||||
let seen = 0;
|
||||
let lastPrint = 0;
|
||||
const tmp = `${target}.part`;
|
||||
|
||||
await pipeline(
|
||||
(async function* () {
|
||||
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
seen += chunk.length;
|
||||
const now = Date.now();
|
||||
if (now - lastPrint > 1000) {
|
||||
lastPrint = now;
|
||||
const pct = total ? ` (${((seen / total) * 100).toFixed(0)} %)` : '';
|
||||
process.stdout.write(`\r ${(seen / 1024 / 1024).toFixed(0)} MB${pct} `);
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
})(),
|
||||
createWriteStream(tmp)
|
||||
);
|
||||
await fs.rename(tmp, target);
|
||||
console.log(`\rFertig: ${target} (${(seen / 1024 / 1024).toFixed(0)} MB)`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- calibrate
|
||||
|
||||
async function calibrate(input?: string, profileName = 'default'): Promise<void> {
|
||||
const layout = loadLayout();
|
||||
const prof = layout.profiles?.[profileName];
|
||||
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
||||
|
||||
if (!input) {
|
||||
const files = (await fs.readdir(paths.replay).catch(() => [] as string[]))
|
||||
.filter((f) => /\.(mp4|mkv|mov|flv|png|jpe?g)$/i.test(f)).sort();
|
||||
const last = files[files.length - 1];
|
||||
if (!last) throw new Error(`Keine Datei angegeben und nichts in ${paths.replay} gefunden.`);
|
||||
input = path.join(paths.replay, last);
|
||||
console.log(`Neueste Datei aus ${paths.replay}: ${last}`);
|
||||
}
|
||||
|
||||
const outDir = path.join(paths.clips, '_calibration');
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const seek = /\.(png|jpe?g)$/i.test(input) ? [] : ['-ss', '5'];
|
||||
const quiet = ['-y', '-hide_banner', '-loglevel', 'error'];
|
||||
|
||||
const frame = path.join(outDir, 'frame.png');
|
||||
await runAsync('ffmpeg', [...quiet, ...seek, '-i', input, '-frames:v', '1', frame]);
|
||||
|
||||
const preview = path.join(outDir, `preview-${profileName}.png`);
|
||||
await runAsync('ffmpeg', [...quiet, '-i', frame, '-filter_complex', verticalFilter(prof),
|
||||
'-map', '[v]', '-frames:v', '1', preview]);
|
||||
|
||||
const marked = path.join(outDir, 'ausschnitte.png');
|
||||
const box = (r: Rect, color: string) => `drawbox=x=${r.x}:y=${r.y}:w=${r.w}:h=${r.h}:color=${color}:t=4`;
|
||||
await runAsync('ffmpeg', [...quiet, '-i', frame, '-vf',
|
||||
`${box(prof.cam.crop, 'yellow')},${box(prof.game.crop, 'cyan')}`, '-frames:v', '1', marked]);
|
||||
|
||||
console.log('\nGeschrieben:');
|
||||
console.log(` ${frame} Einzelbild aus der Quelle`);
|
||||
console.log(` ${marked} Ausschnitte markiert (gelb = Cam, cyan = Gameplay)`);
|
||||
console.log(` ${preview} fertige 9:16-Vorschau`);
|
||||
console.log('\nSitzt der Ausschnitt nicht, die Werte in config/layout.yaml anpassen');
|
||||
console.log('und den Befehl erneut ausfuehren.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- render
|
||||
|
||||
/**
|
||||
* Rendert eine Rohdatei nach 9:16. Dieselben Bausteine wie die Warteschlange
|
||||
* im Daemon, aber ohne Datenbank - gedacht zum Nachrendern, wenn sich das
|
||||
* Layout geaendert hat.
|
||||
*/
|
||||
async function renderOne(input?: string, profileName = 'default'): Promise<void> {
|
||||
if (!input) throw new Error('Datei angeben: render <datei> [profil]');
|
||||
const layout = loadLayout();
|
||||
const prof = layout.profiles?.[profileName];
|
||||
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
||||
const cfg = loadConfig();
|
||||
|
||||
const info = await probe(input);
|
||||
console.log(`Quelle: ${info.width}x${info.height}, ${info.durationSeconds.toFixed(1)} s, ` +
|
||||
`${info.audio.length} Tonspur(en)`);
|
||||
for (const [i, a] of info.audio.entries()) {
|
||||
console.log(` Spur ${i}: ${a.codec}, ${a.channels} Kanal/Kanaele` +
|
||||
(a.title ? ` - "${a.title}"` : ''));
|
||||
}
|
||||
if (info.width !== layout.source.width || info.height !== layout.source.height) {
|
||||
console.log(` Achtung: Layout ist fuer ${layout.source.width}x${layout.source.height} ` +
|
||||
'eingemessen, der Ausschnitt sitzt vermutlich falsch.');
|
||||
}
|
||||
|
||||
const outDir = path.join(paths.clips, '_manuell', new Date().toISOString().replace(/[:.]/g, '-'));
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const vertical = path.join(outDir, 'vertical.mp4');
|
||||
|
||||
const track = cfg.clipper.audio.outputTrack < info.audio.length
|
||||
? cfg.clipper.audio.outputTrack : 0;
|
||||
|
||||
const tail = Math.min(cfg.clipper.tailSeconds, Math.max(1, Math.floor(info.durationSeconds)));
|
||||
const started = Date.now();
|
||||
let lastShown = 0;
|
||||
await runMedia('ffmpeg', verticalArgs({
|
||||
input, output: vertical, profile: prof,
|
||||
tailSeconds: tail,
|
||||
audioTrack: track,
|
||||
encoder: cfg.clipper.ffmpeg.encoder,
|
||||
preset: cfg.clipper.ffmpeg.preset,
|
||||
crf: cfg.clipper.ffmpeg.crf,
|
||||
}), {
|
||||
niceness: cfg.clipper.ffmpeg.niceness,
|
||||
onProgress: (sec) => process.stdout.write(`
|
||||
${sec.toFixed(0)} s ... `),
|
||||
});
|
||||
await runMedia('ffmpeg', thumbnailArgs(vertical, path.join(outDir, 'thumb.jpg')));
|
||||
|
||||
// ---- Untertitel (S4)
|
||||
const model = path.join(paths.models, cfg.clipper.whisper.model);
|
||||
let finalFile = vertical;
|
||||
if (await fs.stat(model).then(() => true, () => false)) {
|
||||
const micTrack = cfg.clipper.audio.transcribeTrack < info.audio.length
|
||||
? cfg.clipper.audio.transcribeTrack : 0;
|
||||
if (micTrack !== cfg.clipper.audio.transcribeTrack) {
|
||||
console.log(` Hinweis: Tonspur ${cfg.clipper.audio.transcribeTrack} (Mikrofon) gibt es ` +
|
||||
`nicht - transkribiere Spur ${micTrack}. Bei Twitch-Clips ist das normal, ` +
|
||||
'die enthalten nur die Mischung.');
|
||||
}
|
||||
const wav = path.join(outDir, 'mic.wav');
|
||||
await runMedia('ffmpeg', micTrackArgs(input, wav, micTrack, tail));
|
||||
|
||||
console.log(' Transkribiere ...');
|
||||
const words = await transcribe(wav, {
|
||||
binary: process.env['WHISPER_BIN'] ?? 'whisper-cli',
|
||||
model,
|
||||
language: cfg.clipper.whisper.language,
|
||||
threads: cfg.clipper.whisper.threads,
|
||||
});
|
||||
const lines = toLines(words);
|
||||
const ass = path.join(outDir, 'subs.ass');
|
||||
await fs.writeFile(ass, buildAss(lines, { width: 1080, height: 1920 }));
|
||||
await fs.writeFile(path.join(outDir, 'subs.srt'), buildSrt(lines));
|
||||
await fs.writeFile(path.join(outDir, 'transcript.txt'),
|
||||
lines.map((l) => l.words.map((w) => w.text).join(' ')).join('\n'));
|
||||
|
||||
finalFile = path.join(outDir, 'final.mp4');
|
||||
await runMedia('ffmpeg', [
|
||||
'-y', '-hide_banner', '-loglevel', 'error', '-i', vertical,
|
||||
'-vf', `ass=${ass}`, '-c:a', 'copy',
|
||||
'-c:v', cfg.clipper.ffmpeg.encoder, '-preset', cfg.clipper.ffmpeg.preset,
|
||||
'-crf', String(cfg.clipper.ffmpeg.crf), '-pix_fmt', 'yuv420p',
|
||||
'-movflags', '+faststart', finalFile,
|
||||
], { niceness: cfg.clipper.ffmpeg.niceness });
|
||||
console.log(` ${words.length} Woerter, ${lines.length} Zeilen`);
|
||||
} else {
|
||||
console.log(` Kein Sprachmodell unter ${model} - ohne Untertitel. ` +
|
||||
'Holen mit: fetch-model');
|
||||
}
|
||||
|
||||
const out = await probe(finalFile);
|
||||
if (process.stdout.isTTY) process.stdout.write('\r');
|
||||
console.log(`\nFertig: ${finalFile}`);
|
||||
console.log(` ${out.width}x${out.height}, ${out.durationSeconds.toFixed(1)} s, ` +
|
||||
`Rechenzeit ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- help
|
||||
|
||||
function help(): void {
|
||||
console.log(`
|
||||
abdulive-toolkit - Container-Einstiegspunkt
|
||||
|
||||
ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite)
|
||||
check-env Umgebung pruefen
|
||||
db:migrate Migrationen anwenden
|
||||
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
||||
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
|
||||
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
|
||||
help Diese Uebersicht
|
||||
|
||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||
|
||||
Stand: Scheiben S0 bis S3 aus docs/07-mvp-plan.md. Untertitel (S4) und die
|
||||
Sichtungsseite mit Onboarding (S5) folgen.
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'ingest': {
|
||||
const { runIngest } = await import('../apps/ingest/src/index.ts');
|
||||
await runIngest();
|
||||
break;
|
||||
}
|
||||
case 'check-env': await checkEnv(); break;
|
||||
case 'db:migrate': openDb(); console.log('Migrationen sind aktuell.'); break;
|
||||
case 'fetch-model': await fetchModel(args[0]); break;
|
||||
case 'calibrate': await calibrate(args[0], args[1]); break;
|
||||
case 'render': await renderOne(args[0], args[1]); break;
|
||||
case 'help': case undefined: help(); break;
|
||||
default:
|
||||
console.error(`Unbekannter Befehl "${cmd}".`);
|
||||
help();
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\n${C.bad}Fehler:${C.off} ${(err as Error).message}`);
|
||||
const stderr = (err as { stderr?: string }).stderr;
|
||||
if (stderr) console.error(String(stderr).trim().split('\n').slice(-5).join('\n'));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -37,7 +37,8 @@ Einrichtungs-Assistenten unter `http://localhost:4792/setup`, **nicht** in einer
|
||||
| `bits:read` | Bits-Events | Broadcaster |
|
||||
|
||||
Der Autorisierungs-Flow läuft über den Einrichtungs-Assistenten:
|
||||
`docker compose up -d`, dann `http://localhost:4792/setup` öffnen und je einmal
|
||||
`Start-ScheduledTask -TaskName abdulive`, dann `http://localhost:4792/setup`
|
||||
öffnen und je einmal
|
||||
für Broadcaster- und Bot-Konto auf „Mit Twitch verbinden" klicken. Die
|
||||
Redirect-URI, die in beiden Twitch-Apps hinterlegt sein muss, lautet
|
||||
`http://localhost:4792/callback`.
|
||||
|
||||
@@ -22,6 +22,8 @@ und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
|
||||
- [ ] Community-Modul: Begrüßungslogik, Cooldowns, `!notiz`
|
||||
- [ ] Overlay-Seite `/overlay/community` (Chatter-Hinweis für Abdu)
|
||||
- [ ] `apps/broadcaster`: Discord + Bluesky, Story-Bild-Generator
|
||||
(Discord-Seite ist fertig: Server, Kanal und Webhook liegen bereit,
|
||||
siehe [`projects/discord-community.md`](projects/discord-community.md))
|
||||
- [ ] Draft-Modus, Kill-Switch, Doppelpost-Schutz
|
||||
|
||||
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
|
||||
|
||||
+97
-55
@@ -1,34 +1,40 @@
|
||||
# Setup
|
||||
|
||||
Alles läuft in Containern. Auf dem Rechner selbst wird nur Docker Desktop und
|
||||
OBS gebraucht — ffmpeg, whisper.cpp, Node und pnpm stecken im Image.
|
||||
Begründung: [ADR-0004](decisions/ADR-0004-docker.md).
|
||||
Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften
|
||||
sind mitgeliefert — vorausgesetzt wird nur OBS.
|
||||
Begruendung: [ADR-0007](decisions/ADR-0007-native-windows.md).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
| Was | Version | Wo |
|
||||
| Was | Version | Anmerkung |
|
||||
|---|---|---|
|
||||
| Docker Desktop | aktuell, WSL2-Backend | Host |
|
||||
| OBS Studio | 30+ mit obs-websocket v5 | Host |
|
||||
| Git | beliebig | Host |
|
||||
| Windows | 10 oder 11, 64 Bit | |
|
||||
| OBS Studio | 30+ mit obs-websocket v5 | derselbe Rechner |
|
||||
|
||||
Alles Weitere (Node 22, pnpm, ffmpeg mit libx264/libass, whisper.cpp,
|
||||
Sprachmodell) liefert das Image.
|
||||
Mehr nicht. Kein Docker, kein WSL2, kein Compiler, kein Git.
|
||||
|
||||
## WSL2 begrenzen
|
||||
## Installieren
|
||||
|
||||
Docker Desktop startet eine VM, die sich ohne Grenze so viel RAM nimmt, wie sie
|
||||
kriegen kann. Auf einem Stream-PC ist das nicht egal. `%USERPROFILE%\.wslconfig`
|
||||
anlegen:
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
memory=4GB
|
||||
processors=4
|
||||
swap=2GB
|
||||
```powershell
|
||||
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Danach `wsl --shutdown` und Docker Desktop neu starten.
|
||||
Alternativ das Paket von der Releases-Seite laden, entpacken und darin
|
||||
`install.ps1` ausfuehren - dasselbe Skript, es merkt selbst, dass das
|
||||
Paket schon danebenliegt.
|
||||
|
||||
Der Installer laeuft **ohne Administratorrechte**:
|
||||
|
||||
| Was | Wohin |
|
||||
|---|---|
|
||||
| Programm | `%LOCALAPPDATA%\Programs\abdulive` |
|
||||
| Daten, Datenbank, Clips | `%LOCALAPPDATA%\abdulive` |
|
||||
| Autostart | Aufgabe `abdulive`, bei der Anmeldung |
|
||||
| Update-Pruefung | Aufgabe `abdulive-update`, taeglich 05:00 |
|
||||
|
||||
Beim ersten Start warnt Windows SmartScreen vor einem unbekannten Herausgeber —
|
||||
das Paket ist nicht signiert. Einmal auf *Weitere Informationen* → *Trotzdem
|
||||
ausfuehren*.
|
||||
|
||||
## Twitch-Apps anlegen
|
||||
|
||||
@@ -63,61 +69,97 @@ im Einrichtungs-Assistenten einfügen (siehe unten). Begründung:
|
||||
|
||||
## Erste Einrichtung
|
||||
|
||||
```powershell
|
||||
Copy-Item config\config.example.yaml config\config.yaml
|
||||
Der Installer erledigt das schon: Sprachmodell laden, Selbsttest, Daemon
|
||||
starten, warten bis die Oberflaeche antwortet, Browser oeffnen.
|
||||
|
||||
docker compose build
|
||||
docker compose run --rm toolkit check-env # ffmpeg, whisper, Modell, Volumes, Layout
|
||||
docker compose run --rm toolkit fetch-model # Sprachmodell, ca. 465 MB
|
||||
docker compose up -d
|
||||
```
|
||||
Auf der Einrichtungsseite dann Client-ID und Secret der beiden Twitch-Apps
|
||||
eintragen und je einmal auf „Mit Twitch verbinden" klicken.
|
||||
|
||||
Dann **http://localhost:4792/setup** öffnen. Dort werden nacheinander
|
||||
eingetragen:
|
||||
|
||||
1. Client-ID und Client-Secret der beiden Twitch-Apps
|
||||
2. „Mit Twitch verbinden" für Broadcaster- und Bot-Konto (OAuth)
|
||||
3. obs-websocket-Passwort
|
||||
4. später: Discord-Webhook, Bluesky-Zugang
|
||||
|
||||
Alles landet verschlüsselt im Docker-Volume, nicht in einer Datei im Projekt.
|
||||
|
||||
> **Diese Seite zeigt Zugangsdaten. Nicht öffnen, während gesendet wird.**
|
||||
|
||||
Eine `.env` ist optional und enthält nur noch den Pfad zum Wiedergabepuffer
|
||||
und das Log-Level (Vorlage: `.env.example`).
|
||||
|
||||
## Facecam einmessen
|
||||
|
||||
Einmalig, sobald eine Replay-Datei vorliegt:
|
||||
Von Hand geht es auch:
|
||||
|
||||
```powershell
|
||||
docker compose run --rm toolkit calibrate
|
||||
abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout
|
||||
abdulive fetch-model # Sprachmodell, ca. 465 MB
|
||||
Start-ScheduledTask -TaskName abdulive
|
||||
```
|
||||
|
||||
Legt einen Frame als PNG in `data/clips/_calibration/` ab, fragt die vier
|
||||
Crop-Werte ab und schreibt sie nach `config/layout.yaml`.
|
||||
## Layout einmessen
|
||||
|
||||
```powershell
|
||||
abdulive calibrate
|
||||
```
|
||||
|
||||
Legt einen Frame als PNG unter `%LOCALAPPDATA%\abdulive\clips\_calibration`
|
||||
ab und rendert die 9:16-Vorschau dazu. Anleitung: [`obs-layout.md`](obs-layout.md).
|
||||
|
||||
Welche OBS-Quelle im Ausschnitt landet — etwa fremde Untertitel:
|
||||
|
||||
```powershell
|
||||
abdulive sources
|
||||
```
|
||||
|
||||
## Betrieb
|
||||
|
||||
```powershell
|
||||
docker compose up -d # startet
|
||||
docker compose logs -f # Logs
|
||||
docker compose down # stoppt
|
||||
Start-ScheduledTask -TaskName abdulive # startet
|
||||
Stop-ScheduledTask -TaskName abdulive # stoppt
|
||||
Get-Content "$env:LOCALAPPDATA\abdulive\logs\abdulive.log" -Wait -Tail 40
|
||||
```
|
||||
|
||||
Sichtungsseite: http://localhost:4792
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
```powershell
|
||||
abdulive update
|
||||
```
|
||||
|
||||
Prueft das Manifest, laedt das Paket, vergleicht die SHA256 und legt es
|
||||
bereit. Die Adresse steht in `config.yaml` unter `update.url` und hat
|
||||
**keine Vorgabe** — ohne Eintrag sagt der Befehl das auch. Liegt die Ablage
|
||||
hinter einer Anmeldung, gehoert ein Lese-Token in den verschluesselten
|
||||
Speicher (Einrichtungsseite), nicht in die `config.yaml`. **Eingespielt wird es beim naechsten Start** — waehrend eines Streams
|
||||
wird nichts ausgetauscht. Sofort uebernehmen:
|
||||
|
||||
```powershell
|
||||
Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive
|
||||
```
|
||||
|
||||
Die Aufgabe `abdulive-update` macht die Pruefung taeglich um 05:00 von selbst.
|
||||
|
||||
## Wieder entfernen
|
||||
|
||||
```powershell
|
||||
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1"
|
||||
```
|
||||
|
||||
Nimmt die beiden Aufgaben aus dem Aufgabenplaner, beendet den laufenden
|
||||
Daemon und loescht den Programmordner. **Die Daten bleiben liegen** — in der
|
||||
Datenbank stehen Chatverlaeufe, und die sind nach einer versehentlichen
|
||||
Deinstallation nicht wiederherstellbar. Wer neu installiert, findet seine
|
||||
Einrichtung wieder.
|
||||
|
||||
Wirklich alles weg, auch Datenbank, Clips und Sprachmodell:
|
||||
|
||||
```powershell
|
||||
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1" -AuchDaten
|
||||
```
|
||||
|
||||
## Backup
|
||||
|
||||
```powershell
|
||||
.\scripts\backup.ps1
|
||||
```
|
||||
|
||||
Kopiert die Datenbank per `VACUUM INTO` aus dem Volume `abdulive-db` nach
|
||||
`data/db/backup/` und hält 30 Generationen. Die DB liegt bewusst in einem
|
||||
Docker-Volume und nicht in einem Windows-Ordner — Dateisperren über die
|
||||
WSL2-Brücke sind für SQLite nicht verlässlich.
|
||||
Kopiert die Datenbank per `VACUUM INTO` aus `%LOCALAPPDATA%\abdulive\db`
|
||||
nach `backup\` und haelt 30 Generationen. `VACUUM INTO` und nicht einfach
|
||||
kopieren: die Datei wird im laufenden Betrieb gesichert, und ein Kopieren
|
||||
waehrend eines Schreibvorgangs ergaebe eine unbrauchbare Sicherung.
|
||||
|
||||
Die Datenbank liegt seit [ADR-0007](decisions/ADR-0007-native-windows.md)
|
||||
in einem gewoehnlichen Windows-Ordner. Der Grund fuer das fruehere
|
||||
Docker-Volume - unzuverlaessige Dateisperren ueber die WSL2-Bruecke - ist
|
||||
mit dem Container weggefallen.
|
||||
|
||||
## Was nicht ins Repo gehört
|
||||
|
||||
|
||||
+5
-5
@@ -113,10 +113,10 @@ Scheibe gibt es etwas, das man anschauen kann.
|
||||
### S0 — Container und Toolchain · ~1 Tag
|
||||
|
||||
`docker/Dockerfile` (Node 22, ffmpeg mit libx264 + libass, whisper.cpp),
|
||||
`docker-compose.yml` mit Volumes und Ports, Modell-Download beim ersten Start
|
||||
Paketbau, Installer und Updater, Modell-Download bei der Einrichtung
|
||||
in ein eigenes Volume, `scripts/check-env` als Selbsttest im Container.
|
||||
|
||||
**Fertig, wenn:** `docker compose run --rm toolkit check-env` meldet ffmpeg,
|
||||
**Fertig, wenn:** `abdulive check-env` meldet ffmpeg,
|
||||
whisper.cpp, Modell und die Schreibrechte auf allen Volumes als OK.
|
||||
|
||||
### S1 — Einrichtung, Auth und Datenaufnahme · ~3 Tage
|
||||
@@ -184,7 +184,7 @@ kopiert und dort verarbeitet — genau das, was [ADR-0004](decisions/ADR-0004-do
|
||||
Punkt 2 verbietet. Jetzt liegt die Rohdatei im Arbeits-Volume, und auf die
|
||||
Windows-Seite kommt nur das fertige, kleine Ergebnis.
|
||||
|
||||
Neu dazu: `docker compose run --rm toolkit render <datei>` rendert eine Rohdatei
|
||||
Neu dazu: `abdulive render <datei>` rendert eine Rohdatei
|
||||
von Hand — gedacht zum Nachrendern, wenn sich das Layout ändert.
|
||||
|
||||
### S4 — Untertitel · ~1,5 Tage
|
||||
@@ -301,11 +301,11 @@ Ein echter Stream, ohne Eingriff von außen:
|
||||
| whisper.cpp versteht Spielgeräusche statt Sprache | ~~mittel~~ **gelöst** | OBS nimmt getrennte Spuren auf; transkribiert wird nur das Mikrofon (Spur 2). |
|
||||
| Twitch ändert die Ableitung der Clip-MP4-URL | niedrig | Betrifft nur den Zweitweg; die Rohdatei kommt aus OBS. Gekapselt in einer Datei. |
|
||||
| Replay-Buffer-Pfad-Abbildung Windows ↔ Container | mittel | In S2 als Erstes lösen, mit einem Testlauf vor allem anderen |
|
||||
| Docker Desktop belastet den Stream-PC | mittel | `.wslconfig` mit Speicher- und CPU-Grenze; wenn es stört, ist ein Betrieb ohne Container jederzeit möglich, weil nur ffmpeg und whisper.cpp fehlen |
|
||||
| ~~Docker Desktop belastet den Stream-PC~~ | — | **eingetreten und geloest**: seit [ADR-0007](decisions/ADR-0007-native-windows.md) laeuft alles nativ, die WSL2-VM und ihre 1-2 GB entfallen |
|
||||
|
||||
## Was Abdu vorher tun muss
|
||||
|
||||
1. Docker Desktop installieren, WSL2-Backend aktivieren
|
||||
1. `install.ps1` ausfuehren (kein Docker, keine Administratorrechte)
|
||||
2. Zwei Twitch-Apps anlegen, Redirect-URI `http://localhost:4792/callback`
|
||||
(siehe [`05-setup.md`](05-setup.md)). Client-ID und Secret **nicht**
|
||||
irgendwo eintragen — die kommen in den Einrichtungs-Assistenten.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# ADR-0004: Betrieb in Docker
|
||||
|
||||
**Status:** akzeptiert · **Datum:** 2026-09-09
|
||||
**Status:** ~~akzeptiert~~ **ersetzt durch [ADR-0007](ADR-0007-native-windows.md)** · **Datum:** 2026-09-09
|
||||
|
||||
> Diese Entscheidung gilt nicht mehr. Das Toolkit laeuft nativ unter Windows.
|
||||
> Die Analyse unten bleibt lesenswert - die vier Fallen, die sie beschreibt,
|
||||
> waren real, und zwei davon (Pfaduebersetzung, Kopieren ueber die
|
||||
> WSL2-Bruecke) sind mit dem Container weggefallen statt geloest worden zu
|
||||
> sein. Warum gewechselt wurde, steht in ADR-0007.
|
||||
|
||||
## Kontext
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# ADR-0007: Native Windows-Anwendung statt Container
|
||||
|
||||
**Status:** akzeptiert · **Datum:** 2026-09-09 · **ersetzt:** [ADR-0004](ADR-0004-docker.md)
|
||||
|
||||
## Kontext
|
||||
|
||||
ADR-0004 hat den Betrieb in Docker festgelegt und dabei zwei Dinge versprochen:
|
||||
„Die einzige Voraussetzung auf dem Stream-PC ist Docker Desktop und OBS" und
|
||||
„Updates sind ein `docker compose pull && up -d`".
|
||||
|
||||
Das zweite Versprechen wurde nie eingelöst. Es gab keine CI, kein
|
||||
veröffentlichtes Image, und `image: abdulive/toolkit:dev` zeigte auf nichts.
|
||||
Wer das Toolkit installieren wollte, musste `docker compose build` ausführen —
|
||||
und damit **whisper.cpp aus dem Quelltext übersetzen**, gut zehn Minuten, mit
|
||||
allen Fehlermöglichkeiten eines Compilers. Für Abdu ist das nicht zumutbar.
|
||||
|
||||
Beim Nachdenken über die Reparatur fiel auf, dass die Voraussetzung selbst
|
||||
teurer ist als der Nutzen. Die Kosten stehen ehrlich in ADR-0004: die WSL2-VM
|
||||
belegt im Leerlauf ein bis zwei GB RAM, und Intel QSV ist aus WSL2 heraus nicht
|
||||
verlässlich erreichbar, also bleibt CPU-Encoding — beides auf einem Rechner,
|
||||
der gleichzeitig streamt.
|
||||
|
||||
Dem steht ein Umstand gegenüber, der die ursprüngliche Begründung untergräbt:
|
||||
**Das Projekt hat keine npm-Abhängigkeiten**, und Node führt TypeScript direkt
|
||||
aus (ADR-0001). Es gibt nichts zu bauen. Der Container schrieb also nur noch
|
||||
drei Programme fest — Node, ffmpeg, whisper.cpp — und dafür braucht es keine
|
||||
Virtualisierung, sondern einen Ordner.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Das Toolkit wird als **native Windows-Anwendung** ausgeliefert. Ein Ordner mit
|
||||
allem darin, ein PowerShell-Installer, ein Updater. Kein Docker, kein WSL2.
|
||||
|
||||
```
|
||||
abdulive/
|
||||
node.exe portable Node-Laufzeit
|
||||
bin/ ffmpeg, ffprobe, whisper-cli und ihre DLLs
|
||||
fonts/ DejaVu Sans (im Repo mitgeliefert, siehe unten)
|
||||
app/ die TypeScript-Quellen, unverändert
|
||||
abdulive.cmd Kommandozeile
|
||||
launcher.ps1 vom Aufgabenplaner gestartet
|
||||
version.json
|
||||
```
|
||||
|
||||
Gebaut wird mit `node scripts/build-package.ts` — lädt die drei Bausteine bei
|
||||
ihren Herausgebern, packt alles zusammen, schreibt Prüfsummen. Das läuft auch
|
||||
in der CI unter Linux.
|
||||
|
||||
## Begründung
|
||||
|
||||
- **Abdu baut nichts.** Herunterladen, `install.ps1`, fertig. Kein Docker
|
||||
Desktop, kein `.wslconfig`, kein Compiler.
|
||||
- **Ein bis zwei GB RAM** stehen wieder dem Stream zur Verfügung.
|
||||
- **Hardware-Encoding wird erreichbar.** Der mitgelieferte ffmpeg ist mit
|
||||
`--enable-libvpl`, `--enable-amf` und `--enable-ffnvcodec` gebaut; QSV und
|
||||
AMF sind nativ ansprechbar. Genutzt wird das noch nicht — aber es ist jetzt
|
||||
möglich, und aus WSL2 heraus war es das nicht.
|
||||
- **Die Pfadübersetzung entfällt.** `mapReplayPath` musste OBS' Windows-Pfade
|
||||
auf `/replay` abbilden; laut Übergabe die Stelle, an der die Clip-Pipeline
|
||||
am ehesten scheitert. Nativ sieht die Anwendung denselben Pfad wie OBS.
|
||||
- **Kein Kopieren über die WSL2-Brücke mehr.** ADR-0004 Punkt 2 verbot das
|
||||
Verarbeiten auf dem Bind-Mount, weil es „ein Vielfaches der Zeit" kostet.
|
||||
Diese Klasse von Fehlern gibt es nicht mehr.
|
||||
|
||||
## Konsequenzen — was dabei zu beachten ist
|
||||
|
||||
**1. Programme werden neben der Anwendung gesucht, nicht im PATH.**
|
||||
`packages/core/src/platform.ts` löst auf: erst die Umgebungsvariable
|
||||
(`FFMPEG_BIN` …), dann `bin/`, dann der PATH. Die Umgebungsvariable steht
|
||||
bewusst vorn — eine kaputte Mitlieferung lässt sich so ohne Neuinstallation
|
||||
überschreiben.
|
||||
|
||||
**2. Schriften müssen mitgeliefert werden.**
|
||||
Im Container kam DejaVu Sans aus `fonts-dejavu-core` und libass fand sie über
|
||||
fontconfig. Windows hat DejaVu nicht. Fehlt der Ordner, setzt libass
|
||||
**wortlos** eine Ersatzschrift ein — gemessen: dieselbe Zeile wird 720 statt
|
||||
826 Pixel breit, ohne jede Fehlermeldung. Deshalb liegen die Schriftdateien
|
||||
unter `assets/fonts/` im Repo, und `assFilter` setzt `fontsdir`.
|
||||
|
||||
**3. Windows-Pfade im ffmpeg-Filtergraphen brauchen drei Schritte.**
|
||||
`C:\Users\…` enthält einen Doppelpunkt (trennt Filteroptionen) und
|
||||
Rückwärtsschrägstriche (ffmpeg-Escapes). An echtem ffmpeg 8.1 durchprobiert:
|
||||
|
||||
| Form | Ergebnis |
|
||||
|---|---|
|
||||
| `ass=filename=C:/x/s.ass` | `No option name near '/x/s.ass'` |
|
||||
| `ass=filename=C\:/x/s.ass` | ebenso |
|
||||
| `ass=filename='C:/x/s.ass'` | ebenso |
|
||||
| `ass=filename='C\:/x/s.ass'` | läuft |
|
||||
|
||||
Anführungszeichen allein schützen den Doppelpunkt nicht, das Escape allein
|
||||
auch nicht. `filterPath()` macht alle drei Schritte, mit Tests.
|
||||
|
||||
**4. `nice` gibt es unter Windows nicht.**
|
||||
Ersetzt durch `os.setPriority` auf die Kind-PID. Das kann beides, braucht
|
||||
keinen Hilfsprozess davor, und das Zurückstufen darf jeder Benutzer.
|
||||
|
||||
**5. Daten liegen unter `%LOCALAPPDATA%\abdulive`, nicht neben dem Programm.**
|
||||
Ein Update tauscht den Programmordner komplett aus; die Datenbank darf dabei
|
||||
nicht mitgehen. `LOCALAPPDATA` und nicht `ProgramData`, weil die Anwendung als
|
||||
angemeldeter Benutzer läuft — dieselbe Sitzung wie OBS. Damit braucht weder
|
||||
Installation noch Update Administratorrechte. Genau daran scheitern
|
||||
Aktualisierungen sonst auf einem Rechner, an dem gerade gestreamt wird.
|
||||
|
||||
**6. Autostart über den Aufgabenplaner, nicht als Windows-Dienst.**
|
||||
Ein Dienst liefe unter SYSTEM in einer anderen Sitzung als OBS, bräuchte
|
||||
Administratorrechte und ein zusätzliches Wrapper-Programm (nssm, WinSW). Die
|
||||
Aufgabe `abdulive` startet bei der Anmeldung, versteckt, mit Neustart nach
|
||||
zwei Minuten bei Absturz.
|
||||
|
||||
**7. Updates werden bereitgelegt, nicht sofort eingespielt.**
|
||||
Windows sperrt die Dateien laufender Programme — `node.exe` lässt sich nicht
|
||||
ersetzen, solange es läuft. `abdulive update` prüft das Manifest, lädt das
|
||||
Paket, **prüft die SHA256** und legt es unter `pending/` ab. `launcher.ps1`
|
||||
tauscht beim nächsten Start aus, bevor irgendetwas startet. Damit gibt es nie
|
||||
einen halb ausgetauschten Ordner, und mitten im Stream wird nichts angefasst.
|
||||
Schlägt der Tausch fehl, läuft die alte Fassung weiter.
|
||||
|
||||
**Kosten, ehrlich benannt:**
|
||||
|
||||
- **Die Umgebung ist nicht mehr festgeschrieben.** Der Container garantierte
|
||||
„kein läuft bei mir". Jetzt garantiert das nur noch der Paketinhalt — Node,
|
||||
ffmpeg und whisper sind mitgeliefert, aber Windows selbst ist es nicht.
|
||||
- **Kein Code-Signing.** SmartScreen warnt beim ersten Start vor einem
|
||||
unbekannten Herausgeber. Ein Zertifikat kostet Geld; bis dahin muss man
|
||||
einmal auf „Trotzdem ausführen" klicken. Steht in `offene-punkte.md`.
|
||||
- **Windows-only.** Ein Linux-Paket gibt es nicht mehr. Für ein Werkzeug, das
|
||||
neben OBS auf einem Windows-Stream-PC läuft, ist das kein Verlust — die
|
||||
Pfade in `platform.ts` kennen den Linux-Fall aber weiterhin, und die Tests
|
||||
laufen dort.
|
||||
- **Drei Bausteine wollen gepflegt werden.** Node, ffmpeg und whisper.cpp
|
||||
aktualisieren sich nicht von selbst. `build-package.ts` holt jeweils die
|
||||
aktuelle Ausgabe und schreibt Name und Prüfsumme nach `version.json` — was
|
||||
drin ist, lässt sich also nachlesen.
|
||||
@@ -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.
|
||||
@@ -5,9 +5,11 @@
|
||||
| [0001](ADR-0001-monorepo-typescript.md) | TypeScript-Monorepo statt gemischter Sprachen | akzeptiert |
|
||||
| [0002](ADR-0002-sqlite.md) | SQLite statt Postgres | akzeptiert |
|
||||
| [0003](ADR-0003-eventsub-websocket.md) | EventSub ueber WebSocket, Chat ueber IRC | akzeptiert |
|
||||
| [0004](ADR-0004-docker.md) | Betrieb in Docker, whisper.cpp statt Python | akzeptiert |
|
||||
| [0004](ADR-0004-docker.md) | Betrieb in Docker, whisper.cpp statt Python | ersetzt durch 0007 |
|
||||
| [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-<nummer>-<kurzname>.md`, Aufbau Kontext /
|
||||
Entscheidung / Begruendung / Konsequenzen.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Baut das Handbuch nach handbuch.pdf.
|
||||
#
|
||||
# Braucht MiKTeX oder TeX Live mit xelatex. Zweimal, damit das
|
||||
# Inhaltsverzeichnis stimmt.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
foreach ($lauf in 1..2) {
|
||||
xelatex -interaction=nonstopmode handbuch.tex | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "xelatex ist in Durchlauf $lauf gescheitert. Siehe handbuch.log" }
|
||||
}
|
||||
|
||||
Remove-Item handbuch.aux, handbuch.log, handbuch.out, handbuch.toc -ErrorAction SilentlyContinue
|
||||
Write-Host "handbuch.pdf gebaut ($([math]::Round((Get-Item handbuch.pdf).Length/1KB)) KB)"
|
||||
Binary file not shown.
@@ -0,0 +1,449 @@
|
||||
\documentclass[11pt,a4paper]{article}
|
||||
|
||||
\usepackage[ngerman]{babel}
|
||||
\usepackage{fontspec}
|
||||
\usepackage[a4paper,margin=2.4cm,headsep=14pt]{geometry}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{tabularx}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{fancyvrb}
|
||||
\usepackage{mdframed}
|
||||
\usepackage[hidelinks]{hyperref}
|
||||
|
||||
\setmainfont{Segoe UI}
|
||||
\setmonofont{Consolas}[Scale=0.92]
|
||||
|
||||
\definecolor{lila}{HTML}{6B3FA0}
|
||||
\definecolor{grau}{HTML}{555555}
|
||||
\definecolor{hellgrau}{HTML}{F2F2F4}
|
||||
\definecolor{rahmen}{HTML}{D8D8DE}
|
||||
|
||||
\titleformat{\section}{\Large\bfseries\color{lila}}{\thesection}{0.7em}{}
|
||||
\titleformat{\subsection}{\large\bfseries}{\thesubsection}{0.6em}{}
|
||||
\titlespacing*{\section}{0pt}{20pt}{8pt}
|
||||
\titlespacing*{\subsection}{0pt}{14pt}{5pt}
|
||||
|
||||
\pagestyle{fancy}
|
||||
\fancyhf{}
|
||||
\fancyhead[L]{\small\color{grau}abdulive-toolkit}
|
||||
\fancyhead[R]{\small\color{grau}\leftmark}
|
||||
\fancyfoot[C]{\small\color{grau}\thepage}
|
||||
\renewcommand{\headrulewidth}{0.4pt}
|
||||
\renewcommand{\headrule}{\color{rahmen}\hrule height 0.4pt}
|
||||
\renewcommand{\sectionmark}[1]{\markboth{#1}{}}
|
||||
|
||||
% Keine Einrueckung, dafuer Abstand zwischen den Absaetzen. Sonst wirkt jeder
|
||||
% Absatz nach einer Liste oder Tabelle wie ein neuer Einzug.
|
||||
\setlength{\parindent}{0pt}
|
||||
\setlength{\parskip}{7pt}
|
||||
\renewcommand{\arraystretch}{1.15}
|
||||
|
||||
\setlist[itemize]{leftmargin=1.3em,itemsep=2pt,topsep=4pt,parsep=0pt}
|
||||
\setlist[enumerate]{leftmargin=1.6em,itemsep=3pt,topsep=4pt,parsep=0pt}
|
||||
|
||||
% Ohne commandchars: Backslashes in Windows-Pfaden bleiben woertlich. Mit
|
||||
% commandchars wuerde aus \textbackslash ein Backslash plus Leerzeichen,
|
||||
% und ein kopierter Pfad waere kaputt.
|
||||
\DefineVerbatimEnvironment{code}{Verbatim}
|
||||
{fontsize=\small,xleftmargin=8pt,frame=leftline,framerule=1.6pt,
|
||||
rulecolor=\color{rahmen},framesep=8pt}
|
||||
|
||||
% Nur fuer Bloecke, die eine Einfaerbung brauchen.
|
||||
\DefineVerbatimEnvironment{codef}{Verbatim}
|
||||
{fontsize=\small,xleftmargin=8pt,frame=leftline,framerule=1.6pt,
|
||||
rulecolor=\color{rahmen},framesep=8pt,commandchars=\\\{\}}
|
||||
|
||||
\newmdenv[backgroundcolor=hellgrau,linecolor=rahmen,linewidth=0.6pt,
|
||||
innertopmargin=8pt,innerbottommargin=8pt,innerleftmargin=10pt,
|
||||
innerrightmargin=10pt,skipabove=10pt,skipbelow=10pt,roundcorner=2pt]{kasten}
|
||||
|
||||
\newcommand{\pfad}[1]{\texttt{\small #1}}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\thispagestyle{empty}
|
||||
\vspace*{2.5cm}
|
||||
{\Huge\bfseries\color{lila} abdulive-toolkit}
|
||||
|
||||
\vspace{6pt}
|
||||
{\Large Handbuch}
|
||||
|
||||
\vspace{28pt}
|
||||
{\large Aus einem Zuruf im Chat wird ein fertiges Hochformat-Video.}
|
||||
|
||||
\vspace{40pt}
|
||||
\begin{tabularx}{\textwidth}{@{}lX@{}}
|
||||
\toprule
|
||||
Version & 0.5.4 \\
|
||||
Stand & 9. September 2026 \\
|
||||
Gilt für & Windows 10 und 11, 64 Bit \\
|
||||
Quelltext & \url{https://git.zeasy.dev/ZeasyAlex/abdulive} \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
\vfill
|
||||
\noindent\small\color{grau}
|
||||
Das Toolkit speichert Chatverläufe. Die Datenbank enthält damit
|
||||
personenbezogene Daten und gehört weder ins Repository noch in eine
|
||||
Cloud ohne Absprache.
|
||||
\newpage
|
||||
|
||||
\tableofcontents
|
||||
\newpage
|
||||
|
||||
\section{Was das Werkzeug tut}
|
||||
|
||||
Jemand tippt \texttt{!clip} in den Chat. Etwa eine Minute später liegt ein
|
||||
fertiges 9:16-Video mit Untertiteln auf der Platte, bereit für TikTok, Reels
|
||||
oder Shorts.
|
||||
|
||||
Dazwischen passiert Folgendes:
|
||||
|
||||
\begin{enumerate}
|
||||
\item Das Toolkit liest den Chat mit und erkennt den Befehl.
|
||||
\item Es weist OBS an, den Wiedergabepuffer zu speichern. OBS hat die
|
||||
letzten 90 Sekunden ohnehin im Speicher, der Moment ist also schon
|
||||
vorbei und trotzdem noch da.
|
||||
\item OBS meldet den Dateipfad zurück. Das Toolkit wartet, bis die Datei
|
||||
fertig geschrieben ist.
|
||||
\item Aus den letzten 45 Sekunden wird ein Hochformat gebaut: Facecam oben,
|
||||
Gameplay unten, beides ohne Verzerrung zugeschnitten.
|
||||
\item Die Mikrofonspur wird transkribiert und als Untertitel ins Bild
|
||||
gebrannt.
|
||||
\end{enumerate}
|
||||
|
||||
\begin{kasten}
|
||||
\textbf{Während des Streams wird nicht gerendert.} Das Encodieren nimmt
|
||||
Rechenzeit, die der Stream braucht. Die Warteschlange arbeitet erst nach
|
||||
Streamende. Wer das anders will, setzt \texttt{renderWhileLive: true}.
|
||||
\end{kasten}
|
||||
|
||||
\subsection{Was dabei herauskommt}
|
||||
|
||||
Je Clip entsteht ein Ordner mit:
|
||||
|
||||
\begin{tabularx}{\textwidth}{@{}lX@{}}
|
||||
\toprule
|
||||
\pfad{final.mp4} & das fertige Video mit eingebrannten Untertiteln \\
|
||||
\pfad{vertical.mp4} & dasselbe ohne Untertitel \\
|
||||
\pfad{thumb.jpg} & Vorschaubild \\
|
||||
\pfad{subs.srt} & Untertitel als Textdatei, praktisch für die Beschreibung \\
|
||||
\pfad{transcript.txt} & reiner Text des Gesagten \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
\subsection{Was es noch nicht tut}
|
||||
|
||||
Es lädt nichts hoch. Es entscheidet nicht, welcher Clip gut ist. Die
|
||||
Sichtungsseite, auf der man Clips durchgeht und aussortiert, ist noch nicht
|
||||
gebaut.
|
||||
|
||||
\section{Installation}
|
||||
|
||||
Vorausgesetzt wird Windows 10 oder 11 in 64 Bit und OBS Studio 30 oder neuer.
|
||||
Sonst nichts. Node, ffmpeg, whisper.cpp und die Schriften sind im Paket
|
||||
enthalten.
|
||||
|
||||
In PowerShell:
|
||||
|
||||
\begin{code}
|
||||
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
|
||||
\end{code}
|
||||
|
||||
Das Skript lädt das Paket, prüft die Prüfsumme, installiert, holt das
|
||||
Sprachmodell (etwa 465 MB), startet den Dienst und öffnet die
|
||||
Einrichtungsseite im Browser.
|
||||
|
||||
Es braucht keine Administratorrechte. Beim ersten Start warnt Windows
|
||||
SmartScreen vor einem unbekannten Herausgeber, weil das Paket nicht signiert
|
||||
ist. Einmal auf \emph{Weitere Informationen}, dann \emph{Trotzdem ausführen}.
|
||||
|
||||
\subsection{Wohin es installiert}
|
||||
|
||||
\begin{tabularx}{\textwidth}{@{}lX@{}}
|
||||
\toprule
|
||||
Programm & \pfad{\%LOCALAPPDATA\%\textbackslash Programs\textbackslash abdulive} \\
|
||||
Daten & \pfad{\%LOCALAPPDATA\%\textbackslash abdulive} \\
|
||||
Autostart & Aufgabe \texttt{abdulive}, bei jeder Anmeldung \\
|
||||
Updates & Aufgabe \texttt{abdulive-update}, täglich 5:00 Uhr \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
Programm und Daten liegen getrennt. Ein Update tauscht den Programmordner
|
||||
komplett aus, die Datenbank bleibt davon unberührt.
|
||||
|
||||
\section{Einrichtung}
|
||||
|
||||
Die Einrichtungsseite liegt unter \url{http://localhost:4792/setup} und ist
|
||||
nur vom eigenen Rechner erreichbar. Sie zeigt Zugangsdaten im Klartext, also
|
||||
nicht während des Streams öffnen.
|
||||
|
||||
\subsection{Schritt 1: Zwei Twitch-Anwendungen anlegen}
|
||||
|
||||
Unter \url{https://dev.twitch.tv/console/apps} zwei Anwendungen erstellen. Bei
|
||||
beiden als OAuth-Redirect-URL exakt eintragen:
|
||||
|
||||
\begin{code}
|
||||
http://localhost:4792/callback
|
||||
\end{code}
|
||||
|
||||
Die Anwendungen hängen nicht am Kanal. Sie können auch unter einem anderen
|
||||
Konto angelegt werden.
|
||||
|
||||
\subsection{Schritt 2: Broadcaster-Konto verbinden}
|
||||
|
||||
Client-ID und Secret der ersten Anwendung auf der Einrichtungsseite eintragen,
|
||||
speichern, dann auf \emph{Mit Twitch verbinden} klicken.
|
||||
|
||||
\begin{kasten}
|
||||
\textbf{Das kann nur der Streamer selbst.} Twitch bindet die Berechtigungen an
|
||||
das Konto, das sich anmeldet. Es gibt keine Rolle, mit der jemand anderes das
|
||||
übernehmen kann. Moderator und Redakteur helfen hier nicht.
|
||||
\end{kasten}
|
||||
|
||||
Damit bekommt das Toolkit Zugriff auf Clips, Titel und Kategorie, Abonnenten,
|
||||
Follower, Raids, Kanalpunkte und Bits.
|
||||
|
||||
\subsection{Schritt 3: Bot-Konto verbinden}
|
||||
|
||||
Dasselbe mit der zweiten Anwendung, angemeldet als Bot-Konto. Das ist ein
|
||||
eigenes, kostenloses Twitch-Konto, damit Antworten im Chat als Bot erkennbar
|
||||
sind.
|
||||
|
||||
Ohne dieses Konto liest das Toolkit den Chat trotzdem mit. Es kann nur nicht
|
||||
antworten.
|
||||
|
||||
\begin{kasten}
|
||||
\textbf{Das Bot-Konto sollte Moderator im Kanal sein.} Der Streamer tippt
|
||||
dafür einmal \texttt{/mod botname} in seinen Chat. Ohne Moderatorrechte gilt
|
||||
ein Limit von 20 Nachrichten je 30 Sekunden statt 100, und im
|
||||
Follower-only-Modus kommt der Bot gar nicht durch.
|
||||
\end{kasten}
|
||||
|
||||
\subsection{Schritt 4: OBS-Passwort}
|
||||
|
||||
In OBS unter \emph{Werkzeuge} $\rightarrow$ \emph{WebSocket-Servereinstellungen}
|
||||
den Server aktivieren, Port 4455. Das dort angezeigte Passwort auf der
|
||||
Einrichtungsseite eintragen.
|
||||
|
||||
\subsection{Fertig?}
|
||||
|
||||
Unten auf der Seite steht \texttt{bereit} oder \texttt{unvollständig}.
|
||||
Solange das Broadcaster-Konto fehlt, läuft das Toolkit im Lesemodus: Chat
|
||||
wird mitgeschrieben, Zuschauerzahlen und Clips bleiben aus.
|
||||
|
||||
\section{OBS einrichten}
|
||||
|
||||
Ohne diese vier Punkte entstehen keine Clips oder sie sitzen schief.
|
||||
|
||||
\subsection{Wiedergabepuffer}
|
||||
|
||||
\emph{Einstellungen} $\rightarrow$ \emph{Ausgabe} $\rightarrow$
|
||||
\emph{Wiedergabepuffer} aktivieren, 90 Sekunden.
|
||||
|
||||
Unter \emph{Einstellungen} $\rightarrow$ \emph{Hotkeys} eine Taste auf
|
||||
\emph{Wiedergabepuffer speichern} legen. Das ist der Clip-Knopf für den
|
||||
Streamer selbst. Ein eigener Windows-Hotkey wird bewusst nicht gebaut: OBS
|
||||
meldet den gespeicherten Puffer ohnehin über WebSocket, das ist derselbe
|
||||
Auslöser.
|
||||
|
||||
\subsection{Getrennte Tonspuren}
|
||||
|
||||
\emph{Einstellungen} $\rightarrow$ \emph{Ausgabe} $\rightarrow$
|
||||
\emph{Aufnahme} $\rightarrow$ \emph{Audiospuren}, und im Audiomixer je Quelle
|
||||
die Zuordnung prüfen:
|
||||
|
||||
\begin{tabularx}{\textwidth}{@{}lX@{}}
|
||||
\toprule
|
||||
Spur 1 & Mischung, kommt ins fertige Video \\
|
||||
Spur 2 & Mikrofon, daraus entstehen die Untertitel \\
|
||||
Spur 3 & Spielton \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
Das ist bei lauten Spielen der Unterschied zwischen brauchbarem und
|
||||
unbrauchbarem Transkript.
|
||||
|
||||
\subsection{Bildaufteilung}
|
||||
|
||||
Der Zuschnitt nimmt feste Bereiche aus dem fertigen Streambild. Verrutscht die
|
||||
Kamera um 50 Pixel, sitzt in jedem Short der Kopf schief. Deshalb liegt die
|
||||
Position fest:
|
||||
|
||||
\begin{tabularx}{\textwidth}{@{}lll@{}}
|
||||
\toprule
|
||||
\textbf{Element} & \textbf{Position} & \textbf{Größe} \\
|
||||
\midrule
|
||||
Facecam & x 0, y 0 & 480 $\times$ 270 \\
|
||||
Gameplay-Band & x 515 bis 1405 & 890 $\times$ 1080 \\
|
||||
Chat, Alerts, Overlays & ab x 1405 & 515 px Spalte \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
Zwischen Kamera und Gameplay-Band liegen 35 Pixel Luft. Ein leicht
|
||||
verrutschter Kamerarahmen schiebt so nicht sofort einen Streifen Gesicht ins
|
||||
Spielbild.
|
||||
|
||||
Zum Ausrichten liegt eine Vorlage bei. Als Bildquelle in OBS laden, ganz nach
|
||||
oben ziehen, Deckkraft über einen Farbkorrektur-Filter auf etwa 60 Prozent,
|
||||
eigene Quellen einpassen, Vorlage wieder ausblenden.
|
||||
|
||||
Steht die Kamera in OBS als 4:3, vorher in den Kameraeinstellungen auf 16:9
|
||||
umstellen. Ein 4:3-Bild in einen 16:9-Rahmen zu ziehen staucht das Gesicht.
|
||||
|
||||
\subsection{Fremder Text im Bild}
|
||||
|
||||
Was im Streambild steht, ist eingebrannt und lässt sich nachträglich nicht
|
||||
entfernen. Untertitel des Spiels und Live-Caption-Overlays landen also im
|
||||
Short, zusätzlich zu den eigenen Untertiteln.
|
||||
|
||||
\begin{itemize}
|
||||
\item \textbf{Spieluntertitel:} abschalten. Sie laufen über die volle
|
||||
Bildbreite, werden vom Zuschnitt angeschnitten und bringen im Short
|
||||
nichts.
|
||||
\item \textbf{Live-Captions:} verschieben statt abschalten. Für Zuschauer
|
||||
ohne Ton sind sie wertvoll. Sie müssen nur aus dem Gameplay-Band
|
||||
heraus, etwa nach unten links oder in die rechte Spalte.
|
||||
\end{itemize}
|
||||
|
||||
Welche OBS-Quelle den Text überhaupt malt, sagt \texttt{abdulive sources}.
|
||||
|
||||
\section{Der laufende Betrieb}
|
||||
|
||||
\subsection{Clips auslösen}
|
||||
|
||||
\begin{tabularx}{\textwidth}{@{}lX@{}}
|
||||
\toprule
|
||||
\texttt{!clip} im Chat & jeder Zuschauer \\
|
||||
OBS-Hotkey & der Streamer \\
|
||||
Chat-Spitze & erkennt auffällige Reaktionen, standardmäßig aus \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
Abklingzeiten sind nicht optional. Ohne sie ist \texttt{!clip} nach zwei
|
||||
Streams ein Spielzeug: 60 Sekunden global, 300 Sekunden je Zuschauer. Wer zu
|
||||
früh dran ist, bekommt eine Antwort im Chat.
|
||||
|
||||
Die Spitzen-Erkennung läuft von Anfang an mit und schreibt jeden Kandidaten
|
||||
samt Punktzahl mit, löst aber nichts aus. Sobald ein paar Streams
|
||||
protokolliert sind, lässt sie sich an echten Daten kalibrieren und
|
||||
scharfschalten.
|
||||
|
||||
\subsection{Befehle}
|
||||
|
||||
\begin{tabularx}{\textwidth}{@{}lX@{}}
|
||||
\toprule
|
||||
\texttt{abdulive check-env} & Selbsttest: Programme, Schriften, Modell, Verzeichnisse, OBS \\
|
||||
\texttt{abdulive sources} & listet die OBS-Quellen und sagt, welche im 9:16-Ausschnitt landen \\
|
||||
\texttt{abdulive calibrate} & prüft den Bildzuschnitt an einer echten Aufnahme \\
|
||||
\texttt{abdulive render <datei>} & rendert eine Rohdatei nach, etwa nach einer Layoutänderung \\
|
||||
\texttt{abdulive fetch-model} & lädt ein Sprachmodell \\
|
||||
\texttt{abdulive update} & prüft auf eine neue Fassung \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
\subsection{Läuft es?}
|
||||
|
||||
\begin{codef}
|
||||
Start-ScheduledTask -TaskName abdulive \textcolor{grau}{# starten}
|
||||
Stop-ScheduledTask -TaskName abdulive \textcolor{grau}{# stoppen}
|
||||
\end{codef}
|
||||
|
||||
Status und Zahlen liefert \url{http://127.0.0.1:4792/health}. Die Protokolle
|
||||
liegen unter \pfad{\%LOCALAPPDATA\%\textbackslash abdulive\textbackslash logs}.
|
||||
|
||||
\begin{kasten}
|
||||
Bei einer Adresse im Browser immer \texttt{127.0.0.1} statt
|
||||
\texttt{localhost} verwenden, wenn etwas hängt. Der Dienst lauscht nur auf
|
||||
IPv4, und Windows probiert bei \texttt{localhost} zuerst IPv6. Das kostet
|
||||
zwei Sekunden.
|
||||
\end{kasten}
|
||||
|
||||
\section{Updates}
|
||||
|
||||
Die Aufgabe \texttt{abdulive-update} sieht täglich um 5:00 Uhr nach. Findet
|
||||
sie eine neue Fassung, lädt sie das Paket, prüft die Prüfsumme und legt es
|
||||
bereit.
|
||||
|
||||
\textbf{Eingespielt wird beim nächsten Start.} Mitten im Stream wird nichts
|
||||
ausgetauscht. Schlägt der Tausch fehl, läuft die alte Fassung weiter.
|
||||
|
||||
Sofort übernehmen:
|
||||
|
||||
\begin{code}
|
||||
Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive
|
||||
\end{code}
|
||||
|
||||
\section{Sicherung}
|
||||
|
||||
\begin{code}
|
||||
& "$env:LOCALAPPDATA\Programs\abdulive\backup.ps1"
|
||||
\end{code}
|
||||
|
||||
Schreibt eine in sich stimmige Kopie der Datenbank und hält 30 Generationen.
|
||||
Die Datenbank wird im laufenden Betrieb beschrieben, ein einfaches Kopieren
|
||||
ergäbe eine unbrauchbare Sicherung.
|
||||
|
||||
\section{Deinstallation}
|
||||
|
||||
\begin{code}
|
||||
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1"
|
||||
\end{code}
|
||||
|
||||
Nimmt die beiden Aufgaben, beendet den Dienst und löscht den Programmordner.
|
||||
Die Daten bleiben stehen, weil in der Datenbank Chatverläufe liegen. Wer neu
|
||||
installiert, findet seine Einrichtung wieder.
|
||||
|
||||
Wirklich alles weg, auch Datenbank, Clips und Sprachmodell:
|
||||
|
||||
\begin{code}
|
||||
& "$env:LOCALAPPDATA\Programs\abdulive\uninstall.ps1" -AuchDaten
|
||||
\end{code}
|
||||
|
||||
\section{Wenn etwas nicht geht}
|
||||
|
||||
\begin{tabularx}{\textwidth}{@{}lX@{}}
|
||||
\toprule
|
||||
\textbf{Symptom} & \textbf{Ursache und Abhilfe} \\
|
||||
\midrule
|
||||
Keine Clips & OBS-WebSocket aus oder Passwort fehlt. \texttt{check-env}
|
||||
zeigt es unter \emph{OBS}. \\
|
||||
Clips ohne Untertitel & Sprachmodell fehlt. \texttt{abdulive fetch-model}. \\
|
||||
Untertitel voller Unsinn & Transkribiert wird die falsche Tonspur. Mikrofon
|
||||
gehört auf Spur 2. \\
|
||||
Kopf sitzt schief & OBS-Layout weicht von der Vorgabe ab.
|
||||
\texttt{abdulive calibrate}. \\
|
||||
Fremder Text im Short & Eine OBS-Quelle liegt im Gameplay-Band.
|
||||
\texttt{abdulive sources}. \\
|
||||
Chat wird nur gelesen & Broadcaster-Konto nicht verbunden. \\
|
||||
Oberfläche antwortet nicht & Protokolle unter
|
||||
\pfad{\%LOCALAPPDATA\%\textbackslash abdulive\textbackslash logs} ansehen. \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
\section{Wo was liegt}
|
||||
|
||||
\begin{tabularx}{\textwidth}{@{}lX@{}}
|
||||
\toprule
|
||||
\pfad{config\textbackslash config.yaml} & Einstellungen. Keine Geheimnisse. \\
|
||||
\pfad{config\textbackslash layout.yaml} & Bildaufteilung für den Zuschnitt \\
|
||||
\pfad{db\textbackslash} & Datenbank und verschlüsselte Zugangsdaten \\
|
||||
\pfad{clips\textbackslash} & fertige Videos \\
|
||||
\pfad{work\textbackslash} & Rohdateien der Verarbeitung \\
|
||||
\pfad{models\textbackslash} & Sprachmodelle \\
|
||||
\pfad{logs\textbackslash} & Protokolle \\
|
||||
\bottomrule
|
||||
\end{tabularx}
|
||||
|
||||
Alles unterhalb von \pfad{\%LOCALAPPDATA\%\textbackslash abdulive}.
|
||||
|
||||
\begin{kasten}
|
||||
\textbf{Der Arbeitsordner wird noch nicht aufgeräumt.} Rohdateien sammeln
|
||||
sich unbegrenzt an, mehrere hundert MB je Clip. Bis eine Aufbewahrungsregel
|
||||
gebaut ist, gelegentlich selbst nachsehen.
|
||||
\end{kasten}
|
||||
|
||||
\end{document}
|
||||
+522
-66
@@ -1,113 +1,569 @@
|
||||
# Übergabe
|
||||
|
||||
Stand **2026-09-09**. Wer hier weitermacht, sollte diese Seite zuerst lesen und
|
||||
danach [`07-mvp-plan.md`](07-mvp-plan.md).
|
||||
Stand **2026-10-18**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
|
||||
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
|
||||
selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
|
||||
|
||||
## Wo das Projekt steht
|
||||
|
||||
Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut. Die Kette vom
|
||||
Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist sie
|
||||
Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
|
||||
überall dort, wo es ohne Abdus Stream-PC ging.
|
||||
|
||||
| Scheibe | Stand | Nachweis |
|
||||
|---|---|---|
|
||||
| S0 Container + Toolchain | fertig | `check-env` ohne harte Fehler |
|
||||
| S0 Toolchain + Auslieferung | fertig | Paket gebaut und installiert, `check-env` ohne harte Fehler, Updater einmal komplett durchgelaufen |
|
||||
| S1 Einrichtung, Auth, DB, Chat | fertig | Daemon läuft, Chat verbunden, Secrets verschlüsselt |
|
||||
| S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS |
|
||||
| S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme |
|
||||
| S4 Untertitel | fertig | Durchlauf mit echtem deutschen Clip |
|
||||
| S5 Onboarding + Sichtung | offen | — |
|
||||
| S6 Erstchatter-Begrüßung | offen | — |
|
||||
| S4 Untertitel | fertig | Durchlauf mit echtem deutschen Clip, Ränder nachgemessen |
|
||||
| S5 Onboarding + Sichtung | offen | |
|
||||
| S6 Erstchatter-Begrüßung | offen | |
|
||||
|
||||
53 Tests, alle grün. Vollständige Liste dessen, was noch fehlt:
|
||||
288 Tests, alle grün. Was noch fehlt, steht vollständig in
|
||||
[`offene-punkte.md`](offene-punkte.md).
|
||||
|
||||
## Neu: der Dienst läuft zentral
|
||||
|
||||
Seit 2026-09-17 läuft ein Teil des Toolkits nicht mehr auf Abdus Rechner,
|
||||
sondern in einem LXC auf dem Proxmox-Host. Der Schnitt und seine Begründung
|
||||
stehen in [ADR-0008](decisions/ADR-0008-geteilter-betrieb.md); die Kurzfassung
|
||||
ist: alles, was nur Internet braucht, gehört dorthin, wo durchgehend Strom
|
||||
ist.
|
||||
|
||||
| | Hub (LXC 141, `192.168.2.15`) | Ingest (Abdus PC) |
|
||||
|---|---|---|
|
||||
| Befehl | `abdulive hub` | `abdulive ingest` |
|
||||
| Chat, Zuschauerzahlen | ja | ja |
|
||||
| Discord-Knopf, Go-Live-Ping | **ja** | nein |
|
||||
| Overlay für OBS | **ja** | nein |
|
||||
| OBS, `!clip`, Rendern | nein | **ja** |
|
||||
|
||||
Erreichbar unter `https://abdu.dimension47.de`, hinter dem vorhandenen Nginx
|
||||
Proxy Manager (Proxy-Eintrag 42, eigenes Let's-Encrypt-Zertifikat, Pufferung
|
||||
abgeschaltet - ohne das sammelt nginx die Overlay-Ereignisse und liefert sie
|
||||
gebündelt aus). Ausgerollt wird mit `scripts/deploy-hub.sh`, gestartet von
|
||||
systemd (`abdulive-hub`). Der Container hat keinen Zugang zum Repo - das
|
||||
Skript schiebt den Arbeitsstand als Archiv hinüber.
|
||||
|
||||
Der Discord-Teil der Zugangsdaten ist übertragen, der Bot hängt am Hub und
|
||||
meldet beim Start "Rollen-Knöpfe sind scharf". **Abdu hat am 2026-09-23 sein
|
||||
Konto verbunden** (`abdulive_`, alle 9 Scopes). Damit laufen Helix mit
|
||||
Nutzer-Token und EventSub. Der Chat liest weiter anonym mit, weil das
|
||||
Bot-Konto noch nicht verbunden ist.
|
||||
|
||||
**Das kostet ADR-0005 seine Hauptbegründung.** "Bindet nur auf 127.0.0.1" gilt
|
||||
für den Hub nicht mehr. An seine Stelle treten ein Passwort für alles, was ein
|
||||
Mensch bedient, und ein Schlüssel in der Adresse für die Browser-Sources.
|
||||
|
||||
## Der Go-Live-Ping geht raus
|
||||
|
||||
War in der letzten Übergabe noch Vorarbeit, ist jetzt gebaut
|
||||
(`apps/broadcaster`). Ablauf: neuer Stream erkannt, eine Minute warten, Titel
|
||||
und Kategorie frisch holen, Webhook posten, Rolle `Live-Ping` anpingen.
|
||||
|
||||
Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkörper. `<@&ID>`
|
||||
allein färbt die Erwähnung ein und benachrichtigt niemanden - das ist der
|
||||
Fehler, den man erst nach drei leeren Streams bemerkt, und dagegen steht ein
|
||||
eigener Test.
|
||||
|
||||
Gegen den doppelten Ping stehen drei Dinge, denn wer zweimal in zehn Minuten
|
||||
angepingt wird, nimmt sich die Rolle wieder ab: die Tabelle `posts` mit einem
|
||||
eindeutigen Index je Stream, eine Abklingzeit von sechs Stunden, und der
|
||||
Umstand, dass der Fortsetzungszweig im Polling gar nicht erst auslöst.
|
||||
|
||||
Der Hub steht auf `mode: draft` - der Post geht vollständig, aber in
|
||||
`#technik-log` und ohne Ping. Ein Probelauf ist dort gelandet, der Weg zum
|
||||
Webhook ist damit nachgewiesen. Zum Scharfschalten: `mode: live` in
|
||||
`/var/lib/abdulive/config/config.yaml`.
|
||||
|
||||
**Ausgelöst hat er noch nie.** Bis zum 2026-09-23 fehlte das
|
||||
Broadcaster-Token; das ist jetzt da. Der nächste Stream ist der erste echte
|
||||
Durchlauf, im Entwurfsmodus.
|
||||
|
||||
## Das Overlay steht
|
||||
|
||||
Browser-Sources unter `apps/overlay-web/public`, ausgeliefert vom
|
||||
Overlay-Server: Einblendungen, Chat, Kamerarahmen, Statusleiste, Effekte,
|
||||
Pausenbild, Vorlesen, Liegestütze. Kein Build, kein Framework - OBS lädt die
|
||||
Dateien direkt.
|
||||
|
||||
**Nächste Sitzung: das Overlay wird neu gemacht.** Gestaltung von Grund auf,
|
||||
nicht nur der Kamerarahmen. Was bleiben sollte, weil es Fehler verhindert, die
|
||||
man erst im Stream bemerkt: `client.js` (Neuverbinden, Wachhund,
|
||||
Schnappschuss, `Overlay.esc`), der durchsichtige Hintergrund und die Kontur
|
||||
aus `theme.css`, `?probe=1` zum Ansehen im Browser. Die Ereignisse auf dem Bus
|
||||
(`apps/overlay-server/src/bus.ts`) sind die Schnittstelle, an der sich die
|
||||
neuen Seiten festhalten können.
|
||||
|
||||
Die Ereignisse kommen über Server-Sent Events statt WebSocket. Warum, und was
|
||||
gegen die tote Browser-Source nach einem Neustart getan ist, steht in
|
||||
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
|
||||
|
||||
Abos, Raids und Bits kommen über IRC-USERNOTICE herein, das wird jetzt
|
||||
ausgewertet. **Follows fehlen** - die gibt es nur über EventSub. EventSub
|
||||
läuft inzwischen, abonniert ist aber nur `channel_points_custom_reward_redemption.add`.
|
||||
Follows wären ein zweites Abonnement (`channel.follow`, Version 2, braucht
|
||||
`moderator:read:followers`, der Scope ist schon da).
|
||||
|
||||
**Kamerarahmen, Entwurf** (`/kamera`, 520×380 auf 0,0): ein Rahmen rechts und
|
||||
unten um die Facecam, darunter eine Platte mit Kanal, Kategorie, Zuschauern
|
||||
und Laufzeit. Soll die Statusleiste ersetzen, die als unschön empfunden wird.
|
||||
Über der Kamera liegt bewusst nichts, weil genau dieser Bereich für die
|
||||
Shorts ausgeschnitten wird. Noch nicht ausgerollt, Rückmeldung zur Richtung
|
||||
steht aus. Ansehen: `node apps/cli/main.ts overlay --demo`, dann
|
||||
`http://127.0.0.1:4791/kamera?probe=1`.
|
||||
|
||||
Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
|
||||
**Ausprobieren** für jede Einblendung, jede Chat-Variante und jeden Effekt
|
||||
einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im
|
||||
Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
|
||||
|
||||
## Das Pult: Handy als Stream Deck
|
||||
|
||||
Seit 2026-10-26 gebaut, **noch nicht ausgerollt und nicht an echtem OBS
|
||||
geprüft**. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit
|
||||
Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen,
|
||||
Vorlese-Freigabe und den Testknöpfen. Beschreibung in
|
||||
[`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult).
|
||||
|
||||
- **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf
|
||||
dem Hub unter `<db>/sounds/`, gespielt wird über die neue Browser-Source
|
||||
`/sounds` (muss in OBS eingetragen werden, mit Ton).
|
||||
- **Szenen** brauchen die neue Leitung Stream-PC → Hub: einmal
|
||||
`abdulive fern:verbinden <adresse> <schlüssel>` auf dem Stream-PC (Befehl
|
||||
steht auf der Übersicht), dann läuft sie in `abdulive ingest` mit. Das
|
||||
heißt auch: **ein neues Paket für den Stream-PC** ist nötig, nicht nur
|
||||
ein Ausrollen des Hubs.
|
||||
- Geprüft: 311 Tests, Typprüfung, ein Durchlauf im Browser gegen einen
|
||||
nachgebauten Stream-PC mit falschem OBS (Szenenwechsel, Stummschalten,
|
||||
abgelehnte Szene, Hochladen, Abspielen in der Sound-Quelle). Ungeprüft:
|
||||
echtes OBS, iPhone/Android-Installation, der Weg durch Nginx Proxy Manager
|
||||
für die lange offene `/fern/befehle`-Leitung und Uploads bis 8 MB.
|
||||
|
||||
## Kanalpunkte: „Nachricht vorlesen" läuft
|
||||
|
||||
Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub
|
||||
prüft (`apps/hub/src/rewards.ts`), Piper spricht, `/tts` spielt ab.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Belohnung | „Nachricht vorlesen", 1000 Punkte, Texteingabe Pflicht |
|
||||
| Freigabe | keine, wird direkt vorgelesen |
|
||||
| Schutz | Wortfilter (29 Einträge), 200 Zeichen, 3 je Person und Stunde, „Sofort still" auf der Übersichtsseite |
|
||||
| Stimme | Piper, `de_DE-thorsten-medium` (mls klang verwaschen) |
|
||||
|
||||
**Die Stimme entsteht im Hub, nicht im Browser.** Zuerst lief es über
|
||||
`speechSynthesis` in der Browser-Quelle. Das hat in OBS gesprochen, aber
|
||||
amerikanisch: OBS' Browser sah keine deutsche Stimme und die Seite nahm die
|
||||
erste beliebige. Piper liegt unter `/opt/piper` im LXC (2 Kerne: 200 Zeichen
|
||||
in 0,7 s), das Stimmenmodell unter `models/piper/`. Beides überlebt jedes
|
||||
Ausrollen; eingerichtet mit `scripts/install-piper.sh`.
|
||||
|
||||
**Die erste Stimme war `de_DE-mls-medium`, Sprecher 0**, und klang im Stream
|
||||
„wie jemand mit Schlaganfall". Die Phoneme stimmten (`piper --debug`), es lag
|
||||
am Modell: Hörbuch-Aufnahmen von 236 Laien, Sprecher 0 ist ein beliebiger.
|
||||
Seit 2026-09-24 läuft `de_DE-thorsten-medium`, ein Sprecher, darum kein
|
||||
`sprecher` mehr in der Konfiguration. Das mls-Modell liegt noch daneben.
|
||||
|
||||
**Die Belohnung muss über die API angelegt sein** (`abdulive rewards:anlegen`).
|
||||
Nur die App, die eine Belohnung angelegt hat, darf deren Einlösungen
|
||||
abschließen oder erstatten. Im Dashboard sieht man davon nichts. Titel,
|
||||
Kosten und Abklingzeit darf man dort ändern; löschen und neu anlegen nicht,
|
||||
dann ist die ID weg, die in der Hub-Konfiguration steht.
|
||||
|
||||
**Der Wortfilter normalisiert vorher**: Leetspeak, Punkte zwischen den
|
||||
Buchstaben, gezogene Buchstaben, Akzente. Einträge ab 5 Buchstaben werden im
|
||||
zusammengezogenen Text gesucht, kürzere nur am Wortanfang, sonst stünde
|
||||
„nazi" in „kann azubi". Die Liste steht nur in der Hub-Konfiguration, nicht
|
||||
im Repo.
|
||||
|
||||
**Der Filter hat eine Stunde lang fast alles abgelehnt.** `www` in der Liste
|
||||
wurde zusammengezogen zu `w` und traf damit jedes Wort, das mit w anfängt
|
||||
(„wie", „weg"). Die Punkte gingen zurück, im Log stand nur `Wortfilter`.
|
||||
Behoben am 2026-09-24: was nach dem Zusammenziehen unter 3 Buchstaben hat,
|
||||
trifft nur noch als ganzes Wort. **Noch offen:** lange Einträge treffen über
|
||||
Wortgrenzen hinweg, „Spaß tut gut" fliegt wegen `spast` raus, „eine Gerade"
|
||||
wegen `neger`. Wer eine Ablehnung nachvollziehen will: der Text steht im
|
||||
Ereignis `points.redeemed` in der Datenbank.
|
||||
|
||||
**Der YAML-Leser konnte keine Listen aus Maps.** `- titel: X` zerfiel in
|
||||
Zeichenketten, jede Belohnung war ohne Meldung unwirksam - auch das Beispiel
|
||||
in `config.example.yaml`. Behoben und getestet; die bestehenden Dateien
|
||||
werden nachweislich gleich gelesen wie vorher.
|
||||
|
||||
## Kanalpunkte: Liegestütze
|
||||
|
||||
Seit 2026-10-04 scharf. Die Belohnung „5 Liegestütze" gab es auf Twitch schon
|
||||
(von Hand angelegt, 61 Einlösungen bis dahin). Sie steht jetzt in der
|
||||
Hub-Konfiguration mit `aktion: liegestuetze`, `anzahl: 5`, zugeordnet über den
|
||||
Titel. Wird sie auf Twitch umbenannt, zählt der Hub nicht mehr mit.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Zähler | `apps/hub/src/liegestuetze.ts`, Tabelle `zaehler` (Migration 003), übersteht Neustarts |
|
||||
| Offen | Einlösung +5. Auf der Übersicht −1/−5/−10, Setzen, auf 0 |
|
||||
| Gemacht | Was mit −1/−5/−10 abgezogen wird. Setzen zählt nicht, das ist Korrektur. Eigenes Feld zum Korrigieren |
|
||||
| Neuer Stream | Gemacht auf 0, offen bleibt. Nicht beim Neustart des Hubs mitten im Stream, nicht bei einer Lücke bis 5 Minuten |
|
||||
| Overlay | `/liegestuetze`, 420×200. `?verstecken=1`, `?gemacht=0`, `?titel=...` |
|
||||
|
||||
**Ungeprüft im Stream.** Getestet mit Probeserver und Browser. Weder eine
|
||||
echte Einlösung noch das Nullen beim Streamstart ist seit dem Ausrollen
|
||||
passiert. Der Zähler startete bei 0 offen, die 61 alten Einlösungen sind nicht
|
||||
übernommen. In OBS ist die Quelle noch nicht eingetragen.
|
||||
|
||||
**Die Belohnung ist nicht über die API angelegt.** Der Hub kann ihre
|
||||
Einlösungen deshalb nicht als erledigt melden und schreibt bei jeder eine
|
||||
Warnung ins Log. Für das Zählen egal. Eine ältere Belohnung „10 Liegestütze"
|
||||
(12 Einlösungen bis 2026-09-24) steht nicht in der Konfiguration.
|
||||
|
||||
**Nebenbei behoben: die Vorlese-Freigabe auf der Übersicht war tot.**
|
||||
`POST /tts` landete bei der Overlay-Seite `/tts` statt bei der Freigabe, auf
|
||||
dem Hub ohne Schlüssel sogar mit 403. „Vorlesen", „Ablehnen" und „Sofort
|
||||
still" haben damit nie gewirkt. Overlay-Seiten werden jetzt nur bei GET
|
||||
ausgeliefert. Gegen eine echte Warteschlange nicht nachgeprüft.
|
||||
|
||||
## Der Discord-Server steht
|
||||
|
||||
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
|
||||
Community-Server ist eingerichtet, und zwar als Code statt von Hand. Der
|
||||
Bauplan liegt in [`packages/discord/src/plan.ts`](../packages/discord/src/plan.ts),
|
||||
aufgetragen wird er mit `abdulive discord:setup`. Wiederholbar: gefunden wird
|
||||
über Namen und frühere Namen, danach wird angeglichen. Der zweite Lauf ändert
|
||||
nichts, gelöscht wird nie.
|
||||
|
||||
Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für
|
||||
[Go-Live](projects/go-live-broadcaster.md), [Recap](projects/stream-recap.md)
|
||||
und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher
|
||||
unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge
|
||||
baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten.
|
||||
|
||||
Zehn Kanäle, vier Rollen, zwei AutoMod-Regeln. Seit 2026-10-17 dabei:
|
||||
`🙋│begrüßung` für Discords Beitrittsmeldungen (liefen vorher in
|
||||
`#allgemein`), und die Foren `🎮│spielvorschläge` und
|
||||
`👀│reaction-einsendungen`. Beide hatte Abdu als Textkanäle angelegt, sie sind
|
||||
jetzt Foren mit Tags, die nur Mods setzen, und 👍 als Standard-Reaktion.
|
||||
Discord wandelt keinen Textkanal in ein Forum um: die beiden leeren
|
||||
Textkanäle wurden einmalig über die API gelöscht, das Setup-Skript selbst
|
||||
löscht weiterhin nichts.
|
||||
|
||||
Im Spielvorschläge-Forum stehen neun Beiträge, einer je Spiel aus Abdus
|
||||
Liste, vom Bot angelegt (Titel vorher nachgeschlagen, „involkyr" heißt
|
||||
Invokyr). In `📢│ankündigungen` hat der Bot das Forum angekündigt, ohne Ping.
|
||||
Der Bot darf dort trotz Schreibsperre für `@everyone` posten.
|
||||
|
||||
**Der Hub ist nicht neu ausgerollt.** Das Setup lief aus einer Kopie im
|
||||
Container, der laufende Prozess hat noch den Stand davor. Für den Server ist
|
||||
das egal, für den Knopf auch. Beim nächsten Ausrollen kommt es mit. Der Entwurf und seine
|
||||
Begründung stehen in [`projects/discord-community.md`](projects/discord-community.md);
|
||||
die Kurzfassung ist, dass bei 5 bis 30 Zuschauern fünf sichtbare Kanäle besser
|
||||
sind als zwanzig leere.
|
||||
|
||||
Ein Teil davon läuft dauerhaft: der Knopf unter dem Willkommenstext, mit dem
|
||||
man sich die Rolle `Live-Ping` selbst gibt. Er hängt als Gateway-Verbindung mit
|
||||
im Prozess und meldet sich als `discordConnected` auf der Statusseite. Seit dem
|
||||
Umzug in den Hub läuft er durch, auch wenn Abdus Rechner aus ist.
|
||||
|
||||
Dazugekommen ist ein vierter Webhook: `technikWebhook` in `#technik-log`. Dort
|
||||
landet der Go-Live-Post im Entwurfsmodus - dieselbe Nachricht wie im Betrieb,
|
||||
nur ohne Ping und nur für Mods sichtbar. Wer den Server vor dieser Fassung
|
||||
eingerichtet hat, muss `abdulive discord:setup` einmal erneut laufen lassen.
|
||||
|
||||
## Was jemand wissen muss, bevor er etwas anfasst
|
||||
|
||||
**Es gibt keine Abhängigkeiten und keinen Build.** Node 24 bringt SQLite,
|
||||
WebSocket, `fetch` und die Ausführung von TypeScript mit. `node docker/entrypoint.ts`
|
||||
läuft direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax — keine `enum`,
|
||||
keine Parameter-Properties im Konstruktor, keine `namespace`. `erasableSyntaxOnly`
|
||||
in der `tsconfig.json` erzwingt das und hat schon einen echten Fehler gefangen.
|
||||
**Keine Abhängigkeiten, kein Build.** Node 24 bringt SQLite, WebSocket,
|
||||
`fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts` läuft
|
||||
direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax, also keine `enum`,
|
||||
keine Parameter-Properties im Konstruktor, keine `namespace`.
|
||||
`erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen
|
||||
echten Fehler gefangen.
|
||||
|
||||
**Alles läuft unter einem Dach**, einem Prozess in einem Container. Schwere
|
||||
Arbeit (ffmpeg, whisper) läuft als Kindprozess und darf abstürzen. Der Ingest
|
||||
darf nie sterben — was er nicht mitschreibt, ist für immer weg.
|
||||
**Alles läuft in einem Prozess** - aber es gibt jetzt zwei davon, Hub und
|
||||
Ingest (siehe oben). Schwere Arbeit (ffmpeg, whisper) läuft als Kindprozess und
|
||||
darf abstürzen. Der Chat-Prozess darf nie sterben. Was er nicht mitschreibt,
|
||||
ist für immer weg.
|
||||
|
||||
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie liegen
|
||||
verschlüsselt in einem Docker-Volume, bewusst außerhalb der Datenbank, weil die
|
||||
gesichert und dabei auf die Windows-Seite kopiert wird.
|
||||
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie
|
||||
liegen verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr. Die
|
||||
Datenbank wird gesichert und weitergereicht, die Geheimnisse nicht.
|
||||
|
||||
**Rohdateien gehören ins Arbeits-Volume**, nicht in den Windows-Bind-Mount. Über
|
||||
die WSL2-Brücke zu encodieren kostet ein Vielfaches. Dagegen habe ich einmal
|
||||
selbst verstoßen — der Fehler fiel erst in S3 auf.
|
||||
**Das Repo ist seit 2026-09-09 öffentlich.** Damit funktioniert der Einzeiler
|
||||
zur Installation, und der Updater braucht keinen Token. Es heißt aber auch:
|
||||
nichts Vertrauliches mehr hineinschreiben, auch nicht in die Doku.
|
||||
|
||||
Die Begründungen stehen in [`decisions/`](decisions/), sechs ADRs. Wer eine
|
||||
davon umwerfen will, sollte sie vorher lesen; jede hat einen Grund, der beim
|
||||
Bauen aufgetaucht ist.
|
||||
**Es gibt zwei Remotes**, beide mit demselben Inhalt: `origin`
|
||||
(`ZeasyAlex/abdulive`) und `discord` (`ZeasyAlex/abdulive_-discord`). Wer
|
||||
pusht, pusht auf beide, sonst laufen sie auseinander.
|
||||
|
||||
**`deploy-hub.sh` schiebt den Arbeitsstand, nicht den Commit.** Liegt
|
||||
Unfertiges herum, landet es im LXC. Sauber ausrollen aus einem eigenen
|
||||
Checkout:
|
||||
|
||||
```bash
|
||||
git worktree add --detach ../abdulive-deploy HEAD
|
||||
../abdulive-deploy/scripts/deploy-hub.sh
|
||||
git worktree remove ../abdulive-deploy
|
||||
```
|
||||
|
||||
Die Begründungen stehen in [`decisions/`](decisions/), acht ADRs. Wer eine
|
||||
davon umwerfen will, liest sie vorher. ADR-0004 (Docker) ist durch ADR-0007
|
||||
ersetzt; die Analyse darin bleibt lesenswert, weil zwei der vier
|
||||
beschriebenen Fallen mit dem Container weggefallen sind statt gelöst worden
|
||||
zu sein.
|
||||
|
||||
## Loslegen
|
||||
|
||||
```powershell
|
||||
git clone https://git.zeasy.dev/ZeasyAlex/abdulive.git
|
||||
cd abdulive
|
||||
Copy-Item config\config.example.yaml config\config.yaml
|
||||
|
||||
docker compose build
|
||||
docker compose run --rm toolkit check-env
|
||||
docker compose run --rm toolkit fetch-model # ca. 465 MB
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Dann `http://localhost:4792/setup` öffnen. Ausführlich in
|
||||
[`05-setup.md`](05-setup.md).
|
||||
|
||||
Tests:
|
||||
Auf Abdus Rechner, eine native Windows-Anwendung:
|
||||
|
||||
```powershell
|
||||
docker run --rm -v "${PWD}:/app" -w /app node:24-bookworm-slim `
|
||||
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||
irm https://git.zeasy.dev/ZeasyAlex/abdulive/raw/branch/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Typprüfung (das Einzige, wofür npm gebraucht wird):
|
||||
Der Installer lädt das Paket, prüft die Prüfsumme, holt das Sprachmodell,
|
||||
läuft den Selbsttest, startet den Dienst und öffnet die Einrichtungsseite.
|
||||
Ohne Administratorrechte. Ausführlich in [`05-setup.md`](05-setup.md).
|
||||
|
||||
Zum Entwickeln reicht ein Checkout und Node 24 oder neuer:
|
||||
|
||||
```powershell
|
||||
docker run --rm -v "${PWD}:/app" -w /tmp node:24-bookworm-slim sh -c `
|
||||
"npm i -s typescript@5.9 @types/node && cd /app && /tmp/node_modules/.bin/tsc --noEmit --typeRoots /tmp/node_modules/@types"
|
||||
node apps/cli/main.ts check-env
|
||||
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||
node apps/cli/main.ts overlay --demo # Overlay ohne Stream ansehen
|
||||
node scripts/build-package.ts --version 0.5.5 # baut dist/
|
||||
```
|
||||
|
||||
Den Hub ausrollen (braucht SSH auf den Proxmox-Host):
|
||||
|
||||
```bash
|
||||
scripts/deploy-hub.sh
|
||||
```
|
||||
|
||||
Typprüfung, das Einzige wofür npm gebraucht wird:
|
||||
|
||||
```powershell
|
||||
npm i typescript@5.9 @types/node
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Ausliefern
|
||||
|
||||
`build-package.ts` lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern,
|
||||
packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
|
||||
`abdulive update` auf Abdus Rechner ab.
|
||||
|
||||
Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau
|
||||
ersetzt, damit die Adresse gleich bleibt.
|
||||
`.gitea/workflows/release.yml` macht das beim Tag automatisch. **Die Pipeline
|
||||
ist noch nie gelaufen**, bisher wurde von Hand veröffentlicht. Der Runner
|
||||
(`ubuntu-latest`) steht.
|
||||
|
||||
## Windows-Fallen, die schon zugeschlagen haben
|
||||
|
||||
Alle vier haben Zeit gekostet, und keine sieht im Fehlerfall nach ihrer
|
||||
Ursache aus. Wer an den PowerShell-Skripten arbeitet, sollte sie kennen.
|
||||
|
||||
**stderr beendet das Skript.** Windows PowerShell 5.1 verpackt jede
|
||||
stderr-Zeile eines nativen Programms in einen `ErrorRecord`. Zusammen mit
|
||||
`$ErrorActionPreference = 'Stop'` bricht das sofort alles ab. Der Daemon
|
||||
schreibt im Normalbetrieb Warnungen auf stderr, der Starter hat ihn also bei
|
||||
seiner ersten Logzeile umgebracht: Aufgabe meldete Code 1, das Protokoll war
|
||||
leer. Deshalb läuft der Start über `Start-Process` mit getrennten
|
||||
Umleitungen, und `install.ps1` ruft native Programme über eine Hilfsfunktion
|
||||
auf, die `ErrorActionPreference` kurz zurückstellt.
|
||||
|
||||
**`& $programm; return $LASTEXITCODE` gibt auch die Ausgabe zurück.** Die
|
||||
Funktion liefert dann ein Array aus allen Ausgabezeilen plus Exit-Code, und
|
||||
jeder Vergleich damit ist falsch, ohne dass es auffällt. Ein erfolgreiches
|
||||
`fetch-model` galt so als fehlgeschlagen. Ausgabe mit `| Out-Host`
|
||||
durchreichen.
|
||||
|
||||
**`localhost` kostet zwei Sekunden.** Der Dienst lauscht nur auf IPv4,
|
||||
Windows probiert bei `localhost` zuerst `::1`. Gemessen: 2066 ms bis zum
|
||||
Rückfall gegenüber 15 ms bei `127.0.0.1`. Eine Bereitschaftsprüfung mit zwei
|
||||
Sekunden Zeitlimit läuft dadurch immer ab, obwohl der Dienst längst läuft.
|
||||
|
||||
**Windows-Pfade im ffmpeg-Filtergraphen brauchen drei Schritte.** An echtem
|
||||
ffmpeg 8.1 durchprobiert:
|
||||
|
||||
| Form | Ergebnis |
|
||||
|---|---|
|
||||
| `ass=filename=C:/x/s.ass` | `No option name near '/x/s.ass'` |
|
||||
| `ass=filename=C\:/x/s.ass` | ebenso |
|
||||
| `ass=filename='C:/x/s.ass'` | ebenso |
|
||||
| `ass=filename='C\:/x/s.ass'` | läuft |
|
||||
|
||||
Vorwärtsschrägstriche, escapter Doppelpunkt **und** Anführungszeichen. Je
|
||||
zwei davon reichen nicht. `filterPath()` in `packages/media/src/ffmpeg.ts`
|
||||
macht das, mit Tests.
|
||||
|
||||
## Untertitel: was gemessen ist
|
||||
|
||||
**Die Schrift muss mitgeliefert werden.** Im Container kam DejaVu Sans aus
|
||||
dem Betriebssystem, Windows hat sie nicht. Fehlt `fontsdir`, setzt libass
|
||||
wortlos eine Ersatzschrift ein. Gemessen: dieselbe Zeile wird 720 statt 826
|
||||
Pixel breit, ohne jede Fehlermeldung. Die Dateien liegen unter
|
||||
`assets/fonts/`.
|
||||
|
||||
**Die Zeilenlänge ist eingemessen, nicht geschätzt.** Vorher stand dort fest
|
||||
`maxChars: 30`. Das sind bei Schriftgröße 76 rund 1085 px, nutzbar sind 940.
|
||||
Zusammen mit `WrapStyle: 2`, bei dem libass gar nicht umbricht, lief der Text
|
||||
links und rechts aus dem Bild. Jetzt wird die Grenze aus Breite und
|
||||
Schriftgröße abgeleitet, Versalien zählen breiter, und einzelne zu lange
|
||||
Wörter bekommen eine kleinere Schrift, weil libass innerhalb eines Wortes
|
||||
nicht umbricht. Deutsche Komposita sind regelmäßig breiter als das Bild.
|
||||
|
||||
Wer die Schrift wechselt, muss `CHAR_WIDTH_RATIO` in `subtitles.ts` neu
|
||||
einmessen. Sonst bricht die Zeile wieder an der falschen Stelle.
|
||||
|
||||
## Discord: was gemessen ist
|
||||
|
||||
Vier Dinge, die nicht in der Dokumentation standen, in der man zuerst sucht.
|
||||
Alle am echten Server nachgeprüft.
|
||||
|
||||
**Kanalnamen lassen sich nur zweimal in zehn Minuten ändern.** Danach verlangt
|
||||
Discord eine Pause, gemessen wurden 529 Sekunden. Ein Einrichtungslauf, der
|
||||
brav auf ein 429 wartet, steht dann neun Minuten. Der Client wartet deshalb
|
||||
höchstens eine Minute, meldet den Kanal als übersprungen und macht weiter.
|
||||
|
||||
**Eine Rolle kann nur Rollen unterhalb der eigenen verschieben** - auch mit
|
||||
Administrator. Discord hängt die Rolle eines frisch eingeladenen Bots ganz
|
||||
unten ein, also gibt es dort keinen Platz. Der erste Lauf quittierte das mit
|
||||
`Missing Permissions`, was nach einem fehlenden Recht aussieht und keines ist.
|
||||
`rolePositions()` rechnet den Platz jetzt aus und sagt, was zu tun ist.
|
||||
|
||||
**Discords Onboarding braucht einen größeren Server.**
|
||||
|
||||
```
|
||||
HTTP 400 Cannot enable onboarding, requirements are not met (350000)
|
||||
|
||||
sichtbar für @everyone: 5 davon beschreibbar: 3
|
||||
Discord verlangt: 7 davon beschreibbar: 5
|
||||
```
|
||||
|
||||
Zwei Kanäle anzulegen, damit eine Schwelle passt, hätte den Server leerer
|
||||
gemacht statt besser. Daher der Knopf.
|
||||
|
||||
**Kanalnamen werden normalisiert, aber nicht so weit wie erwartet.**
|
||||
Textkanäle werden kleingeschrieben und Leerzeichen zu Bindestrichen; Emoji,
|
||||
Umlaute und das Trennzeichen `│` (U+2502) bleiben unverändert stehen.
|
||||
`normalizeName()` bildet das ab. Ohne diesen Abgleich geht bei jedem Lauf
|
||||
dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von
|
||||
oben.
|
||||
|
||||
## Die drei Dinge, die Abdu tun muss
|
||||
|
||||
1. **Zwei Twitch-Apps anlegen** (dev.twitch.tv/console/apps), Redirect-URI
|
||||
`http://localhost:4792/callback`. Client-ID und Secret danach auf der
|
||||
Einrichtungsseite eintragen — nicht in eine Datei.
|
||||
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
|
||||
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
|
||||
muss.
|
||||
|
||||
Die beiden Twitch-Apps (dev.twitch.tv/console/apps) kann auch jemand anders
|
||||
anlegen, sie hängen nicht am Kanal. Seit dem Umzug in den Hub brauchen sie
|
||||
als Redirect-URI **`https://abdu.dimension47.de/callback`**; die alte
|
||||
`http://localhost:4792/callback` bleibt daneben stehen, wenn auf dem
|
||||
Stream-PC weiter eingerichtet werden soll. Twitch vergleicht zeichengenau.
|
||||
|
||||
Die App ist inzwischen hinterlegt. Was nur Abdu kann: auf
|
||||
`https://abdu.dimension47.de/setup` eingeloggt als er selbst auf „Mit
|
||||
Twitch verbinden" klicken. Der Vorteil gegenüber vorher: das geht von jedem
|
||||
Gerät, der Stream-PC muss nicht laufen.
|
||||
|
||||
Ob die Redirect-URI in der App richtig eingetragen ist, zeigt sich erst bei
|
||||
diesem Klick. Twitch prüft sie erst nach dem Login und gibt vorher auch bei
|
||||
einer falschen URI ein freundliches 302 zurück - von außen ist das nicht
|
||||
nachweisbar.
|
||||
2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s,
|
||||
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
|
||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md). Die
|
||||
Ausrichtungsvorlage in OBS über die Szene legen, Quellen einpassen, wieder
|
||||
ausblenden. Danach `calibrate` zur Kontrolle.
|
||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
||||
`abdulive calibrate` zur Kontrolle.
|
||||
|
||||
Ohne Punkt 1 liest das Toolkit den Chat anonym mit und kann nichts senden.
|
||||
Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
|
||||
Chat anonym mit und kann nichts senden.
|
||||
Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief.
|
||||
|
||||
Dazu eine Kleinigkeit mit Wirkung: das Bot-Konto sollte Moderator sein
|
||||
(`/mod <botname>`). Ohne das gilt ein Limit von 20 Chat-Nachrichten je 30
|
||||
Sekunden statt 100, und im Follower-only-Modus kommt der Bot gar nicht durch.
|
||||
|
||||
Und drei für Discord, die nicht über die API gehen:
|
||||
|
||||
1. **Sich selbst die Rolle `Abdu` geben.** Das Skript legt Rollen an, aber es
|
||||
hängt sie niemandem um.
|
||||
2. **Einladungslink** ohne Ablaufdatum erzeugen und ins Twitch-Panel, in die
|
||||
Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream
|
||||
niemand erwähnt, bleibt leer. Das ist der eine Handgriff, an dem hängt, ob
|
||||
der Server überhaupt jemanden bekommt.
|
||||
3. **Tags in den Foren pflegen.** „Wird gespielt", „Gespielt", „Eher nicht"
|
||||
bzw. „Kommt dran", „Angeschaut" setzen nur Abdu und Mods. Ein Vorschlag,
|
||||
der sichtbar umgesetzt wird, bringt den nächsten.
|
||||
|
||||
## Wo ich weitermachen würde
|
||||
|
||||
**S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne Einrichtungs-
|
||||
Assistenten und Sichtungsseite für Abdu nicht bedienbar ist. Die Kette
|
||||
funktioniert, aber sie hat noch keine Oberfläche, die jemand ohne Erklärung
|
||||
benutzt. Abnahmekriterium steht im Plan: *Ein Fremder kommt von „nichts
|
||||
eingerichtet" bis zum ersten eigenen Clip, ohne die Dokumentation zu lesen.*
|
||||
**S2 an einem laufenden OBS abnehmen.** Das ist die einzige ungeprüfte Stelle
|
||||
in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
|
||||
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
|
||||
malt. Das war vorher nicht zu erraten.
|
||||
|
||||
Davor, wenn Gelegenheit ist: **S2 an einem laufenden OBS abnehmen**. Das ist
|
||||
die einzige ungeprüfte Stelle in der ganzen Kette, und alles Weitere hängt
|
||||
daran.
|
||||
**Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
|
||||
`abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
|
||||
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
|
||||
Wirkung.
|
||||
|
||||
**Das Overlay neu machen** (nächste Sitzung, siehe oben). Der Kamerarahmen
|
||||
als Entwurf geht darin auf, die Statusleiste soll ersetzt werden. Danach die
|
||||
Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten.
|
||||
|
||||
**`apps/recap` bauen.** Angefangen, aber nicht committet: `apps/recap/src/recap.ts`
|
||||
und Ergänzungen in `packages/db/src/repos.ts` liegen nur im Arbeitsstand
|
||||
(die Typprüfung meckert dort noch über `url: undefined`). Der Webhook liegt
|
||||
bereit, der Hub hat die Daten. Seit 2026-10-04 wird aus einem sauberen
|
||||
Checkout ausgerollt, der halbe Recap liegt also nicht mehr im LXC.
|
||||
|
||||
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
|
||||
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
||||
hat keine Oberfläche, auf der man Clips durchgeht und aussortiert.
|
||||
Abnahmekriterium steht im Plan: *Ein Fremder kommt von „nichts eingerichtet"
|
||||
bis zum ersten eigenen Clip, ohne die Dokumentation zu lesen.* Installer und
|
||||
Handbuch decken davon inzwischen ein gutes Stück ab.
|
||||
|
||||
## Womit ich nicht zufrieden bin
|
||||
|
||||
Die Untertitel sind brauchbar, nicht gut. `ggml-small` verhört sich hörbar
|
||||
(„Danke" wurde „Dank es"), die Schrift ist unauffällig, und das fortlaufende
|
||||
Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs konkrete
|
||||
Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
|
||||
**Das Aufräumen im Arbeitsordner fehlt komplett.** Rohdateien sammeln sich
|
||||
unbegrenzt an, mehrere hundert MB je Clip. Das ist die Lücke, die als Erstes
|
||||
weh tun wird.
|
||||
|
||||
Und: Das Aufräumen im Arbeits-Volume fehlt komplett. Rohdateien sammeln sich
|
||||
unbegrenzt an. Das ist die Lücke, die als Erstes weh tun wird.
|
||||
**Die Untertitel sind brauchbar, nicht gut.** `ggml-small` verhört sich
|
||||
hörbar („Danke" wurde „Dank es"), DejaVu Sans ist unauffällig, und das
|
||||
fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs
|
||||
konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
|
||||
|
||||
**Die CI ist geschrieben, aber ungetestet.** Beim ersten Tag zusehen.
|
||||
|
||||
**Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation.
|
||||
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
|
||||
bekommt, nicht mehr.
|
||||
|
||||
**Der Discord-Knopf hängt am Daemon** - das war der Punkt aus der letzten
|
||||
Übergabe und ist mit dem Hub erledigt. Dafür gibt es jetzt einen neuen: der
|
||||
Bot darf nur an **einer** Stelle laufen. Steht auf dem Stream-PC ein
|
||||
Discord-Bot-Token im Speicher und läuft dort `ingest`, antworten zwei Prozesse
|
||||
auf denselben Klick. Auf dem Stream-PC bleibt das Discord-Token leer.
|
||||
|
||||
**Der Overlay-Schlüssel steht in der Adresse.** Damit steht er in jedem
|
||||
OBS-Profil und auf jedem Screenshot der Einstellungen. Neu würfeln geht auf
|
||||
der Übersichtsseite, aber danach müssen alle Adressen in OBS neu
|
||||
eingetragen werden.
|
||||
|
||||
**Das Overlay hat nie unter Last gelaufen.** Geprüft ist es gegen den
|
||||
Vorführmodus und im Browser. Wie es sich bei 200 Nachrichten pro Minute
|
||||
verhält, und was der Emote-Regen beim Encoder kostet, ist offen.
|
||||
|
||||
**Der Discord-Bot ist nie unter Last gelaufen.** Getestet sind die Bausteine
|
||||
und ein Klickweg gegen einen nachgebauten Server; am echten Server ist die
|
||||
Verbindung nachgewiesen und der Rollenwechsel einmal in beide Richtungen
|
||||
durchgespielt. Was bei gleichzeitigen Klicks oder nach Tagen Laufzeit
|
||||
passiert, ist offen.
|
||||
|
||||
**Es gibt keine Einstellung für den OBS-Wiedergabepuffer-Ordner.** Im
|
||||
Container kam der Pfad über `OBS_REPLAY_DIR`, nativ fehlt der Weg. Für die
|
||||
Clip-Kette ist das egal, weil OBS den Pfad über WebSocket meldet. Aber
|
||||
`calibrate` findet so keine Aufnahmen, und `check-env` warnt zu Recht.
|
||||
+20
-4
@@ -52,16 +52,32 @@ schiebt dann nicht sofort einen Streifen Gesicht in das Gameplay-Bild.
|
||||
## Prüfen
|
||||
|
||||
```powershell
|
||||
docker compose run --rm toolkit calibrate
|
||||
abdulive calibrate
|
||||
```
|
||||
|
||||
Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder in
|
||||
`data/clips/_calibration/` ab: das Einzelbild, dasselbe mit eingezeichneten
|
||||
Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder
|
||||
unter `%LOCALAPPDATA%\abdulive\clips\_calibration` ab: das Einzelbild, dasselbe mit eingezeichneten
|
||||
Ausschnitten, und die fertige 9:16-Vorschau. Sitzt in der Vorschau der Kopf
|
||||
mittig und ist im Gameplay-Teil kein Chat zu sehen, stimmt das Layout.
|
||||
|
||||
## Text im Bild — hier wird es eng
|
||||
|
||||
**Wer malt den Text?** Das ist nicht zu erraten — die Overlays sind ins
|
||||
Streambild eingebrannt, man muss nachsehen, welche OBS-Quelle sie zeichnet:
|
||||
|
||||
```powershell
|
||||
abdulive sources
|
||||
```
|
||||
|
||||
Der Befehl fragt das laufende OBS und listet jede Quelle der aktuellen Szene
|
||||
mit Position, Größe und dem Urteil, ob sie im 9:16-Ausschnitt landet. Bei
|
||||
Browser-Quellen steht die Adresse dabei — daran erkennt man den Dienst
|
||||
dahinter. Was nach Untertiteln aussieht, wird am Ende noch einmal
|
||||
zusammengefasst.
|
||||
|
||||
Dafür muss in OBS **Werkzeuge → WebSocket-Server-Einstellungen → Server
|
||||
aktivieren** gesetzt sein (Port 4455) und das Passwort unter `/setup` liegen.
|
||||
|
||||
Bei der Prüfung eines echten Clips lagen **drei Textebenen übereinander**:
|
||||
|
||||
1. die Untertitel des Spiels („Genau, ich mach da … sag zu dem Typen")
|
||||
@@ -115,5 +131,5 @@ fest, wie es vor der Vorgabe aussah — damit lassen sich alte Aufnahmen noch
|
||||
korrekt rendern:
|
||||
|
||||
```powershell
|
||||
docker compose run --rm toolkit render <datei> bestand-2026-09
|
||||
abdulive render <datei> bestand-2026-09
|
||||
```
|
||||
+41
-9
@@ -16,7 +16,7 @@ Erledigtes wandert raus, nicht ins Archiv — die Historie steht in Git.
|
||||
|
||||
## Bekannte Lücke im Betrieb
|
||||
|
||||
- [ ] **Aufräumen im Arbeits-Volume.** Jede Rohdatei landet unter `/work/<clipId>/`
|
||||
- [ ] **Aufräumen im Arbeitsordner.** Jede Rohdatei landet unter `work/<clipId>/`
|
||||
und bleibt dort liegen. Bei fünf Clips pro Stream und mehreren hundert MB
|
||||
je Datei läuft das Volume in wenigen Wochen voll. Gebraucht wird eine
|
||||
Aufbewahrungsregel — Vorschlag: Rohdateien der letzten 30 Clips behalten,
|
||||
@@ -47,6 +47,10 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
|
||||
- [ ] **Position prüfen.** 340 px Abstand nach unten ist geschätzt, nicht
|
||||
gemessen. An einem echten Handy gegen die TikTok-, Reels- und
|
||||
Shorts-Oberfläche halten.
|
||||
- [ ] **Seitliche Ränder gegengeprüft**, aber nur rechnerisch gegen eine
|
||||
gemessene mittlere Zeichenbreite. Bei einer anderen Schrift (siehe
|
||||
Punkt oben) muss `CHAR_WIDTH_RATIO` in `subtitles.ts` neu eingemessen
|
||||
werden — sonst bricht die Zeile wieder an der falschen Stelle.
|
||||
- [ ] **Nur transkribieren, wenn Abdu spricht.** Bei getrennten Tonspuren
|
||||
reicht ein Pegelfilter auf der Mikrofonspur, damit aus Stille kein
|
||||
erfundener Text wird.
|
||||
@@ -55,11 +59,45 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
|
||||
|
||||
- [ ] **Live-Captions verschieben.** Sie liegen im Gameplay-Band und sind fest
|
||||
eingebrannt. Zwei Vorschläge stehen in [`obs-layout.md`](obs-layout.md).
|
||||
Welche OBS-Quelle sie überhaupt zeichnet, sagt
|
||||
`abdulive sources` — das war vorher unbekannt.
|
||||
- [ ] **Spieluntertitel abschalten.**
|
||||
- [ ] **Bessere Bildquelle für die Facecam** — Plugin `Source Record` oder
|
||||
`Aitum Vertical`. Damit entfällt die 2,25-fache Vergrößerung. Lohnt sich
|
||||
erst, wenn regelmäßig Shorts entstehen.
|
||||
|
||||
## Auslieferung
|
||||
|
||||
- [ ] **Code-Signing.** Das Paket ist nicht signiert, SmartScreen warnt bei
|
||||
jeder Installation vor einem unbekannten Herausgeber. Ein
|
||||
OV-Zertifikat kostet rund 200 EUR im Jahr, ein EV-Zertifikat mehr und
|
||||
braucht Hardware. Solange nur Abdu installiert, reicht ein Klick auf
|
||||
"Trotzdem ausfuehren" - sobald jemand Drittes das Paket bekommt, nicht
|
||||
mehr.
|
||||
- [ ] **CI einmal wirklich laufen lassen.** `.gitea/workflows/release.yml`
|
||||
ist geschrieben, der Runner (`ubuntu-latest`) steht — ausgefuehrt wurde
|
||||
sie aber noch nie. Beim ersten Tag zusehen, ob sie durchlaeuft.
|
||||
- [ ] **Hardware-Encoding.** Der mitgelieferte ffmpeg kann QSV, AMF und NVENC
|
||||
(`--enable-libvpl`, `--enable-amf`, `--enable-ffnvcodec`) - aus WSL2
|
||||
heraus ging das nicht. `clipper.ffmpeg.encoder` steht weiter auf
|
||||
`libx264`. Auf Abdus Rechner messen, ob `h264_qsv` etwas bringt.
|
||||
|
||||
## Discord
|
||||
|
||||
- [ ] **Den Bot unter Last sehen.** Gleichzeitige Klicks auf den Rollen-Knopf
|
||||
sind nie vorgekommen, ebenso wenig eine Laufzeit über Tage. Die
|
||||
Gateway-Verbindung kann fortsetzen und wiederverbinden, aber beides ist
|
||||
nur gegen einen nachgebauten Server geprüft.
|
||||
- [ ] **Onboarding einschalten, sobald der Server groß genug ist.** Discord
|
||||
verlangt sieben Vorgabekanäle mit fünf beschreibbaren, wir haben fünf und
|
||||
drei. Die Frage liegt fertig auf dem Server, nur ausgeschaltet. Dann
|
||||
könnte der Knopf entfallen und mit ihm die Gateway-Verbindung.
|
||||
- [ ] **Twitch-Konto unter Integrationen verbinden**, damit Subs automatisch
|
||||
eine Rolle bekommen.
|
||||
- [ ] **Stammgast automatisch vergeben.** Bisher von Hand. Die Daten dafür
|
||||
liegen längst in der Datenbank, es fehlt die Brücke zwischen
|
||||
Twitch-Konto und Discord-Konto.
|
||||
|
||||
## Nach dem MVP
|
||||
|
||||
- [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden
|
||||
@@ -72,11 +110,5 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
|
||||
|
||||
## Kleinigkeiten
|
||||
|
||||
- [ ] `docker compose run` reicht ein TTY durch, dadurch schreibt die
|
||||
Fortschrittsanzeige `\r`-Zeilen ins Protokoll. Mit `-T` sauber, aber
|
||||
unschön als Standard.
|
||||
- [ ] `ldconfig` meldet beim Bauen `libwhisper.so.1 is not a symbolic link`.
|
||||
Funktioniert, ist aber unsauber.
|
||||
- [ ] `docs/05-setup.md` nennt ffmpeg 6+; im Image steckt 5.1.9 aus Debian
|
||||
bookworm. Für unseren Filtergraph irrelevant, die Angabe stimmt trotzdem
|
||||
nicht.
|
||||
- [ ] Die Fortschrittsanzeige schreibt `\r`-Zeilen; im Protokoll der Aufgabe
|
||||
sieht das unschoen aus. Beim Umleiten in eine Datei besser unterdruecken.
|
||||
@@ -15,3 +15,4 @@ Entscheidungen, Fallstricke, Abnahmekriterien.
|
||||
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
|
||||
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
|
||||
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
|
||||
| — | [Discord-Community](discord-community.md) | `packages/discord` |
|
||||
@@ -95,7 +95,7 @@ ffmpeg -i in.mp4 -filter_complex "
|
||||
Nachmessen und Vorschau rendern:
|
||||
|
||||
```powershell
|
||||
docker compose run --rm toolkit calibrate
|
||||
abdulive calibrate
|
||||
```
|
||||
|
||||
Das legt drei Bilder in `data/clips/_calibration/` ab: das Einzelbild, dasselbe
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# Discord-Community
|
||||
|
||||
**Wirkrichtung:** Bindung. Twitch ist der Ort, an dem Leute zuschauen. Discord
|
||||
ist der Ort, an dem sie zwischen den Streams noch da sind. Ohne den zweiten Ort
|
||||
faengt jeder Stream bei null an.
|
||||
|
||||
Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin:
|
||||
[Go-Live-Broadcaster](go-live-broadcaster.md), [Stream-Recap](stream-recap.md)
|
||||
und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo
|
||||
und nicht nur in einer Oberflaeche.
|
||||
|
||||
## Leitgedanke: klein anfangen
|
||||
|
||||
Der Kanal hat 5 bis 30 gleichzeitige Zuschauer. Daraus werden realistisch 20
|
||||
bis 100 Discord-Mitglieder, von denen eine Handvoll schreibt.
|
||||
|
||||
Ein Server mit zwanzig Kanaelen sieht bei dieser Groesse nicht nach einer
|
||||
grossen Community aus, sondern nach einer verlassenen. Zwanzig leere Kanaele
|
||||
sagen jedem Neuankoemmling: hier ist nichts los. Fuenf Kanaele, in denen etwas
|
||||
steht, sagen das Gegenteil.
|
||||
|
||||
Also: **fuenf sichtbare Kanaele.** Wachstum kommt spaeter, und nur dann, wenn
|
||||
ein bestehender Kanal wirklich zu voll wird. Wann das so weit ist, steht weiter
|
||||
unten unter [Wann ein Kanal dazukommt](#wann-ein-kanal-dazukommt).
|
||||
|
||||
## Die Struktur
|
||||
|
||||
Der Bauplan liegt in [`packages/discord/src/plan.ts`](../../packages/discord/src/plan.ts)
|
||||
und ist die massgebliche Fassung. Was hier steht, ist die Begruendung dazu.
|
||||
|
||||
### Kanaele
|
||||
|
||||
| Kategorie | Kanal | Wer schreibt | Wofuer |
|
||||
|---|---|---|---|
|
||||
| 📌 Info | `👋│willkommen` | niemand | Die drei Regeln, einmal gepostet |
|
||||
| 📌 Info | `🙋│begrüßung` | alle | Discords Beitrittsmeldungen |
|
||||
| 📌 Info | `📢│ankündigungen` | nur Webhooks | Stream-Start und Recap; Diskussion in Threads |
|
||||
| 🎮 Community | `💬│allgemein` | alle | Der Hauptkanal. Im Zweifel hier |
|
||||
| 🎮 Community | `🎬│clips` | alle + Clip-Knopf | Gute Momente |
|
||||
| 🎮 Community | `🎮│spielvorschläge` | alle (Forum) | Ein Beitrag je Spiel, abgestimmt per 👍 |
|
||||
| 🎮 Community | `👀│reaction-einsendungen` | alle (Forum) | Ein Beitrag je Clip, auf den Abdu reagieren soll |
|
||||
| 🎮 Community | `🔊 Zusammen zocken` | — | Sprachkanal |
|
||||
| 🔒 Intern | `🔨│mod-chat` | Mods | Fuer alle anderen unsichtbar |
|
||||
| 🔒 Intern | `🤖│technik-log` | Toolkit | Probelaeufe, Fehler, AutoMod-Treffer |
|
||||
|
||||
Zu den Namen: Discord schreibt Textkanaele klein und macht aus Leerzeichen
|
||||
Bindestriche. Emoji, Umlaute und das Trennzeichen `│` (U+2502) bleiben stehen -
|
||||
am echten Server nachgemessen, nicht angenommen. `normalizeName()` bildet das
|
||||
ab, damit nicht bei jedem Lauf dieselbe Umbenennung erneut rausgeht.
|
||||
|
||||
Und zur Schreibweise: im Quelltext bleibt es bei `ae/oe/ue` wie im uebrigen
|
||||
Repo, aber alles, was ein Mitglied auf Discord liest, steht in richtigem
|
||||
Deutsch. „Sei anstaendig" liest sich wie ein Tippfehler, und der Text im
|
||||
Server ist der erste Eindruck, den jemand davon bekommt.
|
||||
|
||||
`📢│ankündigungen` ist schreibgeschuetzt, erlaubt aber Reaktionen und Threads.
|
||||
So bleibt die Kanalansicht eine saubere Liste von Streams, und die Diskussion
|
||||
ueber das Recap haengt trotzdem direkt daran.
|
||||
|
||||
`🙋│begrüßung` gibt es, weil Discord die Beitrittsmeldungen sonst in den
|
||||
ersten Kanal des Servers schreibt, und das war `💬│allgemein`. Dort gingen die
|
||||
Meldungen im Gespräch unter und das Gespräch in den Meldungen. Der Kanal ist
|
||||
beschreibbar, sonst tut der Winke-Knopf unter der Meldung nichts. Welcher
|
||||
Kanal es ist, steht in `SYSTEM_CHANNEL`.
|
||||
|
||||
Die beiden Foren hat Abdu als Textkanäle angelegt, seit 2026-10-17 stehen sie
|
||||
als Foren im Bauplan. In einem Textkanal schlägt nach drei Wochen jemand
|
||||
dasselbe Spiel noch einmal vor, weil der erste Vorschlag weggescrollt ist. Im
|
||||
Forum gibt es einen Beitrag je Spiel, darunter wird per 👍 abgestimmt. Die
|
||||
Tags für den Stand („Wird gespielt", „Gespielt", „Eher nicht" bzw. „Kommt
|
||||
dran", „Angeschaut") setzen nur Mods. Sonst markiert jeder sein eigenes Spiel
|
||||
als „wird gespielt". Discord wandelt einen Textkanal nicht in ein Forum um:
|
||||
die leeren Textkanäle wurden einmalig von Hand gelöscht, das Skript selbst
|
||||
löscht weiterhin nichts. Foren gehen auch ohne Community-Server, am echten
|
||||
Server nachgeprüft.
|
||||
|
||||
Der Sprachkanal ist bewusst dabei, obwohl er anfangs leer sein wird. Ein
|
||||
Sprachkanal, den zwei Leute nutzen, ist der Anfang; einer, den es nicht gibt,
|
||||
wird nie einer.
|
||||
|
||||
### Rollen
|
||||
|
||||
Die Reihenfolge ist zugleich die Rangfolge auf dem Server.
|
||||
|
||||
| Rolle | Sichtbar | Wofuer |
|
||||
|---|---|---|
|
||||
| `Abdu` | ja | Der Kanal selbst. Administrator |
|
||||
| `Mod` | ja | Loeschen, stummschalten, rauswerfen |
|
||||
| `Stammgast` | ja | Auszeichnung fuer Leute, die regelmaessig da sind |
|
||||
| `Live-Ping` | nein | Wird bei Stream-Start angepingt, selbst vergeben |
|
||||
|
||||
**`Abdu`** traegt `Administrator`. Das Recht schliesst alle anderen ein und
|
||||
setzt jede Kanal-Ausnahme ausser Kraft, auch kuenftige. Es gehoert deshalb an
|
||||
genau ein Konto und an kein zweites. Ein Test im Repo haelt fest, dass keine
|
||||
andere Rolle es bekommt.
|
||||
|
||||
**`Live-Ping` ist die wichtigste der vier.** Ein Rundruf an alle bei jedem
|
||||
Stream ist der schnellste Weg, dass die Leute den Server stummschalten - und
|
||||
ein stummgeschalteter Server ist ein toter Server. Angepingt wird nur, wer sich
|
||||
die Rolle selbst geholt hat. Das sind weniger Leute, aber es sind die, die
|
||||
kommen.
|
||||
|
||||
### Der Knopf fuer Live-Ping
|
||||
|
||||
`Live-Ping` nuetzt nur, wenn man sie sich selbst geben kann. Discord bietet
|
||||
dafuer **Onboarding** an, aber das laesst sich hier nicht einschalten:
|
||||
|
||||
```
|
||||
HTTP 400 Cannot enable onboarding, requirements are not met (350000)
|
||||
|
||||
sichtbar fuer @everyone: 5 davon beschreibbar: 3
|
||||
Discord verlangt: 7 davon beschreibbar: 5
|
||||
```
|
||||
|
||||
Zwei Kanaele anzulegen, damit eine Schwelle passt, macht den Server leerer
|
||||
statt besser - genau das, wogegen der ganze Entwurf gebaut ist. Also
|
||||
stattdessen ein **Knopf unter dem Willkommenstext**. Ein Druck holt die Rolle,
|
||||
der naechste gibt sie wieder ab, und die Antwort sieht nur, wer geklickt hat.
|
||||
|
||||
Der Knopf braucht einen laufenden Bot: Discord liefert Klicks entweder ans
|
||||
Gateway oder an eine oeffentlich erreichbare HTTPS-Adresse, und der Stream-PC
|
||||
hat keine. Die Gateway-Verbindung haengt deshalb im Ingest-Daemon mit drin
|
||||
([`gateway.ts`](../../packages/discord/src/gateway.ts)) und meldet sich als
|
||||
`discordConnected` auf der Statusseite. Zum Ausprobieren einzeln:
|
||||
|
||||
```powershell
|
||||
abdulive discord:bot
|
||||
```
|
||||
|
||||
Die Verbindung fordert **keine** Absichten an (`intents: 0`). Interaktionen
|
||||
kommen trotzdem an, aber Nachrichteninhalte und Mitgliederlisten sieht der Bot
|
||||
nicht - was man nicht anfordert, kann man auch nicht versehentlich mitlesen.
|
||||
|
||||
Welche Knoepfe es gibt, steht in `SELF_ROLES` in `plan.ts`. Die `custom_id`
|
||||
steht dauerhaft in der Nachricht und darf sich nie aendern, sonst tun alte
|
||||
Knoepfe nichts mehr.
|
||||
|
||||
**`Stammgast`** hat fast keine zusaetzlichen Rechte, und das ist Absicht. Der
|
||||
Wert liegt darin, dass der Name farbig ist und in der Mitgliederliste oben
|
||||
steht. Bei dieser Kanalgroesse ist sichtbare Zugehoerigkeit der wirksamste
|
||||
Hebel, den es gibt, und er kostet nichts.
|
||||
|
||||
**`Mod`** bekommt kein `Server verwalten`, kein `Rollen verwalten` und kein
|
||||
`Kanaele verwalten`. Moderation heisst aufraeumen, nicht umbauen. Wer umbauen
|
||||
soll, bekommt das Recht einzeln.
|
||||
|
||||
### Was @everyone darf
|
||||
|
||||
Gesetzt wird eine vollstaendige Liste statt Discords Vorgabe - die aendert sich
|
||||
mit der Zeit, und dann steht irgendwann ein Recht drin, das niemand vergeben
|
||||
hat. Ausdruecklich **nicht** enthalten: `@everyone` erwaehnen, TTS-Nachrichten,
|
||||
private Threads, saemtliche Verwaltungsrechte.
|
||||
|
||||
Ausdruecklich **enthalten**: Einladungen erstellen. Ein Server, in den
|
||||
Mitglieder niemanden einladen koennen, waechst nur so schnell, wie Abdu selbst
|
||||
Links verteilt.
|
||||
|
||||
### Serverweit
|
||||
|
||||
- **Sicherheitsstufe „mittel"** - das Konto muss aelter als fuenf Minuten sein.
|
||||
Kostet echte Leute nichts und haelt Wegwerf-Konten draussen. Ein oeffentlich
|
||||
von Twitch aus verlinkter Server bekommt die.
|
||||
- **Benachrichtigungen nur bei Erwaehnung.** Sonst klingelt jede Nachricht bei
|
||||
jedem. Siehe oben, stummgeschalteter Server.
|
||||
- **Zwei AutoMod-Regeln**: Erwaehnungs-Spam (mehr als fuenf auf einmal) und
|
||||
Discords eigene Spam-Erkennung. Beide blocken die Nachricht und melden sie in
|
||||
`🤖│technik-log`. Kein Wortfilter - der kostet bei dieser Groesse mehr Aerger
|
||||
durch Fehlalarme, als er an Spam verhindert.
|
||||
|
||||
## Einrichten
|
||||
|
||||
### 1. Von Hand: Server und Bot
|
||||
|
||||
Beides geht nur ueber die Oberflaeche, ein Skript darf das nicht.
|
||||
|
||||
1. **Server anlegen.** In Discord links unten auf `+`, „Eigenen erstellen",
|
||||
Name und Bild setzen.
|
||||
2. **Entwicklermodus einschalten** (Einstellungen > Erweitert), dann Rechtsklick
|
||||
auf den Server > „Server-ID kopieren".
|
||||
3. **App anlegen** unter <https://discord.com/developers/applications>:
|
||||
„New Application", dann links „Bot". Dort „Reset Token", Token kopieren.
|
||||
Er wird genau einmal angezeigt.
|
||||
4. **Bot einladen.** Client-ID aus „General Information" einsetzen:
|
||||
|
||||
```
|
||||
https://discord.com/oauth2/authorize?client_id=DEINE_CLIENT_ID&permissions=805375024&scope=bot
|
||||
```
|
||||
|
||||
Die Zahl sind genau die sieben Rechte, die das Skript braucht: Kanaele,
|
||||
Rollen, Webhooks und Server verwalten, lesen, schreiben, Verlauf lesen.
|
||||
Kein Administrator.
|
||||
|
||||
### 2. Der Befehl
|
||||
|
||||
```powershell
|
||||
abdulive discord:setup --server <SERVER-ID> --vorschau
|
||||
```
|
||||
|
||||
Die Vorschau liest den Server aus und zeigt Zeile fuer Zeile, was passieren
|
||||
wuerde. Sie schreibt nichts. Sieht das gut aus, dasselbe ohne `--vorschau`:
|
||||
|
||||
```powershell
|
||||
abdulive discord:setup --server <SERVER-ID>
|
||||
```
|
||||
|
||||
Der Bot-Token wird beim ersten Lauf abgefragt (die Eingabe wird nicht
|
||||
angezeigt) und danach zusammen mit der Server-ID verschluesselt abgelegt -
|
||||
siehe [ADR-0005](../decisions/ADR-0005-zugangsdaten.md). Ab dem zweiten Lauf
|
||||
genuegt `abdulive discord:setup`.
|
||||
|
||||
Die drei Webhook-URLs landet das Skript direkt im selben Speicher, unter
|
||||
`goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Sie werden nie ausgegeben
|
||||
und nie geloggt - Broadcaster, Recap und Clipper lesen sie von dort.
|
||||
|
||||
### 3. Von Hand: der Rest
|
||||
|
||||
Drei Dinge kann die API nicht oder nicht sinnvoll:
|
||||
|
||||
1. **Dir selbst `Abdu` geben** und die Bot-Rolle in den Servereinstellungen
|
||||
ueber `Abdu` ziehen. Eine Rolle kann nur Rollen unterhalb der eigenen
|
||||
verschieben - auch mit Administrator. Discord haengt die Bot-Rolle beim
|
||||
Einladen ganz unten ein, dort ist also kein Platz fuer vier Rollen darunter.
|
||||
Bis das erledigt ist, meldet das Skript die Rangfolge als uebersprungen und
|
||||
nennt den Grund.
|
||||
2. **Einladungslink** erzeugen, ohne Ablaufdatum, und ins Twitch-Panel, in die
|
||||
Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream
|
||||
niemand erwaehnt, bleibt leer.
|
||||
3. **Twitch-Konto verbinden** (Servereinstellungen > Integrationen). Danach
|
||||
bekommen Subs automatisch eine Rolle.
|
||||
|
||||
Onboarding steht bewusst nicht in der Liste - warum, steht oben unter
|
||||
[Der Knopf fuer Live-Ping](#der-knopf-fuer-live-ping).
|
||||
|
||||
## Was das Skript nicht tut
|
||||
|
||||
**Es loescht nichts.** Keine Kanaele, keine Rollen, keine Nachrichten. Ein
|
||||
Einrichtungsskript, das aufraeumt, raeumt irgendwann das Falsche weg. Was von
|
||||
Hand dazugekommen ist, bleibt stehen - auch eigene Rechte-Ausnahmen in einem
|
||||
Kanal, den das Skript anfasst.
|
||||
|
||||
**Es ueberschreibt den Willkommenstext nicht.** Gepostet wird nur, wenn
|
||||
`#willkommen` leer ist. Danach gehoert der Text der Hand, die ihn zuletzt
|
||||
angefasst hat.
|
||||
|
||||
**Es laesst von Discord verwaltete Rollen in Ruhe.** Rollen, die zu einer App
|
||||
oder zur Twitch-Anbindung gehoeren, meldet es als uebersprungen.
|
||||
|
||||
Wiederholtes Ausfuehren ist der Normalfall: gefunden wird ueber den Namen,
|
||||
danach wird angeglichen statt neu angelegt. Der zweite Lauf aendert nichts.
|
||||
|
||||
## Wann ein Kanal dazukommt
|
||||
|
||||
Nicht auf Verdacht. Jeweils erst, wenn die Bedingung wirklich eintritt:
|
||||
|
||||
| Neuer Kanal | Erst wenn |
|
||||
|---|---|
|
||||
| `#vorschlaege` | In `#allgemein` gehen Vorschlaege zwischen anderem unter |
|
||||
| `#fotos` oder `#offtopic` | `#allgemein` besteht ueberwiegend aus einem Thema, das nicht der Stream ist |
|
||||
| Spiel-eigener Kanal | Zu einem Spiel gibt es ueber Wochen taeglich Gespraech |
|
||||
| Zweiter Sprachkanal | Der erste ist regelmaessig belegt, wenn jemand dazu will |
|
||||
| Forum statt `#clips` | Mehr als etwa zehn Clips pro Woche |
|
||||
|
||||
Umgekehrt gilt genauso: ein Kanal, in dem vier Wochen nichts steht, kommt weg.
|
||||
|
||||
## Fallstricke
|
||||
|
||||
- **Die Bot-Rolle steht anfangs unten.** Discord haengt sie beim Einladen
|
||||
unterhalb bestehender Rollen ein. Dann schlaegt das Setzen der Rangfolge
|
||||
fehl. Das Skript bricht deswegen nicht ab, sondern meldet es - nachziehen und
|
||||
noch einmal laufen lassen.
|
||||
- **Von Hand angelegte Webhooks geben ihren Token nicht heraus.** Wer einen
|
||||
Webhook in der Oberflaeche neu anlegt, bekommt eine URL, die das Skript nicht
|
||||
auslesen kann. Es meldet ihn als uebersprungen; loeschen und noch einmal
|
||||
laufen lassen loest es.
|
||||
- **Kanalnamen lassen sich nur zweimal in zehn Minuten aendern.** Discord
|
||||
verlangt danach bis zu neun Minuten Pause. Der Lauf wartet hoechstens eine
|
||||
Minute, meldet den Kanal als uebersprungen und macht weiter - spaeter noch
|
||||
einmal ausfuehren holt es nach.
|
||||
- **Umbenennen statt verdoppeln.** Aendert sich ein Name im Bauplan, gehoert
|
||||
der alte in `aliases`. Sonst findet das Skript den Kanal nicht wieder und
|
||||
legt einen zweiten daneben an. Ein Test besteht darauf, dass jeder Eintrag
|
||||
einen frueheren Namen hinterlegt hat.
|
||||
- **Ankuendigungskanaele im Discord-Sinn** (die, denen andere Server folgen
|
||||
koennen) brauchen einen Community-Server, und der braucht Regelkanal,
|
||||
Update-Kanal und einige Einstellungen. Fuer diese Groesse bringt das nichts,
|
||||
deshalb ist `#ankuendigungen` ein normaler Textkanal mit Schreibschutz.
|
||||
- **Ein Ping ist keine Reichweite.** Wer `Live-Ping` nicht anbietet und
|
||||
stattdessen `@everyone` benutzt, hat beim dritten Mal einen stummen Server.
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
- `abdulive discord:setup --vorschau` laeuft auf einem frischen Server durch und
|
||||
meldet keinen uebersprungenen Punkt.
|
||||
- Nach dem echten Lauf existieren drei Kategorien, sieben Kanaele, vier Rollen
|
||||
und drei Webhooks; `👋│willkommen` enthaelt genau eine Nachricht.
|
||||
- Ein zweiter Lauf meldet ausschliesslich „steht" und legt nichts doppelt an.
|
||||
- Ein Konto ohne Rolle sieht `Intern` nicht und kann in `#ankuendigungen` nicht
|
||||
schreiben, aber einen Thread eroeffnen.
|
||||
- Die Webhook-URLs stehen im verschluesselten Speicher und tauchen in keiner
|
||||
Ausgabe und keinem Log auf.
|
||||
- Der Knopf unter dem Willkommenstext vergibt `Live-Ping` und nimmt sie beim
|
||||
zweiten Druck wieder weg; die Antwort sieht nur, wer geklickt hat.
|
||||
+4
-6
@@ -10,18 +10,16 @@
|
||||
"imports": {
|
||||
"#shared/*": "./packages/shared/src/*",
|
||||
"#core/*": "./packages/core/src/*",
|
||||
"#discord/*": "./packages/discord/src/*",
|
||||
"#db/*": "./packages/db/src/*",
|
||||
"#twitch/*": "./packages/twitch/src/*",
|
||||
"#obs/*": "./packages/obs/src/*",
|
||||
"#media/*": "./packages/media/src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "docker compose up -d",
|
||||
"stop": "docker compose down",
|
||||
"status": "docker compose ps",
|
||||
"logs": "docker compose logs -f",
|
||||
"check-env": "node docker/entrypoint.ts check-env",
|
||||
"ingest": "node docker/entrypoint.ts ingest",
|
||||
"start": "node apps/cli/main.ts ingest",
|
||||
"check-env": "node apps/cli/main.ts check-env",
|
||||
"package": "node scripts/build-package.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test \"packages/**/*.test.ts\" \"apps/**/*.test.ts\""
|
||||
},
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseYaml, type YamlMap } from './yaml.ts';
|
||||
import { defaultDirs } from './platform.ts';
|
||||
|
||||
// Die Vorgaben haengen am Betriebssystem (siehe platform.ts); die
|
||||
// Umgebungsvariablen ueberschreiben sie wie bisher.
|
||||
const dirs = defaultDirs();
|
||||
|
||||
export const paths = {
|
||||
config: process.env.CONFIG_DIR || '/app/config',
|
||||
db: process.env.DB_DIR || '/db',
|
||||
work: process.env.WORK_DIR || '/work',
|
||||
clips: process.env.CLIPS_DIR || '/app/data/clips',
|
||||
replay: process.env.REPLAY_DIR || '/replay',
|
||||
models: process.env.MODEL_DIR || '/models',
|
||||
config: process.env.CONFIG_DIR || dirs.config,
|
||||
db: process.env.DB_DIR || dirs.db,
|
||||
work: process.env.WORK_DIR || dirs.work,
|
||||
clips: process.env.CLIPS_DIR || dirs.clips,
|
||||
replay: process.env.REPLAY_DIR || dirs.replay,
|
||||
models: process.env.MODEL_DIR || dirs.models,
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
@@ -27,6 +32,7 @@ export type Config = {
|
||||
};
|
||||
};
|
||||
obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] };
|
||||
update: { url: string };
|
||||
clipper: {
|
||||
enabled: boolean;
|
||||
alsoCreateTwitchClip: boolean;
|
||||
@@ -39,6 +45,41 @@ 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' | 'liegestuetze' | 'nichts';
|
||||
freigabe?: boolean; maxZeichen?: number; anzahl?: number;
|
||||
}>;
|
||||
tts: {
|
||||
freigabe: boolean; maxZeichen: number;
|
||||
blockliste: string[]; proPersonProStunde: number;
|
||||
/** Piper-Stimme: Name im Modellordner (models/piper/<name>.onnx) oder absoluter Pfad. */
|
||||
stimme: string;
|
||||
/** Sprecher-Nummer bei Stimmen mit mehreren Sprechern. Leer: der erste. */
|
||||
sprecher?: 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;
|
||||
@@ -57,7 +98,8 @@ const DEFAULTS = {
|
||||
laughEmotes: ['KEKW', 'LUL', 'OMEGALUL', 'PogChamp', 'Pog'],
|
||||
},
|
||||
},
|
||||
obs: { url: 'ws://host.docker.internal:4455', replayBufferSeconds: 90, chapterScenes: [] },
|
||||
obs: { url: 'ws://127.0.0.1:4455', replayBufferSeconds: 90, chapterScenes: [] },
|
||||
update: { url: '' },
|
||||
clipper: {
|
||||
enabled: true,
|
||||
alsoCreateTwitchClip: true,
|
||||
@@ -74,6 +116,41 @@ 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' | 'liegestuetze' | 'nichts';
|
||||
freigabe?: boolean; maxZeichen?: number; anzahl?: 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,
|
||||
stimme: 'de_DE-thorsten-medium',
|
||||
},
|
||||
},
|
||||
overlay: { port: 4791, enabled: true },
|
||||
publicUrl: '',
|
||||
ingestServer: { port: 4790 },
|
||||
dashboard: { port: 4792 },
|
||||
};
|
||||
@@ -98,7 +175,12 @@ export function loadConfig(): Config {
|
||||
if (fs.existsSync(file)) {
|
||||
raw = parseYaml(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
cached = { ...merge(DEFAULTS, raw), raw } as Config;
|
||||
const merged = merge(DEFAULTS, raw) as Omit<Config, 'raw'>;
|
||||
// Die Umgebung gewinnt: im Container steht die oeffentliche Adresse in der
|
||||
// Unit-Datei, nicht in einer YAML, die mitversioniert wird.
|
||||
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
|
||||
merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
|
||||
cached = { ...merged, raw } as Config;
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Wo die Anwendung ihre Programme, Schriften und Daten findet.
|
||||
//
|
||||
// Bis 2026-09 lief alles in einem Container: ffmpeg lag im PATH, die Daten
|
||||
// unter festen Linux-Absolutpfaden (/db, /work, /models). Nativ unter Windows
|
||||
// gibt es beides nicht - die Programme liegen neben der Anwendung, die Daten
|
||||
// unter %ProgramData%. Siehe ADR-0007.
|
||||
//
|
||||
// Die Linux-Pfade bleiben als Vorgabe stehen, damit die Tests im Container
|
||||
// unveraendert laufen und ein Betrieb unter Linux moeglich bleibt.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const isWindows = process.platform === 'win32';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Wurzel des Quellbaums - im Paket der Ordner `app`. */
|
||||
export const appRoot = path.resolve(HERE, '..', '..', '..');
|
||||
|
||||
/**
|
||||
* Wurzel der Installation. Im Paket liegt darin `app/`, `bin/`, `fonts/` und
|
||||
* `node.exe`; im Quell-Checkout ist es schlicht der Ordner ueber dem Repo und
|
||||
* enthaelt nichts davon - dann greift die Suche im PATH.
|
||||
*/
|
||||
export const installRoot = path.resolve(appRoot, '..');
|
||||
|
||||
/** Verzeichnisse, in denen mitgelieferte Dateien gesucht werden. */
|
||||
const bundleDirs = (sub: string): string[] =>
|
||||
[path.join(installRoot, sub), path.join(appRoot, sub)];
|
||||
|
||||
const found = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Ein Programm auflösen: erst die Umgebungsvariable, dann das mitgelieferte
|
||||
* `bin/`, sonst der blanke Name - dann sucht das Betriebssystem im PATH.
|
||||
*
|
||||
* Die Reihenfolge ist Absicht. Die Umgebungsvariable gewinnt, damit sich eine
|
||||
* kaputte Mitlieferung ohne Neuinstallation ueberschreiben laesst.
|
||||
*/
|
||||
export function findBinary(name: string, envVar: string): string {
|
||||
const fromEnv = process.env[envVar];
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const cached = found.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const exe = isWindows ? `${name}.exe` : name;
|
||||
for (const dir of bundleDirs('bin')) {
|
||||
const p = path.join(dir, exe);
|
||||
if (fs.existsSync(p)) { found.set(name, p); return p; }
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export const binaries = {
|
||||
get ffmpeg(): string { return findBinary('ffmpeg', 'FFMPEG_BIN'); },
|
||||
get ffprobe(): string { return findBinary('ffprobe', 'FFPROBE_BIN'); },
|
||||
get whisper(): string { return findBinary('whisper-cli', 'WHISPER_BIN'); },
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordner mit den mitgelieferten Schriften, oder `undefined`.
|
||||
*
|
||||
* Im Container kam DejaVu Sans aus `fonts-dejavu-core` und libass fand sie
|
||||
* ueber fontconfig. Windows hat DejaVu nicht. Ohne diesen Ordner setzt libass
|
||||
* still eine Ersatzschrift ein - der Untertitel sieht dann anders aus, ohne
|
||||
* dass irgendetwas eine Warnung ausgibt.
|
||||
*/
|
||||
export function fontsDir(): string | undefined {
|
||||
if (process.env['FONTS_DIR']) return process.env['FONTS_DIR'];
|
||||
for (const dir of bundleDirs('fonts')) if (fs.existsSync(dir)) return dir;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Vorgabe fuer die Datenverzeichnisse, je nach Betriebssystem. */
|
||||
export function defaultDirs(): Record<
|
||||
'config' | 'db' | 'work' | 'clips' | 'replay' | 'models', string
|
||||
> {
|
||||
if (!isWindows) {
|
||||
return {
|
||||
config: '/app/config', db: '/db', work: '/work',
|
||||
clips: '/app/data/clips', replay: '/replay', models: '/models',
|
||||
};
|
||||
}
|
||||
// Nicht neben die Programme: ein Update tauscht den Programmordner
|
||||
// komplett aus, und die Datenbank darf dabei nicht mitgehen.
|
||||
//
|
||||
// LOCALAPPDATA und nicht ProgramData, weil die Anwendung als angemeldeter
|
||||
// Benutzer laeuft - dieselbe Sitzung wie OBS. Damit braucht weder die
|
||||
// Installation noch ein Update Administratorrechte. Genau daran scheitern
|
||||
// Aktualisierungen sonst auf einem Rechner, an dem gerade gestreamt wird.
|
||||
const local = process.env['LOCALAPPDATA'] ??
|
||||
path.join(process.env['USERPROFILE'] ?? 'C:\\', 'AppData', 'Local');
|
||||
const base = path.join(local, 'abdulive');
|
||||
return {
|
||||
config: path.join(base, 'config'),
|
||||
db: path.join(base, 'db'),
|
||||
work: path.join(base, 'work'),
|
||||
clips: path.join(base, 'clips'),
|
||||
replay: path.join(base, 'replay'),
|
||||
models: path.join(base, 'models'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Der YAML-Leser ist selbst geschrieben (ADR-0001: keine Abhaengigkeiten) und
|
||||
// liest die Konfiguration, an der alles haengt - hatte aber bis hierher keinen
|
||||
// einzigen Test. Geprueft wird genau der Sprachumfang, den config/*.yaml
|
||||
// benutzt.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseYaml } from './yaml.ts';
|
||||
|
||||
test('verschachtelte Maps ueber Einrueckung', () => {
|
||||
assert.deepEqual(parseYaml('channel:\n login: abdulive_\n language: de\n'),
|
||||
{ channel: { login: 'abdulive_', language: 'de' } });
|
||||
});
|
||||
|
||||
test('erkennt Zahlen, Wahrheitswerte und Leeres', () => {
|
||||
const y = parseYaml([
|
||||
'a: 42', 'b: -7', 'c: 3.5', 'd: true', 'e: false', 'f:', 'g: ~',
|
||||
].join('\n'));
|
||||
assert.deepEqual(y, { a: 42, b: -7, c: 3.5, d: true, e: false, f: null, g: null });
|
||||
});
|
||||
|
||||
test('Anfuehrungszeichen bleiben Text', () => {
|
||||
// Wichtig fuer update.url - sonst wuerde aus "0.5" eine Zahl
|
||||
const y = parseYaml('update:\n url: "https://git.zeasy.dev/x"\n v: "0.5"\n');
|
||||
assert.deepEqual(y, { update: { url: 'https://git.zeasy.dev/x', v: '0.5' } });
|
||||
});
|
||||
|
||||
test('Inline-Maps, wie sie im Layout stehen', () => {
|
||||
const y = parseYaml('crop: { x: 0, y: 0, w: 480, h: 270 }\n');
|
||||
assert.deepEqual(y, { crop: { x: 0, y: 0, w: 480, h: 270 } });
|
||||
});
|
||||
|
||||
test('Listen aus Skalaren', () => {
|
||||
const y = parseYaml('chapterScenes: [Gameplay, "Just Chatting"]\n');
|
||||
assert.deepEqual(y, { chapterScenes: ['Gameplay', 'Just Chatting'] });
|
||||
});
|
||||
|
||||
test('Kommentare und Leerzeilen stoeren nicht', () => {
|
||||
const y = parseYaml('# Kopfzeile\n\nobs:\n # dazwischen\n url: ws://127.0.0.1:4455\n');
|
||||
assert.deepEqual(y, { obs: { url: 'ws://127.0.0.1:4455' } });
|
||||
});
|
||||
|
||||
test('Zeilenenden nach Windows-Art', () => {
|
||||
// config.yaml wird auf dem Stream-PC bearbeitet
|
||||
assert.deepEqual(parseYaml('a:\r\n b: 1\r\n'), { a: { b: 1 } });
|
||||
});
|
||||
|
||||
test('ein Byte Order Mark verstellt nicht den ersten Schluessel', () => {
|
||||
// Notepad und `Set-Content -Encoding utf8` unter Windows PowerShell 5.1
|
||||
// schreiben eines. Ohne Behandlung hiesse der erste Schluessel "channel",
|
||||
// und die Einstellung wuerde stillschweigend ignoriert - ohne Fehlermeldung.
|
||||
const y = parseYaml('\uFEFFchannel:\n login: abdulive_\n');
|
||||
assert.deepEqual(y, { channel: { login: 'abdulive_' } });
|
||||
assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff));
|
||||
});
|
||||
|
||||
test('Listen aus Maps, wie in rewards.belohnungen', () => {
|
||||
// Frueher zerfiel das in Zeichenketten wie "id: abc", und die Belohnung
|
||||
// war ohne jede Meldung unwirksam.
|
||||
const y = parseYaml([
|
||||
'rewards:',
|
||||
' enabled: true',
|
||||
' belohnungen:',
|
||||
' - id: abc-123',
|
||||
' titel: Nachricht vorlesen',
|
||||
' aktion: tts',
|
||||
' - titel: Konfetti',
|
||||
' aktion: konfetti',
|
||||
' tts:',
|
||||
' blockliste:',
|
||||
' - sieg heil',
|
||||
' - http',
|
||||
' maxZeichen: 200',
|
||||
].join('\n'));
|
||||
assert.deepEqual(y, {
|
||||
rewards: {
|
||||
enabled: true,
|
||||
belohnungen: [
|
||||
{ id: 'abc-123', titel: 'Nachricht vorlesen', aktion: 'tts' },
|
||||
{ titel: 'Konfetti', aktion: 'konfetti' },
|
||||
],
|
||||
tts: { blockliste: ['sieg heil', 'http'], maxZeichen: 200 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('Listeneintrag mit Doppelpunkt ohne Leerzeichen bleibt Text', () => {
|
||||
assert.deepEqual(parseYaml('a:\n - http://x.de\n'), { a: ['http://x.de'] });
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
// Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien.
|
||||
//
|
||||
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
|
||||
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren und
|
||||
// Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
|
||||
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren oder
|
||||
// Maps (`- titel: X`) und Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
|
||||
// kommt ohne npm-Abhaengigkeiten aus, und der Sprachumfang, den wir selbst
|
||||
// schreiben, ist ueberschaubar.
|
||||
|
||||
@@ -51,7 +51,11 @@ function stripComment(line: string): string {
|
||||
type Frame = { indent: number; target: YamlMap; listKey?: string };
|
||||
|
||||
export function parseYaml(text: string): YamlMap {
|
||||
const lines = text.split(/\r?\n/);
|
||||
// Byte Order Mark abschneiden. Notepad und `Set-Content -Encoding utf8`
|
||||
// unter Windows PowerShell 5.1 schreiben eines an den Dateianfang; ohne
|
||||
// diese Zeile heisst der erste Schluessel dann "channel" und die
|
||||
// Einstellung wird stillschweigend ignoriert.
|
||||
const lines = text.replace(/^/, '').split(/\r?\n/);
|
||||
const root: YamlMap = {};
|
||||
const stack: Frame[] = [{ indent: -1, target: root }];
|
||||
let i = 0;
|
||||
@@ -69,7 +73,20 @@ export function parseYaml(text: string): YamlMap {
|
||||
if (body.startsWith('- ')) {
|
||||
const key = frame.listKey;
|
||||
const list = key ? frame.target[key] : undefined;
|
||||
if (Array.isArray(list)) list.push(scalar(body.slice(2)));
|
||||
const item = body.slice(2);
|
||||
// `- titel: X` beginnt eine Map als Listeneintrag. Die Zeile wird mit
|
||||
// der Einrueckung ihres Inhalts neu gelesen, die folgenden, tiefer
|
||||
// eingerueckten Zeilen landen in derselben Map. Ohne das zerfiel
|
||||
// `rewards.belohnungen` in einzelne Zeichenketten, und jede Belohnung
|
||||
// war ohne Fehlermeldung unwirksam.
|
||||
if (Array.isArray(list) && /^[^\s'"{[#-][^:]*:(\s|$)/.test(item)) {
|
||||
const map: YamlMap = {};
|
||||
list.push(map);
|
||||
stack.push({ indent, target: map });
|
||||
lines[i] = ' '.repeat(indent + 2) + item;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(list)) list.push(scalar(item));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import zlib from 'node:zlib';
|
||||
import crypto from 'node:crypto';
|
||||
import { readZip, writeZip } from './zip.ts';
|
||||
|
||||
/**
|
||||
* Baut ein ZIP von Hand. Ein Schreiber gehoert nicht ins Projekt, aber ohne
|
||||
* eigenes Archiv braeuchte der Test eine Beispieldatei im Repo - und die
|
||||
* saehe niemand mehr an.
|
||||
*/
|
||||
function makeZip(files: Array<{ name: string; body: string; deflate?: boolean }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const name = Buffer.from(f.name, 'utf8');
|
||||
const raw = Buffer.from(f.body, 'utf8');
|
||||
const data = f.deflate ? zlib.deflateRawSync(raw) : raw;
|
||||
const method = f.deflate ? 8 : 0;
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, data);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(raw.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + data.length;
|
||||
}
|
||||
|
||||
const dir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(dir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...locals, dir, eocd]);
|
||||
}
|
||||
|
||||
test('liest gespeicherte und komprimierte Eintraege', () => {
|
||||
const zip = makeZip([
|
||||
{ name: 'bin/ffmpeg.exe', body: 'MZ-so-tun-als-ob' },
|
||||
{ name: 'liesmich.txt', body: 'Hallo '.repeat(200), deflate: true },
|
||||
]);
|
||||
const entries = readZip(zip);
|
||||
|
||||
assert.equal(entries.length, 2);
|
||||
assert.equal(entries[0]?.data().toString(), 'MZ-so-tun-als-ob');
|
||||
assert.equal(entries[1]?.data().toString(), 'Hallo '.repeat(200));
|
||||
assert.equal(entries[1]?.size, 1200);
|
||||
});
|
||||
|
||||
test('erkennt Verzeichniseintraege am Schraegstrich', () => {
|
||||
const entries = readZip(makeZip([{ name: 'bin/', body: '' }, { name: 'bin/a', body: 'x' }]));
|
||||
assert.equal(entries[0]?.isDirectory, true);
|
||||
assert.equal(entries[1]?.isDirectory, false);
|
||||
});
|
||||
|
||||
test('findet das Inhaltsverzeichnis auch hinter einem Kommentar', () => {
|
||||
// Der Kommentar steht hinter dem EOCD - von hinten zu suchen reicht nicht,
|
||||
// man muss das Feld tatsaechlich pruefen.
|
||||
const zip = makeZip([{ name: 'a.txt', body: 'inhalt' }]);
|
||||
const withComment = Buffer.concat([zip, Buffer.from('# angehaengter Kommentar')]);
|
||||
withComment.writeUInt16LE(24, withComment.length - 24 - 22 + 20);
|
||||
assert.equal(readZip(withComment)[0]?.data().toString(), 'inhalt');
|
||||
});
|
||||
|
||||
test('sagt deutlich, wenn kein ZIP vorliegt', () => {
|
||||
assert.throws(() => readZip(Buffer.alloc(200)), /Inhaltsverzeichnis/);
|
||||
});
|
||||
|
||||
test('meldet ZIP64 statt still Unsinn zu liefern', () => {
|
||||
const zip = makeZip([{ name: 'a', body: 'x' }]);
|
||||
zip.writeUInt32LE(0xffffffff, zip.length - 22 + 16); // dirStart
|
||||
assert.throws(() => readZip(zip), /ZIP64/);
|
||||
});
|
||||
|
||||
test('Geschriebenes laesst sich wieder lesen', () => {
|
||||
const gross = Buffer.from('x'.repeat(50_000));
|
||||
const zip = writeZip([
|
||||
{ name: 'bin/ffmpeg.exe', data: Buffer.from([0x4d, 0x5a, 0x90, 0x00]) },
|
||||
{ name: 'app/gross.txt', data: gross },
|
||||
]);
|
||||
const back = readZip(zip);
|
||||
assert.deepEqual(back.map((e) => e.name), ['bin/ffmpeg.exe', 'app/gross.txt']);
|
||||
assert.deepEqual(back[1]?.data(), gross);
|
||||
// Gut komprimierbar - der Eintrag muss deutlich kleiner geworden sein
|
||||
assert.ok(zip.length < 5_000, `Archiv ist ${zip.length} Bytes gross`);
|
||||
});
|
||||
|
||||
test('schreibt eine gueltige CRC32 - sonst meckert der Explorer', () => {
|
||||
const data = Buffer.from('Wiedergabepuffer');
|
||||
const zip = writeZip([{ name: 'a.txt', data }]);
|
||||
// Im lokalen Kopf steht die Pruefsumme an Offset 14
|
||||
assert.equal(zip.readUInt32LE(14), zlib.crc32(data));
|
||||
});
|
||||
|
||||
test('legt bereits komprimierte Daten unveraendert ab', () => {
|
||||
// Echter Zufall laesst sich nicht packen, deflate waere groesser als das
|
||||
// Original. Eine gerechnete Folge taugt dafuer nicht - `i * k % 256`
|
||||
// wiederholt sich nach 256 Werten und ist bestens komprimierbar.
|
||||
const rauschen = crypto.randomBytes(4096);
|
||||
const zip = writeZip([{ name: 'r.bin', data: rauschen }]);
|
||||
assert.equal(zip.readUInt16LE(8), 0, 'Verfahren 0 = gespeichert');
|
||||
assert.deepEqual(readZip(zip)[0]?.data(), rauschen);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// ZIP lesen. Node bringt zlib mit, aber kein Archivformat, und das Projekt
|
||||
// kommt ohne Abhaengigkeiten aus (ADR-0001) - wie beim YAML-Parser und beim
|
||||
// obs-websocket-Client also selbst gebaut.
|
||||
//
|
||||
// Gebraucht wird das an zwei Stellen: beim Paketbau kommen ffmpeg,
|
||||
// whisper.cpp und Node als ZIP von ihren Herausgebern, und der Updater auf
|
||||
// Abdus Rechner packt damit das neue Paket aus - ohne PowerShell, ohne
|
||||
// externes Programm.
|
||||
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const EOCD = 0x06054b50; // End of Central Directory
|
||||
const CENTRAL = 0x02014b50; // Eintrag im Inhaltsverzeichnis
|
||||
const LOCAL = 0x04034b50; // Kopf vor den eigentlichen Daten
|
||||
|
||||
export type ZipEntry = {
|
||||
name: string;
|
||||
/** Unkomprimierte Groesse in Bytes. */
|
||||
size: number;
|
||||
/** Verzeichniseintraege haben keine Daten. */
|
||||
isDirectory: boolean;
|
||||
data: () => Buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inhaltsverzeichnis eines ZIP lesen.
|
||||
*
|
||||
* Die Daten werden erst beim Aufruf von `data()` entpackt - beim Paketbau
|
||||
* werden von 800 Eintraegen keine 30 gebraucht.
|
||||
*/
|
||||
export function readZip(buf: Buffer): ZipEntry[] {
|
||||
const eocd = findEocd(buf);
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
const dirSize = buf.readUInt32LE(eocd + 12);
|
||||
const dirStart = buf.readUInt32LE(eocd + 16);
|
||||
|
||||
if (dirStart === 0xffffffff || dirSize === 0xffffffff || count === 0xffff) {
|
||||
throw new Error('ZIP64 wird nicht unterstuetzt - das Archiv ist zu gross.');
|
||||
}
|
||||
|
||||
const entries: ZipEntry[] = [];
|
||||
let p = dirStart;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buf.readUInt32LE(p) !== CENTRAL) {
|
||||
throw new Error(`Beschaedigtes Inhaltsverzeichnis bei Eintrag ${i}`);
|
||||
}
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const compressed = buf.readUInt32LE(p + 20);
|
||||
const size = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
size,
|
||||
isDirectory: name.endsWith('/'),
|
||||
data: () => extract(buf, localOffset, method, compressed, size, name),
|
||||
});
|
||||
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extract(
|
||||
buf: Buffer, localOffset: number, method: number,
|
||||
compressed: number, size: number, name: string
|
||||
): Buffer {
|
||||
if (buf.readUInt32LE(localOffset) !== LOCAL) {
|
||||
throw new Error(`Kein lokaler Dateikopf fuer ${name}`);
|
||||
}
|
||||
// Der lokale Kopf hat eigene Laengen fuer Name und Extra-Feld; sie koennen
|
||||
// von denen im Inhaltsverzeichnis abweichen und muessen von hier kommen.
|
||||
const nameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const extraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + nameLen + extraLen;
|
||||
const raw = buf.subarray(start, start + compressed);
|
||||
|
||||
if (method === 0) return Buffer.from(raw);
|
||||
if (method === 8) {
|
||||
const out = zlib.inflateRawSync(raw);
|
||||
if (out.length !== size) {
|
||||
throw new Error(`${name}: ${out.length} Bytes entpackt, ${size} erwartet`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
throw new Error(`${name}: unbekanntes Kompressionsverfahren ${method}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Inhaltsverzeichnis steht am Dateiende, hinter einem Kommentar
|
||||
* beliebiger Laenge. Also von hinten suchen - hoechstens 64 KB weit, mehr
|
||||
* darf der Kommentar nicht sein.
|
||||
*/
|
||||
function findEocd(buf: Buffer): number {
|
||||
const min = Math.max(0, buf.length - 0x10000 - 22);
|
||||
for (let i = buf.length - 22; i >= min; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD) return i;
|
||||
}
|
||||
throw new Error('Kein ZIP-Inhaltsverzeichnis gefunden.');
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP schreiben. Nur so viel Format, wie der Windows-Explorer und
|
||||
* `Expand-Archive` verlangen - insbesondere die CRC32-Pruefsumme, sonst
|
||||
* halten beide das Archiv fuer beschaedigt.
|
||||
*/
|
||||
export function writeZip(files: Array<{ name: string; data: Buffer }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const name = Buffer.from(f.name.replace(/\\/g, '/'), 'utf8');
|
||||
const packed = zlib.deflateRawSync(f.data, { level: 9 });
|
||||
// Groesser als das Original kommt bei schon komprimierten Dateien vor -
|
||||
// dann unveraendert ablegen.
|
||||
const useDeflate = packed.length < f.data.length;
|
||||
const body = useDeflate ? packed : f.data;
|
||||
const crc = zlib.crc32(f.data);
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(LOCAL, 0);
|
||||
local.writeUInt16LE(20, 4); // benoetigte Version: 2.0
|
||||
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(f.data.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, body);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(CENTRAL, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(body.length, 20);
|
||||
central.writeUInt32LE(f.data.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + body.length;
|
||||
}
|
||||
|
||||
const dir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(EOCD, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(dir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
|
||||
const out = Buffer.concat([...locals, dir, eocd]);
|
||||
if (out.length > 0xffffffff) throw new Error('Ueber 4 GB braeuchte ZIP64.');
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Was das Toolkit nach draussen geschickt hat. Eine Zeile je Plattform und
|
||||
-- Anlass.
|
||||
--
|
||||
-- Der Zweck ist nicht Statistik, sondern Verhinderung: Twitch schickt nach
|
||||
-- einem Verbindungsabbruch ein zweites stream.online, und ohne diese Tabelle
|
||||
-- bekommt der Discord-Server dann einen zweiten Rundruf. Ein Ping zu viel
|
||||
-- kostet Abonnenten der Rolle, ein Ping zu wenig kostet nur einen Stream.
|
||||
|
||||
-- stream_id ist bewusst KEIN Fremdschluessel. Ein Post ist die Quittung fuer
|
||||
-- etwas, das den Rechner verlassen hat; die muss auch dann schreibbar sein,
|
||||
-- wenn es die Sitzung in `streams` nicht gibt - beim Probelauf von
|
||||
-- `golive:test` etwa. Ein Fremdschluessel wuerde hier ausgerechnet das
|
||||
-- Protokoll verhindern, das erklaeren soll, was passiert ist.
|
||||
CREATE TABLE posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
stream_id TEXT,
|
||||
kind TEXT NOT NULL, -- golive | recap | clip
|
||||
platform TEXT NOT NULL, -- discord | bluesky | mastodon | ...
|
||||
mode TEXT NOT NULL, -- live | draft
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- gesendet | fehlgeschlagen | entwurf
|
||||
title TEXT,
|
||||
category TEXT,
|
||||
url TEXT, -- Nachricht auf der Zielplattform
|
||||
detail TEXT -- Fehlertext, wenn es schiefging
|
||||
);
|
||||
|
||||
-- Die Frage, die vor jedem Post gestellt wird: gab es das hier schon?
|
||||
-- Nur gesendete Posts sperren; ein fehlgeschlagener Versuch darf wiederholt
|
||||
-- werden, sonst kostet ein Netzwerkaussetzer die Ankuendigung des Streams.
|
||||
CREATE UNIQUE INDEX idx_posts_einmal
|
||||
ON posts(stream_id, kind, platform)
|
||||
WHERE stream_id IS NOT NULL AND status = 'gesendet';
|
||||
CREATE INDEX idx_posts_kind ON posts(kind, created_at);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Zaehler, die einen Neustart ueberleben muessen. Erster Fall: offene
|
||||
-- Liegestuetze aus Kanalpunkten. Wer 2000 Punkte ausgegeben hat, will nicht
|
||||
-- erleben, dass ein Update des Hubs seine Liegestuetze verschluckt.
|
||||
CREATE TABLE zaehler (
|
||||
name TEXT PRIMARY KEY,
|
||||
wert INTEGER NOT NULL,
|
||||
geaendert TEXT NOT NULL
|
||||
);
|
||||
@@ -21,7 +21,7 @@ function msg(over: Partial<ChatMessage> = {}): ChatMessage {
|
||||
id: `m-${Math.random().toString(36).slice(2)}`,
|
||||
ts: '2026-09-09T20:00:00.000Z',
|
||||
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
|
||||
text: 'moin', emotes: [], isFirst: false, isReturning: false,
|
||||
text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false,
|
||||
isCommand: false, isMod: false, isSub: false, isBroadcaster: false,
|
||||
bits: 0, color: null, ...over,
|
||||
};
|
||||
|
||||
@@ -19,6 +19,12 @@ export type ClipRow = {
|
||||
status: string; score: number | null;
|
||||
};
|
||||
|
||||
export type PostRow = {
|
||||
id: string; stream_id: string | null; kind: string; platform: string;
|
||||
mode: string; created_at: string; status: string;
|
||||
title: string | null; category: string | null; url: string | null; detail: string | null;
|
||||
};
|
||||
|
||||
export type StreamRow = {
|
||||
id: string; started_at: string; ended_at: string | null;
|
||||
peak_viewers: number; unique_chatters: number;
|
||||
@@ -84,6 +90,21 @@ export function repos(db: Db) {
|
||||
clipsOfStream: db.prepare(
|
||||
'SELECT * FROM clips WHERE stream_id IS ? ORDER BY created_at DESC'
|
||||
),
|
||||
insertPost: db.prepare(`
|
||||
INSERT INTO posts
|
||||
(id, stream_id, kind, platform, mode, created_at, status, title, category, url, detail)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
`),
|
||||
postFor: db.prepare(`
|
||||
SELECT * FROM posts
|
||||
WHERE stream_id IS ? AND kind = ? AND platform = ? AND status = 'gesendet'
|
||||
LIMIT 1
|
||||
`),
|
||||
lastPost: db.prepare(`
|
||||
SELECT * FROM posts
|
||||
WHERE kind = ? AND platform = ? AND status = 'gesendet'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`),
|
||||
stats: db.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM chat_messages) AS messages,
|
||||
@@ -160,6 +181,37 @@ export function repos(db: Db) {
|
||||
.run(...keys.map((k) => fields[k] ?? null), id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Haelt fest, was rausgegangen ist. Wirft nicht, wenn der eindeutige Index
|
||||
* zuschlaegt - dann war jemand anders schneller, und genau das soll er.
|
||||
*/
|
||||
insertPost(p: {
|
||||
streamId: string | null; kind: string; platform: string; mode: string;
|
||||
status: string; title?: string | null; category?: string | null;
|
||||
url?: string | null; detail?: string | null;
|
||||
}): string | null {
|
||||
const id = ulid();
|
||||
try {
|
||||
stmt.insertPost.run(id, p.streamId, p.kind, p.platform, p.mode,
|
||||
new Date().toISOString(), p.status,
|
||||
p.title ?? null, p.category ?? null, p.url ?? null, p.detail ?? null);
|
||||
return id;
|
||||
} catch (err) {
|
||||
if (/UNIQUE/i.test((err as Error).message)) return null;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/** Gab es fuer diesen Stream schon einen gesendeten Post dieser Art? */
|
||||
postFor(streamId: string | null, kind: string, platform: string) {
|
||||
return stmt.postFor.get(streamId, kind, platform) as PostRow | undefined;
|
||||
},
|
||||
|
||||
/** Der letzte gesendete Post dieser Art - fuer die Abklingzeit. */
|
||||
lastPost(kind: string, platform: string) {
|
||||
return stmt.lastPost.get(kind, platform) as PostRow | undefined;
|
||||
},
|
||||
|
||||
getClip(id: string) { return stmt.getClip.get(id) as ClipRow | undefined; },
|
||||
clipsOfStream(streamId: string | null) {
|
||||
return stmt.clipsOfStream.all(streamId) as ClipRow[];
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# @abdulive/discord
|
||||
|
||||
Discord-Anbindung: ein kleiner REST-Client und das Einrichten des
|
||||
Community-Servers.
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| `src/permissions.ts` | Rechtebits als `BigInt` (das Feld ist 64 Bit breit) |
|
||||
| `src/api.ts` | REST-Client: Warteschlange, Rate Limits, lesbare Fehler |
|
||||
| `src/plan.ts` | **Der Bauplan**: Rollen, Kanaele, Rechte, Servereinstellungen |
|
||||
| `src/apply.ts` | Traegt den Bauplan auf einen bestehenden Server auf |
|
||||
| `src/gateway.ts` | WebSocket-Verbindung fuer Klicks auf Knoepfe |
|
||||
| `src/selfroles.ts` | Der Knopf, der `Live-Ping` gibt und wieder nimmt |
|
||||
| `src/bot.ts` | Beides zusammen; laeuft im Ingest-Daemon mit |
|
||||
|
||||
Einrichten geschieht einmalig ueber `apply.ts`, der Betrieb laeuft ueber
|
||||
`bot.ts`. Beide lesen denselben Bauplan.
|
||||
|
||||
Aufruf ueber die CLI:
|
||||
|
||||
```powershell
|
||||
abdulive discord:setup --server <ID> --vorschau # zeigt nur, was passieren wuerde
|
||||
abdulive discord:setup --server <ID> # wendet es an
|
||||
```
|
||||
|
||||
Und der laufende Teil, der die Knoepfe bedient:
|
||||
|
||||
```powershell
|
||||
abdulive discord:bot # zum Ausprobieren; sonst im Ingest-Daemon
|
||||
```
|
||||
|
||||
Wiederholbar: gefunden wird ueber den Namen, danach wird angeglichen statt neu
|
||||
angelegt. Geloescht wird nie.
|
||||
|
||||
Begruendung der Struktur, die Schritte, die von Hand gehoeren, und die
|
||||
Fallstricke: [docs/projects/discord-community.md](../../docs/projects/discord-community.md)
|
||||
@@ -0,0 +1,186 @@
|
||||
// Discord-REST-Client. Nur das, was fuers Einrichten und fuers Posten
|
||||
// gebraucht wird - kein Gateway, keine Bibliothek.
|
||||
//
|
||||
// Discord drosselt pro Route mit eigenen Eimern. Wir schicken die Anfragen
|
||||
// nacheinander und werten `X-RateLimit-Remaining` aus: sinkt es auf 0, warten
|
||||
// wir `X-RateLimit-Reset-After` ab, bevor die naechste Anfrage rausgeht. Das
|
||||
// ist bei ~25 Anfragen pro Lauf reichlich und spart uns eine Eimerverwaltung.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { retry, sleep } from '#core/retry.ts';
|
||||
|
||||
const log = logger('discord');
|
||||
const BASE = 'https://discord.com/api/v10';
|
||||
|
||||
/** Laenger warten wir bei einem 429 nicht - dann wird der Schritt gemeldet. */
|
||||
const MAX_WAIT_MS = 60_000;
|
||||
|
||||
export type RestError = Error & { status: number; code: number };
|
||||
|
||||
function restError(status: number, code: number, message: string): RestError {
|
||||
const err = new Error(message) as RestError;
|
||||
err.status = status;
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlertexte von Discord sind verschachtelt:
|
||||
* `{ code: 50013, message: "Missing Permissions", errors: { name: { _errors: [...] } } }`
|
||||
* Wir ziehen die tiefen Feldfehler raus, sonst steht da nur "Invalid Form Body".
|
||||
*/
|
||||
function describe(body: unknown): { code: number; message: string } {
|
||||
if (!body || typeof body !== 'object') return { code: 0, message: '' };
|
||||
const b = body as { code?: number; message?: string; errors?: unknown };
|
||||
const parts: string[] = [];
|
||||
const walk = (node: unknown, path: string): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const n = node as { _errors?: Array<{ message?: string }> };
|
||||
if (Array.isArray(n._errors)) {
|
||||
for (const e of n._errors) parts.push(`${path}: ${e.message ?? ''}`.trim());
|
||||
return;
|
||||
}
|
||||
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k);
|
||||
};
|
||||
walk(b.errors, '');
|
||||
const detail = parts.length ? ` (${parts.join('; ')})` : '';
|
||||
return { code: b.code ?? 0, message: `${b.message ?? 'Unbekannter Fehler'}${detail}` };
|
||||
}
|
||||
|
||||
export class Discord {
|
||||
private token: string;
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
/** Zeitpunkt, ab dem wieder gesendet werden darf (ms seit Epoche). */
|
||||
private notBefore = 0;
|
||||
|
||||
constructor(botToken: string) {
|
||||
if (!botToken) throw new Error('Kein Discord-Bot-Token angegeben.');
|
||||
this.token = botToken;
|
||||
}
|
||||
|
||||
/** Reiht die Anfrage ein - es laeuft immer nur eine gleichzeitig. */
|
||||
private run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.queue.then(fn, fn);
|
||||
// Fehler duerfen die Kette nicht vergiften, der Aufrufer bekommt sie.
|
||||
this.queue = next.catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
|
||||
async request<T>(method: string, path: string, body?: unknown, reason?: string): Promise<T> {
|
||||
return this.run(() => retry(async () => {
|
||||
const wait = this.notBefore - Date.now();
|
||||
if (wait > 0) await sleep(wait);
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
'User-Agent': 'abdulive-toolkit (https://git.zeasy.dev/ZeasyAlex/abdulive, 0.1.0)',
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
// Landet im Audit-Log des Servers - so ist spaeter nachvollziehbar,
|
||||
// woher eine Rolle oder ein Kanal kam.
|
||||
...(reason ? { 'X-Audit-Log-Reason': encodeURIComponent(reason) } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
const remaining = Number(res.headers.get('x-ratelimit-remaining') ?? '1');
|
||||
const resetAfter = Number(res.headers.get('x-ratelimit-reset-after') ?? '0');
|
||||
if (remaining <= 0 && resetAfter > 0) this.notBefore = Date.now() + resetAfter * 1000 + 50;
|
||||
|
||||
if (res.status === 429) {
|
||||
const info = await res.json().catch(() => ({})) as { retry_after?: number; global?: boolean };
|
||||
const retryAfter = Math.max(1, (info.retry_after ?? 1)) * 1000 + 100;
|
||||
// Umbenennen eines Kanals ist auf zwei je zehn Minuten begrenzt. Dort
|
||||
// verlangt Discord schon mal neun Minuten Pause - so lange darf ein
|
||||
// Einrichtungslauf nicht stehen. Lieber melden und weitermachen.
|
||||
if (retryAfter > MAX_WAIT_MS) {
|
||||
const err = restError(429, 0,
|
||||
`${method} ${path}: Discord verlangt ${Math.round(retryAfter / 1000)} s Pause ` +
|
||||
'(bei Kanalnamen sind nur zwei Aenderungen je zehn Minuten erlaubt) - spaeter erneut ausfuehren');
|
||||
throw Object.assign(err, { final: true });
|
||||
}
|
||||
this.notBefore = Date.now() + retryAfter;
|
||||
log.warn(`Rate Limit${info.global ? ' (global)' : ''} auf ${method} ${path}, warte ${Math.round(retryAfter)} ms`);
|
||||
throw new Error('Rate Limit erreicht');
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
const text = await res.text();
|
||||
const parsed: unknown = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
const { code, message } = describe(parsed);
|
||||
// 401/403/404 sind Einrichtungsfehler und werden durch Wiederholen
|
||||
// nicht besser - sofort durchreichen.
|
||||
const err = restError(res.status, code, `${method} ${path}: HTTP ${res.status} ${message}`);
|
||||
if (res.status === 401 || res.status === 403 || res.status === 404) throw Object.assign(err, { final: true });
|
||||
throw err;
|
||||
}
|
||||
return parsed as T;
|
||||
}, {
|
||||
attempts: 4,
|
||||
// `retry` bietet keinen Abbruch, aber `onRetry` laeuft innerhalb des
|
||||
// catch-Blocks: wirft es, verlaesst der Fehler die Schleife sofort.
|
||||
// So werden Einrichtungsfehler nicht viermal wiederholt.
|
||||
onRetry: (err, n, ms) => {
|
||||
if ((err as { final?: boolean }).final) throw err;
|
||||
log.warn(`Wiederhole ${method} ${path} (${n}) in ${ms} ms`, { err: String(err) });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
get<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
|
||||
post<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('POST', path, body, reason); }
|
||||
patch<T>(path: string, body: unknown, reason?: string): Promise<T> { return this.request<T>('PATCH', path, body, reason); }
|
||||
put<T>(path: string, body?: unknown, reason?: string): Promise<T> { return this.request<T>('PUT', path, body, reason); }
|
||||
del<T>(path: string, reason?: string): Promise<T> { return this.request<T>('DELETE', path, undefined, reason); }
|
||||
|
||||
/** Fuer die Gateway-Verbindung; verlaesst diese Klasse sonst nie. */
|
||||
get botToken(): string { return this.token; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Was apply.ts vom Client braucht. Als Typ statt als Klasse, damit die Tests
|
||||
* einen nachgebauten Server unterschieben koennen, ohne ins Netz zu gehen.
|
||||
*/
|
||||
export type Rest = {
|
||||
get<T>(path: string): Promise<T>;
|
||||
post<T>(path: string, body: unknown, reason?: string): Promise<T>;
|
||||
patch<T>(path: string, body: unknown, reason?: string): Promise<T>;
|
||||
};
|
||||
|
||||
/** Zusaetzlich das, was der Bot im Betrieb braucht: Rollen setzen und loesen. */
|
||||
export type RestFull = Rest & {
|
||||
put<T>(path: string, body?: unknown, reason?: string): Promise<T>;
|
||||
del<T>(path: string, reason?: string): Promise<T>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Typen
|
||||
|
||||
export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean };
|
||||
export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string };
|
||||
export type ApiTag = { id?: string; name: string; emoji_id?: string | null; emoji_name: string | null; moderated: boolean };
|
||||
export type ApiChannel = {
|
||||
id: string; name: string; type: number; position: number; parent_id: string | null;
|
||||
topic?: string | null; permission_overwrites?: ApiOverwrite[];
|
||||
// Nur Foren.
|
||||
available_tags?: ApiTag[];
|
||||
default_reaction_emoji?: { emoji_id: string | null; emoji_name: string | null } | null;
|
||||
};
|
||||
export type ApiGuild = {
|
||||
id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[];
|
||||
// Die Felder aus GUILD_SETTINGS, damit sich vergleichen laesst, statt bei
|
||||
// jedem Lauf blind zu schreiben.
|
||||
verification_level?: number;
|
||||
explicit_content_filter?: number;
|
||||
default_message_notifications?: number;
|
||||
system_channel_flags?: number;
|
||||
system_channel_id?: string | null;
|
||||
};
|
||||
export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string };
|
||||
export type ApiUser = { id: string; username: string; bot?: boolean };
|
||||
|
||||
/** Kanaltypen, die hier vorkommen. */
|
||||
export const CH = { TEXT: 0, VOICE: 2, CATEGORY: 4, FORUM: 15 } as const;
|
||||
@@ -0,0 +1,544 @@
|
||||
// Der ganze Durchlauf gegen einen nachgebauten Server. Kein Netz, kein Token.
|
||||
//
|
||||
// Die Frage, die hier beantwortet wird, ist nicht "kompiliert es", sondern:
|
||||
// Was passiert beim zweiten Lauf? Ein Einrichtungsskript, das beim zweiten Mal
|
||||
// alles doppelt anlegt, richtet mehr Schaden an, als es Arbeit spart.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiWebhook, type Rest } from './api.ts';
|
||||
import { applyPlan, mergeTags, type Step } from './apply.ts';
|
||||
import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts';
|
||||
import { str } from './permissions.ts';
|
||||
|
||||
const GUILD = '100';
|
||||
|
||||
/** Ein Discord-Server im Arbeitsspeicher. Kennt nur die Routen, die wir nutzen. */
|
||||
class FakeDiscord implements Rest {
|
||||
guild: ApiGuild;
|
||||
channels: ApiChannel[] = [];
|
||||
webhooks: ApiWebhook[] = [];
|
||||
automod: Array<{ id: string; name: string; actions?: unknown[] }> = [];
|
||||
messages = new Map<string, Array<{ id: string; content: string; author: { id: string }; components?: unknown[] }>>();
|
||||
calls: string[] = [];
|
||||
private next = 1000;
|
||||
|
||||
/** Platz der Bot-Rolle. Discord haengt sie beim Einladen auf 1. */
|
||||
botRolePosition: number;
|
||||
readonly botUserId = '5000';
|
||||
readonly botRoleId = '5001';
|
||||
|
||||
constructor(opts: { botRolePosition?: number } = {}) {
|
||||
this.botRolePosition = opts.botRolePosition ?? 8;
|
||||
this.guild = {
|
||||
id: GUILD, name: 'abdulive', owner_id: '1', features: [],
|
||||
roles: [
|
||||
{
|
||||
id: GUILD, name: '@everyone', permissions: '0', position: 0,
|
||||
color: 0, hoist: false, mentionable: false, managed: false,
|
||||
},
|
||||
{
|
||||
id: this.botRoleId, name: 'abdulive_ Bot', permissions: '8',
|
||||
position: this.botRolePosition, color: 0, hoist: false,
|
||||
mentionable: false, managed: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private id(): string { return String(this.next++); }
|
||||
|
||||
async get<T>(path: string): Promise<T> {
|
||||
this.calls.push(`GET ${path}`);
|
||||
if (path === '/users/@me') return { id: this.botUserId, username: 'abdulive_ Bot', bot: true } as T;
|
||||
if (path === `/guilds/${GUILD}/members/${this.botUserId}`) return { roles: [this.botRoleId] } as T;
|
||||
if (path === `/guilds/${GUILD}/roles`) return structuredClone(this.guild.roles) as T;
|
||||
if (path === `/guilds/${GUILD}`) return structuredClone(this.guild) as T;
|
||||
if (path === `/guilds/${GUILD}/channels`) return structuredClone(this.channels) as T;
|
||||
if (path === `/guilds/${GUILD}/auto-moderation/rules`) return structuredClone(this.automod) as T;
|
||||
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
|
||||
if (wh) return this.webhooks.filter((w) => w.channel_id === wh[1]) as T;
|
||||
const msg = /^\/channels\/(\d+)\/messages\?limit=(\d+)$/.exec(path);
|
||||
if (msg) return structuredClone(this.messages.get(msg[1]!) ?? []).slice(0, Number(msg[2])) as T;
|
||||
throw new Error(`GET ${path} nicht nachgebaut`);
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: unknown): Promise<T> {
|
||||
this.calls.push(`POST ${path}`);
|
||||
const b = body as Record<string, never>;
|
||||
if (path === `/guilds/${GUILD}/roles`) {
|
||||
const role = { id: this.id(), position: 1, managed: false, ...b } as unknown as ApiRole;
|
||||
this.guild.roles.push(role);
|
||||
return structuredClone(role) as T;
|
||||
}
|
||||
if (path === `/guilds/${GUILD}/channels`) {
|
||||
const ch = {
|
||||
id: this.id(), position: this.channels.length, parent_id: null,
|
||||
permission_overwrites: [], ...b,
|
||||
} as unknown as ApiChannel;
|
||||
this.channels.push(ch);
|
||||
return structuredClone(ch) as T;
|
||||
}
|
||||
if (path === `/guilds/${GUILD}/auto-moderation/rules`) {
|
||||
const src = body as Record<string, unknown>;
|
||||
const rule = { id: this.id(), name: String(src['name']), actions: src['actions'] as unknown[] };
|
||||
this.automod.push(rule);
|
||||
return structuredClone(rule) as T;
|
||||
}
|
||||
const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path);
|
||||
if (wh) {
|
||||
const made: ApiWebhook = { id: this.id(), name: String(b['name']), channel_id: wh[1]!, token: 'tok-' + this.id() };
|
||||
this.webhooks.push(made);
|
||||
return structuredClone(made) as T;
|
||||
}
|
||||
const msg = /^\/channels\/(\d+)\/messages$/.exec(path);
|
||||
if (msg) {
|
||||
const made = {
|
||||
id: this.id(),
|
||||
content: String((b as Record<string, unknown>)['content'] ?? ''),
|
||||
author: { id: this.botUserId },
|
||||
components: ((b as Record<string, unknown>)['components'] ?? []) as unknown[],
|
||||
};
|
||||
const list = this.messages.get(msg[1]!) ?? [];
|
||||
list.push(made);
|
||||
this.messages.set(msg[1]!, list);
|
||||
return structuredClone(made) as T;
|
||||
}
|
||||
throw new Error(`POST ${path} nicht nachgebaut`);
|
||||
}
|
||||
|
||||
async patch<T>(path: string, body: unknown): Promise<T> {
|
||||
this.calls.push(`PATCH ${path}`);
|
||||
if (path === `/guilds/${GUILD}`) {
|
||||
// Wie der echte Server: gesetzte Einstellungen stehen danach auch drin.
|
||||
// Ohne das liesse sich nicht pruefen, ob ein zweiter Lauf sie in Ruhe
|
||||
// laesst.
|
||||
Object.assign(this.guild, body as Record<string, unknown>);
|
||||
return structuredClone(this.guild) as T;
|
||||
}
|
||||
if (path === `/guilds/${GUILD}/roles`) { // Rangfolge
|
||||
for (const e of body as Array<{ id: string; position: number }>) {
|
||||
const r = this.guild.roles.find((x) => x.id === e.id);
|
||||
if (!r) continue;
|
||||
if (e.position >= this.botRolePosition) throw new Error('Missing Permissions');
|
||||
r.position = e.position;
|
||||
}
|
||||
return structuredClone(this.guild.roles) as T;
|
||||
}
|
||||
const role = /^\/guilds\/\d+\/roles\/(\d+)$/.exec(path);
|
||||
if (role) {
|
||||
const r = this.guild.roles.find((x) => x.id === role[1]);
|
||||
if (r) Object.assign(r, body);
|
||||
return structuredClone(r) as T;
|
||||
}
|
||||
const rule = /^\/guilds\/\d+\/auto-moderation\/rules\/(\d+)$/.exec(path);
|
||||
if (rule) {
|
||||
const r = this.automod.find((x) => x.id === rule[1]);
|
||||
if (r) Object.assign(r, body);
|
||||
return structuredClone(r) as T;
|
||||
}
|
||||
const msg = /^\/channels\/(\d+)\/messages\/(\d+)$/.exec(path);
|
||||
if (msg) {
|
||||
const m = (this.messages.get(msg[1]!) ?? []).find((x) => x.id === msg[2]);
|
||||
if (m) {
|
||||
const src = body as Record<string, unknown>;
|
||||
m.content = String(src['content'] ?? '');
|
||||
m.components = (src['components'] ?? []) as unknown[];
|
||||
}
|
||||
return structuredClone(m) as T;
|
||||
}
|
||||
const ch = /^\/channels\/(\d+)$/.exec(path);
|
||||
if (ch) {
|
||||
const c = this.channels.find((x) => x.id === ch[1]);
|
||||
if (c) Object.assign(c, body);
|
||||
return structuredClone(c) as T;
|
||||
}
|
||||
throw new Error(`PATCH ${path} nicht nachgebaut`);
|
||||
}
|
||||
}
|
||||
|
||||
const run = (api: Rest, dryRun = false) =>
|
||||
applyPlan(api, GUILD, { dryRun, saveSecrets: false });
|
||||
|
||||
/** Der Kanal aus dem Bauplan, so wie er im nachgebauten Server gelandet ist. */
|
||||
const live = (api: FakeDiscord, key: string): ApiChannel | undefined => {
|
||||
const plan = CHANNELS.find((c) => c.key === key);
|
||||
assert.ok(plan, `key ${key} gibt es im Bauplan nicht`);
|
||||
return api.channels.find((c) => c.name === plan.name && c.type === plan.type);
|
||||
};
|
||||
|
||||
const katName = (key: string): string => {
|
||||
const k = CATEGORIES.find((c) => c.key === key);
|
||||
assert.ok(k);
|
||||
return k.name;
|
||||
};
|
||||
|
||||
const counts = (steps: Step[]): Record<string, number> => {
|
||||
const out: Record<string, number> = {};
|
||||
for (const s of steps) out[s.change] = (out[s.change] ?? 0) + 1;
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Erstlauf
|
||||
|
||||
test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const res = await run(api);
|
||||
|
||||
assert.equal(res.guild, 'abdulive');
|
||||
for (const r of ROLES) {
|
||||
assert.ok(api.guild.roles.find((x) => x.name === r.name), `Rolle ${r.name} fehlt`);
|
||||
}
|
||||
assert.equal(api.channels.filter((c) => c.type === CH.CATEGORY).length, 3);
|
||||
for (const c of CHANNELS) {
|
||||
assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`);
|
||||
}
|
||||
assert.equal(res.webhooks, 4);
|
||||
assert.equal(api.automod.length, 2);
|
||||
assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0);
|
||||
});
|
||||
|
||||
test('@everyone bekommt die Grundrechte aus dem Bauplan', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const everyone = api.guild.roles.find((r) => r.id === GUILD);
|
||||
assert.equal(everyone?.permissions, str(EVERYONE));
|
||||
});
|
||||
|
||||
test('Kanaele haengen unter der richtigen Kategorie', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const kat = (key: string) =>
|
||||
api.channels.find((c) => c.name === katName(key) && c.type === CH.CATEGORY)?.id;
|
||||
for (const plan of CHANNELS) {
|
||||
const c = live(api, plan.key);
|
||||
assert.equal(c?.parent_id, kat(plan.category!), `${plan.name} haengt falsch`);
|
||||
}
|
||||
});
|
||||
|
||||
test('der interne Bereich ist fuer @everyone gesperrt und fuer Mod offen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const intern = api.channels.find((c) => c.name === katName('intern'));
|
||||
const mod = api.guild.roles.find((r) => r.name === 'Mod');
|
||||
const ow = intern?.permission_overwrites ?? [];
|
||||
const jeder = ow.find((o) => o.id === GUILD);
|
||||
assert.ok(jeder && (BigInt(jeder.deny) & 1024n) === 1024n, 'VIEW_CHANNEL muss fuer @everyone gesperrt sein');
|
||||
const fuerMod = ow.find((o) => o.id === mod?.id);
|
||||
assert.ok(fuerMod && (BigInt(fuerMod.allow) & 1024n) === 1024n, 'Mod muss hineinsehen koennen');
|
||||
});
|
||||
|
||||
test('die Begruessung wird gepostet und die Kanalnamen sind aufgeloest', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const willkommen = live(api, 'willkommen');
|
||||
const posts = api.messages.get(willkommen!.id) ?? [];
|
||||
assert.equal(posts.length, 1);
|
||||
assert.match(posts[0]!.content, /<#\d+> - alles andere/, 'die Kanalnamen muessen aufgeloest sein');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Zweitlauf
|
||||
|
||||
test('der zweite Lauf aendert nichts', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const nachErstlauf = {
|
||||
rollen: api.guild.roles.length,
|
||||
kanaele: api.channels.length,
|
||||
webhooks: api.webhooks.length,
|
||||
automod: api.automod.length,
|
||||
};
|
||||
|
||||
api.calls = [];
|
||||
const res = await run(api);
|
||||
|
||||
assert.deepEqual({
|
||||
rollen: api.guild.roles.length,
|
||||
kanaele: api.channels.length,
|
||||
webhooks: api.webhooks.length,
|
||||
automod: api.automod.length,
|
||||
}, nachErstlauf, 'beim zweiten Lauf wurde etwas doppelt angelegt');
|
||||
|
||||
// Kein POST ausser den Lesevorgaengen - nichts Neues, nichts Geaendertes.
|
||||
const schreibend = api.calls.filter((c) => c.startsWith('POST') || (c.startsWith('PATCH') && !c.endsWith('/roles')));
|
||||
assert.deepEqual(schreibend.filter((c) => c !== `PATCH /guilds/${GUILD}`), [],
|
||||
`unerwartete Schreibzugriffe: ${schreibend.join(', ')}`);
|
||||
|
||||
assert.equal(counts(res.steps)['erstellt'] ?? 0, 0);
|
||||
assert.equal(res.webhooks, 4, 'die bestehenden Webhook-URLs muessen wiedergefunden werden');
|
||||
});
|
||||
|
||||
test('die Begruessung wird nicht ein zweites Mal gepostet', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
await run(api);
|
||||
const willkommen = live(api, 'willkommen');
|
||||
assert.equal((api.messages.get(willkommen!.id) ?? []).length, 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Vorschau
|
||||
|
||||
test('die Vorschau schreibt nichts', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const res = await run(api, true);
|
||||
|
||||
assert.equal(api.guild.roles.length, 2, 'nur @everyone und die Bot-Rolle, die es schon gab');
|
||||
assert.equal(api.channels.length, 0);
|
||||
assert.equal(api.webhooks.length, 0);
|
||||
assert.equal(api.automod.length, 0);
|
||||
assert.deepEqual(api.calls.filter((c) => !c.startsWith('GET')), []);
|
||||
assert.ok((counts(res.steps)['erstellt'] ?? 0) > 10, 'die Vorschau muss zeigen, was entstehen wuerde');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Bestand
|
||||
|
||||
test('von Hand angelegte Kanaele und Rechte bleiben stehen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
api.channels.push({
|
||||
id: '900', name: 'memes', type: CH.TEXT, position: 0, parent_id: null,
|
||||
permission_overwrites: [{ id: '777', type: 1, allow: '2048', deny: '0' }],
|
||||
});
|
||||
api.channels.push({
|
||||
id: '901', name: 'allgemein', type: CH.TEXT, position: 1, parent_id: null,
|
||||
permission_overwrites: [{ id: '777', type: 1, allow: '0', deny: '2048' }],
|
||||
});
|
||||
|
||||
await run(api);
|
||||
|
||||
assert.ok(api.channels.find((c) => c.name === 'memes'), 'ein fremder Kanal darf nicht verschwinden');
|
||||
const allgemein = api.channels.find((c) => c.id === '901');
|
||||
assert.equal(live(api, 'allgemein')?.id, '901', 'der bestehende Kanal muss umbenannt werden');
|
||||
assert.equal(api.channels.filter((c) => c.name.includes('allgemein')).length, 1, 'nicht doppelt anlegen');
|
||||
const fremd = (allgemein?.permission_overwrites ?? []).find((o: ApiOverwrite) => o.id === '777');
|
||||
assert.deepEqual(fremd, { id: '777', type: 1, allow: '0', deny: '2048' },
|
||||
'eine von Hand gesetzte Ausnahme darf nicht wegfallen');
|
||||
});
|
||||
|
||||
test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api); // legt alles an
|
||||
// Jemand loescht den Go-Live-Webhook und baut ihn in der Oberflaeche nach -
|
||||
// dann fehlt uns der Token, und das muss auffallen statt still zu scheitern.
|
||||
const i = api.webhooks.findIndex((w) => w.name === 'Go-Live');
|
||||
delete api.webhooks[i]!.token;
|
||||
|
||||
const res = await run(api);
|
||||
const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live'));
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.match(step?.note ?? '', /nicht auslesbar/);
|
||||
assert.equal(res.webhooks, 3, 'die drei uebrigen muessen trotzdem durchkommen');
|
||||
});
|
||||
|
||||
test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
// Die Twitch-Anbindung legt eigene Rollen an. Die gehoeren Discord.
|
||||
api.guild.roles.push({
|
||||
id: '800', name: 'Stammgast', permissions: '0', position: 2,
|
||||
color: 0, hoist: false, mentionable: false, managed: true,
|
||||
});
|
||||
|
||||
const res = await run(api);
|
||||
const step = res.steps.find((s) => s.kind === 'Rolle' && s.name === 'Stammgast');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.equal(api.guild.roles.find((r) => r.id === '800')?.permissions, '0', 'unveraendert geblieben');
|
||||
});
|
||||
|
||||
test('scheitert die Rangfolge, laeuft der Rest trotzdem durch', async () => {
|
||||
const api = new FakeDiscord();
|
||||
const echt = api.patch.bind(api);
|
||||
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
if (path === `/guilds/${GUILD}/roles`) throw new Error('Missing Permissions');
|
||||
return echt<T>(path, body);
|
||||
};
|
||||
|
||||
const res = await run(api);
|
||||
const step = res.steps.find((s) => s.name === 'Rangfolge');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.equal(api.channels.length, 3 + CHANNELS.length, 'die Kanaele muessen trotzdem entstehen');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Rangfolge
|
||||
|
||||
test('die Rollen landen unter der Bot-Rolle, in der Reihenfolge des Bauplans', async () => {
|
||||
const api = new FakeDiscord({ botRolePosition: 8 });
|
||||
await run(api);
|
||||
|
||||
const pos = (name: string) => api.guild.roles.find((r) => r.name === name)?.position;
|
||||
assert.deepEqual(ROLES.map((r) => pos(r.name)), [7, 6, 5, 4]);
|
||||
for (const r of ROLES) {
|
||||
assert.ok(pos(r.name)! < api.botRolePosition, `${r.name} steht ueber der Bot-Rolle`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async () => {
|
||||
// Der reale Fall direkt nach dem Einladen. Discord haengt die Bot-Rolle auf
|
||||
// Platz 1, darunter ist kein Platz - frueher gab das ein Missing Permissions.
|
||||
const api = new FakeDiscord({ botRolePosition: 1 });
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.name === 'Rangfolge');
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.match(step?.note ?? '', /nach oben ziehen/);
|
||||
// Alles andere muss trotzdem stehen.
|
||||
assert.equal(api.channels.length, 3 + CHANNELS.length);
|
||||
assert.equal(res.webhooks, 4);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Umbenennen
|
||||
|
||||
test('ein Kanal unter altem Namen wird umbenannt, nicht verdoppelt', async () => {
|
||||
const api = new FakeDiscord();
|
||||
// So sah der Server nach dem ersten Lauf ohne Emoji aus.
|
||||
const alt = new Map<string, string>();
|
||||
CHANNELS.forEach((plan, i) => {
|
||||
const id = String(900 + i); // der Fake erwartet Ziffern-IDs
|
||||
alt.set(plan.key, id);
|
||||
api.channels.push({
|
||||
id, name: plan.aliases![0]!, type: plan.type,
|
||||
position: 0, parent_id: null, permission_overwrites: [],
|
||||
});
|
||||
});
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
for (const plan of CHANNELS) {
|
||||
assert.equal(live(api, plan.key)?.id, alt.get(plan.key), `${plan.key} wurde neu angelegt statt umbenannt`);
|
||||
}
|
||||
assert.equal(api.channels.filter((c) => c.type !== CH.CATEGORY).length, CHANNELS.length,
|
||||
'es darf kein Kanal dazugekommen sein');
|
||||
assert.ok(res.steps.some((s) => s.name.includes('->')), 'die Umbenennung muss im Bericht stehen');
|
||||
});
|
||||
|
||||
test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', async () => {
|
||||
const api = new FakeDiscord();
|
||||
api.channels.push({
|
||||
id: '901', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null,
|
||||
permission_overwrites: [],
|
||||
});
|
||||
const echt = api.patch.bind(api);
|
||||
api.patch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
if (path === '/channels/901') throw new Error('Discord verlangt 530 s Pause');
|
||||
return echt<T>(path, body);
|
||||
};
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.name.includes('allgemein'));
|
||||
assert.equal(step?.change, 'uebersprungen');
|
||||
assert.match(step?.note ?? '', /530 s/);
|
||||
// Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen.
|
||||
assert.equal(res.webhooks, 4);
|
||||
assert.equal(api.automod.length, 2);
|
||||
});
|
||||
|
||||
test('der zweite Lauf schreibt nichts mehr - auch nicht Einstellungen und Rangfolge', async () => {
|
||||
// Der Bauplan verspricht, dass ein zweiter Lauf nichts aendert. Zwei
|
||||
// Schritte haben das lange gebrochen, weil sie ohne Vergleich geschrieben
|
||||
// haben: die Servereinstellungen und die Rangfolge der Rollen. Folgenlos
|
||||
// war das nicht - jeder Lauf hinterliess zwei Eintraege im Audit-Log und
|
||||
// liess den Bericht so aussehen, als sei etwas passiert.
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
|
||||
api.calls.length = 0;
|
||||
const res = await run(api);
|
||||
|
||||
assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}`), false,
|
||||
'Servereinstellungen standen schon');
|
||||
assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}/roles`), false,
|
||||
'die Rangfolge stand schon');
|
||||
assert.equal(res.steps.find((s) => s.kind === 'Server')?.change, 'unveraendert');
|
||||
assert.equal(res.steps.find((s) => s.name === 'Rangfolge')?.change, 'unveraendert');
|
||||
assert.equal(res.steps.some((s) => s.change === 'erstellt'), false, 'nichts neu angelegt');
|
||||
});
|
||||
|
||||
test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const regel = api.automod[0]!;
|
||||
(regel.actions as Array<{ type: number; metadata: { custom_message: string } }>)[0]!
|
||||
.metadata.custom_message = 'alter Text';
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.kind === 'AutoMod' && s.name === regel.name);
|
||||
assert.equal(step?.change, 'angepasst');
|
||||
assert.match(step?.note ?? '', /actions/);
|
||||
assert.equal(api.automod.length, 2, 'die Regel darf nicht neu angelegt werden');
|
||||
});
|
||||
|
||||
test('der Meldekanal einer bestehenden Regel bleibt, wie er gesetzt wurde', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const regel = api.automod[0]!;
|
||||
const actions = regel.actions as Array<{ type: number; metadata: Record<string, string> }>;
|
||||
actions[0]!.metadata['custom_message'] = 'alter Text';
|
||||
actions[1]!.metadata['channel_id'] = '424242'; // jemand hat umgestellt
|
||||
|
||||
await run(api);
|
||||
|
||||
const danach = (api.automod[0]!.actions as typeof actions).find((a) => a.type === 2);
|
||||
assert.equal(danach?.metadata['channel_id'], '424242', 'die eigene Wahl darf nicht ueberschrieben werden');
|
||||
});
|
||||
|
||||
test('der Willkommenstext bekommt die Knopfleiste', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const post = api.messages.get(live(api, 'willkommen')!.id)![0]!;
|
||||
const reihen = post.components as Array<{ type: number; components: Array<{ custom_id: string }> }>;
|
||||
assert.equal(reihen.length, 1);
|
||||
assert.equal(reihen[0]!.components[0]!.custom_id, 'rolle:live-ping');
|
||||
});
|
||||
|
||||
test('eine Nachricht ohne Knopfleiste bekommt sie nachgereicht', async () => {
|
||||
// So sieht der Willkommenstext aus, der vor den Knoepfen gepostet wurde.
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const kanal = live(api, 'willkommen')!.id;
|
||||
api.messages.get(kanal)![0]!.components = [];
|
||||
|
||||
const res = await run(api);
|
||||
|
||||
const step = res.steps.find((s) => s.name === 'Willkommenstext');
|
||||
assert.equal(step?.change, 'angepasst');
|
||||
assert.equal((api.messages.get(kanal)![0]!.components as unknown[]).length, 1);
|
||||
assert.equal(api.messages.get(kanal)!.length, 1, 'nicht neu posten');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Foren
|
||||
|
||||
test('ein Forum bekommt Tags und Standard-Reaktion aus dem Bauplan', async () => {
|
||||
const api = new FakeDiscord();
|
||||
await run(api);
|
||||
const plan = CHANNELS.find((c) => c.key === 'spielvorschlaege')!;
|
||||
const forum = live(api, 'spielvorschlaege')!;
|
||||
assert.equal(forum.type, CH.FORUM);
|
||||
assert.deepEqual(forum.available_tags?.map((t) => t.name), plan.tags?.map((t) => t.name));
|
||||
assert.equal(forum.default_reaction_emoji?.emoji_name, plan.reaction);
|
||||
});
|
||||
|
||||
test('ein Textkanal gleichen Namens wird nicht fuer das Forum gehalten', async () => {
|
||||
// Discord kann einen Textkanal nicht in ein Forum umwandeln. Der alte steht
|
||||
// also daneben, bis ihn jemand loescht - angepasst werden darf er nicht.
|
||||
const api = new FakeDiscord();
|
||||
api.channels.push({ id: '1', name: '🎮│spielvorschläge', type: CH.TEXT, position: 0, parent_id: null });
|
||||
await run(api);
|
||||
assert.equal(api.channels.filter((c) => c.name === '🎮│spielvorschläge').length, 2);
|
||||
assert.equal(api.channels.find((c) => c.id === '1')?.available_tags, undefined);
|
||||
});
|
||||
|
||||
test('von Hand angelegte Tags bleiben mit ihrer ID stehen', async () => {
|
||||
const fremd = { id: '77', name: 'Horror', emoji_name: '👻', moderated: false };
|
||||
const eigen = { id: '78', name: 'gespielt', emoji_name: '✔️', moderated: false };
|
||||
const out = mergeTags([fremd, eigen], [
|
||||
{ name: 'Gespielt', emoji: '✅', moderated: true },
|
||||
{ name: 'Eher nicht', emoji: '❌', moderated: true },
|
||||
]);
|
||||
assert.ok(out);
|
||||
assert.deepEqual(out.find((t) => t.name === 'Horror'), fremd);
|
||||
const g = out.find((t) => t.name === 'Gespielt')!;
|
||||
assert.equal(g.id, '78', 'ohne ID verlieren die Beitraege ihre Markierung');
|
||||
assert.equal(g.emoji_name, '✅');
|
||||
assert.ok(out.find((t) => t.name === 'Eher nicht'));
|
||||
assert.equal(mergeTags(out, [{ name: 'Gespielt', emoji: '✅', moderated: true }]), null);
|
||||
});
|
||||
@@ -0,0 +1,582 @@
|
||||
// Traegt den Bauplan aus plan.ts auf einen bestehenden Server auf.
|
||||
//
|
||||
// Wiederholbar: gefunden wird ueber Name und frueheren Namen, danach wird
|
||||
// angepasst statt neu angelegt. Zweimal laufen lassen aendert nichts. Was die
|
||||
// Hand am Server zusaetzlich eingerichtet hat - eigene Kanaele, eigene
|
||||
// Rechte-Ausnahmen - bleibt stehen; wir setzen nur, was im Plan steht.
|
||||
//
|
||||
// Was hier NICHT passiert: loeschen. Ein Einrichtungsskript, das Kanaele
|
||||
// wegraeumt, ist ein Einrichtungsskript, das irgendwann den falschen wegraeumt.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { updateSecrets } from '#twitch/secrets.ts';
|
||||
import {
|
||||
CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiTag, type ApiUser,
|
||||
type ApiWebhook, type Rest,
|
||||
} from './api.ts';
|
||||
import { str } from './permissions.ts';
|
||||
import { selfRoleComponents } from './selfroles.ts';
|
||||
import {
|
||||
AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, SYSTEM_CHANNEL, WELCOME,
|
||||
type CategoryPlan, type ChannelPlan, type OverwritePlan, type TagPlan, type WebhookSlot,
|
||||
} from './plan.ts';
|
||||
|
||||
const log = logger('discord:setup');
|
||||
|
||||
export type Change = 'erstellt' | 'angepasst' | 'unveraendert' | 'uebersprungen';
|
||||
export type Step = { kind: string; name: string; change: Change; note?: string };
|
||||
|
||||
export type ApplyResult = {
|
||||
guild: string;
|
||||
steps: Step[];
|
||||
/** Nur die Anzahl - die URLs selbst werden nie zurueckgegeben oder geloggt. */
|
||||
webhooks: number;
|
||||
};
|
||||
|
||||
export type ApplyOptions = {
|
||||
/** Nur zeigen, was passieren wuerde. Schreibt nichts. */
|
||||
dryRun?: boolean;
|
||||
/** Webhook-URLs in den verschluesselten Speicher schreiben. */
|
||||
saveSecrets?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
|
||||
const eq = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
const byName = <T extends { name: string }>(list: T[], name: string): T | undefined =>
|
||||
list.find((x) => eq(x.name, name));
|
||||
|
||||
/** Findet einen Kanal ueber den geplanten Namen oder einen frueheren Namen. */
|
||||
export function findChannel(
|
||||
existing: ApiChannel[], plan: { name: string; aliases?: string[]; type: number },
|
||||
): ApiChannel | undefined {
|
||||
for (const name of [plan.name, ...(plan.aliases ?? [])]) {
|
||||
const hit = existing.find((c) => c.type === plan.type && eq(c.name, name));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die geplanten Rechte-Ausnahmen, laesst fremde stehen.
|
||||
* Gibt null zurueck, wenn sich nichts aendert - dann sparen wir das PATCH.
|
||||
*/
|
||||
export function mergeOverwrites(
|
||||
existing: ApiOverwrite[],
|
||||
planned: OverwritePlan[],
|
||||
roleId: (name: string) => string | undefined,
|
||||
): ApiOverwrite[] | null {
|
||||
const out = existing.map((o) => ({ ...o }));
|
||||
let changed = false;
|
||||
|
||||
for (const p of planned) {
|
||||
const id = roleId(p.role);
|
||||
if (!id) continue; // Rolle gibt es (noch) nicht
|
||||
const allow = str(p.allow ?? 0n);
|
||||
const deny = str(p.deny ?? 0n);
|
||||
const found = out.find((o) => o.id === id && o.type === 0);
|
||||
if (!found) {
|
||||
out.push({ id, type: 0, allow, deny });
|
||||
changed = true;
|
||||
} else if (found.allow !== allow || found.deny !== deny) {
|
||||
found.allow = allow;
|
||||
found.deny = deny;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord normalisiert Kanalnamen: Textkanaele werden kleingeschrieben und
|
||||
* Leerzeichen werden zu Bindestrichen. Emoji, Umlaute und das Trennzeichen
|
||||
* U+2502 bleiben stehen. Wer das nicht mitrechnet, schickt bei jedem Lauf
|
||||
* dieselbe Umbenennung erneut - und laeuft in das Limit von zwei
|
||||
* Umbenennungen je Kanal und zehn Minuten.
|
||||
*/
|
||||
export function normalizeName(name: string, type: number): string {
|
||||
if (type !== CH.TEXT) return name;
|
||||
return name.toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Rollen
|
||||
|
||||
/**
|
||||
* Wohin die Rollen in der Rangfolge duerfen.
|
||||
*
|
||||
* Eine Rolle kann nur Rollen unterhalb der eigenen bearbeiten - auch mit
|
||||
* Administrator. Die Rolle, die der Bot mitbringt, haengt Discord beim
|
||||
* Einladen ganz unten ein, dort ist also erst einmal kein Platz. Statt blind
|
||||
* 3, 2, 1 zu verlangen und ein "Missing Permissions" zu kassieren, rechnen wir
|
||||
* aus, was geht, und sagen sonst, was zu tun ist.
|
||||
*/
|
||||
export function rolePositions(
|
||||
count: number, botTop: number,
|
||||
): { positions: number[] } | { problem: string } {
|
||||
// Frei sind die Plaetze 1 bis botTop-1; Platz 0 gehoert @everyone.
|
||||
const room = botTop - 1;
|
||||
if (room < count) {
|
||||
return {
|
||||
problem:
|
||||
`Bot-Rolle steht auf Platz ${botTop}, darunter ist Platz fuer ${Math.max(0, room)} ` +
|
||||
`von ${count} Rollen. In den Servereinstellungen unter "Rollen" die Bot-Rolle ` +
|
||||
'nach oben ziehen und den Befehl erneut ausfuehren.',
|
||||
};
|
||||
}
|
||||
return { positions: Array.from({ length: count }, (_, i) => botTop - 1 - i) };
|
||||
}
|
||||
|
||||
async function applyRoles(
|
||||
api: Rest, guild: ApiGuild, botUserId: string, steps: Step[], dry: boolean,
|
||||
): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
const roles = [...guild.roles];
|
||||
|
||||
// @everyone traegt in Discord die ID des Servers.
|
||||
ids.set('@everyone', guild.id);
|
||||
const everyone = roles.find((r) => r.id === guild.id);
|
||||
if (everyone && everyone.permissions !== str(EVERYONE)) {
|
||||
if (!dry) await api.patch(`/guilds/${guild.id}/roles/${guild.id}`, { permissions: str(EVERYONE) }, 'Grundrechte aus dem Bauplan');
|
||||
steps.push({ kind: 'Rolle', name: '@everyone', change: 'angepasst' });
|
||||
} else {
|
||||
steps.push({ kind: 'Rolle', name: '@everyone', change: 'unveraendert' });
|
||||
}
|
||||
|
||||
for (const plan of ROLES) {
|
||||
const found = byName(roles, plan.name);
|
||||
const want = {
|
||||
name: plan.name,
|
||||
permissions: str(plan.permissions),
|
||||
color: plan.color,
|
||||
hoist: plan.hoist,
|
||||
mentionable: plan.mentionable,
|
||||
};
|
||||
|
||||
if (!found) {
|
||||
if (dry) {
|
||||
steps.push({ kind: 'Rolle', name: plan.name, change: 'erstellt', note: 'Vorschau' });
|
||||
continue;
|
||||
}
|
||||
const made = await api.post<ApiRole>(`/guilds/${guild.id}/roles`, want, `Rolle aus dem Bauplan: ${plan.purpose}`);
|
||||
ids.set(plan.name, made.id);
|
||||
steps.push({ kind: 'Rolle', name: plan.name, change: 'erstellt' });
|
||||
continue;
|
||||
}
|
||||
|
||||
ids.set(plan.name, found.id);
|
||||
const differs = found.permissions !== want.permissions || found.color !== want.color ||
|
||||
found.hoist !== want.hoist || found.mentionable !== want.mentionable;
|
||||
if (!differs) {
|
||||
steps.push({ kind: 'Rolle', name: plan.name, change: 'unveraendert' });
|
||||
continue;
|
||||
}
|
||||
if (found.managed) {
|
||||
// Rollen, die zu einer App oder zur Twitch-Anbindung gehoeren, gehoeren
|
||||
// Discord. Anfassen geht nicht und waere auch falsch.
|
||||
steps.push({ kind: 'Rolle', name: plan.name, change: 'uebersprungen', note: 'von Discord verwaltet' });
|
||||
continue;
|
||||
}
|
||||
if (!dry) await api.patch(`/guilds/${guild.id}/roles/${found.id}`, want, 'Rolle aus dem Bauplan angeglichen');
|
||||
steps.push({ kind: 'Rolle', name: plan.name, change: 'angepasst' });
|
||||
}
|
||||
|
||||
await applyRoleOrder(api, guild, botUserId, ids, steps, dry);
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function applyRoleOrder(
|
||||
api: Rest, guild: ApiGuild, botUserId: string, ids: Map<string, string>,
|
||||
steps: Step[], dry: boolean,
|
||||
): Promise<void> {
|
||||
const wanted = ROLES.map((r) => ids.get(r.name)).filter((id): id is string => Boolean(id));
|
||||
if (dry || wanted.length === 0) return;
|
||||
|
||||
let botTop: number;
|
||||
let istPosition = new Map<string, number>();
|
||||
try {
|
||||
const member = await api.get<{ roles: string[] }>(`/guilds/${guild.id}/members/${botUserId}`);
|
||||
const fresh = await api.get<ApiRole[]>(`/guilds/${guild.id}/roles`);
|
||||
istPosition = new Map(fresh.map((r) => [r.id, r.position]));
|
||||
botTop = fresh
|
||||
.filter((r) => member.roles.includes(r.id))
|
||||
.reduce((max, r) => Math.max(max, r.position), 0);
|
||||
} catch (err) {
|
||||
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: (err as Error).message });
|
||||
return;
|
||||
}
|
||||
|
||||
const fit = rolePositions(wanted.length, botTop);
|
||||
if ('problem' in fit) {
|
||||
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: fit.problem });
|
||||
log.warn(fit.problem);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stimmt die Rangfolge schon, nicht schreiben. Ein PATCH mit denselben
|
||||
// Werten ist zwar folgenlos, kostet aber bei jedem Lauf einen Aufruf und
|
||||
// einen Eintrag im Audit-Log - und laesst den Lauf so aussehen, als haette
|
||||
// er etwas geaendert.
|
||||
if (wanted.every((id, i) => istPosition.get(id) === fit.positions[i])) {
|
||||
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'unveraendert' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.patch(`/guilds/${guild.id}/roles`,
|
||||
wanted.map((id, i) => ({ id, position: fit.positions[i] })), 'Rangfolge aus dem Bauplan');
|
||||
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'angepasst' });
|
||||
} catch (err) {
|
||||
steps.push({ kind: 'Rollen', name: 'Rangfolge', change: 'uebersprungen', note: (err as Error).message });
|
||||
log.warn(`Rangfolge nicht gesetzt: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Kanaele
|
||||
|
||||
function kindOf(type: number): string {
|
||||
if (type === CH.CATEGORY) return 'Kategorie';
|
||||
if (type === CH.VOICE) return 'Sprachkanal';
|
||||
return 'Kanal';
|
||||
}
|
||||
|
||||
/** Kategorie und Kanal unterscheiden sich fuers Auftragen nur im Typ. */
|
||||
type AnyPlan = CategoryPlan & { type: number; topic?: string; tags?: TagPlan[]; reaction?: string };
|
||||
|
||||
/**
|
||||
* Tags eines Forums: die aus dem Plan nachziehen, fremde stehen lassen.
|
||||
* Discord ersetzt beim PATCH die ganze Liste, deshalb gehen die vorhandenen
|
||||
* mit ihrer ID wieder mit - ohne ID waere es ein neuer Tag, und Beitraege
|
||||
* verloeren ihre Markierung. Gibt null zurueck, wenn alles passt.
|
||||
*/
|
||||
export function mergeTags(existing: ApiTag[], plan: TagPlan[]): ApiTag[] | null {
|
||||
let changed = false;
|
||||
const out = existing.map((t) => ({ ...t }));
|
||||
for (const p of plan) {
|
||||
const want = { name: p.name, emoji_name: p.emoji, moderated: p.moderated ?? false };
|
||||
const hit = out.find((t) => eq(t.name, p.name));
|
||||
if (!hit) {
|
||||
out.push(want);
|
||||
changed = true;
|
||||
} else if (hit.name !== want.name || hit.emoji_name !== want.emoji_name || hit.moderated !== want.moderated) {
|
||||
Object.assign(hit, want, { emoji_id: null });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? out : null;
|
||||
}
|
||||
|
||||
async function applyChannel(
|
||||
api: Rest, guild: ApiGuild, existing: ApiChannel[], steps: Step[], dry: boolean,
|
||||
plan: AnyPlan,
|
||||
parentId: string | null,
|
||||
roleId: (name: string) => string | undefined,
|
||||
): Promise<ApiChannel | null> {
|
||||
const kind = kindOf(plan.type);
|
||||
const found = findChannel(existing, plan);
|
||||
|
||||
if (!found) {
|
||||
if (dry) {
|
||||
steps.push({ kind, name: plan.name, change: 'erstellt', note: 'Vorschau' });
|
||||
return null;
|
||||
}
|
||||
const overwrites: ApiOverwrite[] = (plan.overwrites ?? []).flatMap((p) => {
|
||||
const id = roleId(p.role);
|
||||
return id ? [{ id, type: 0 as const, allow: str(p.allow ?? 0n), deny: str(p.deny ?? 0n) }] : [];
|
||||
});
|
||||
|
||||
const made = await api.post<ApiChannel>(`/guilds/${guild.id}/channels`, {
|
||||
name: plan.name,
|
||||
type: plan.type,
|
||||
...(parentId ? { parent_id: parentId } : {}),
|
||||
...(plan.topic ? { topic: plan.topic } : {}),
|
||||
...(overwrites.length ? { permission_overwrites: overwrites } : {}),
|
||||
...(plan.tags?.length ? { available_tags: mergeTags([], plan.tags) } : {}),
|
||||
...(plan.reaction ? { default_reaction_emoji: { emoji_id: null, emoji_name: plan.reaction } } : {}),
|
||||
}, 'Kanal aus dem Bauplan');
|
||||
steps.push({ kind, name: plan.name, change: 'erstellt' });
|
||||
return made;
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
const rename = !eq(found.name, normalizeName(plan.name, plan.type));
|
||||
if (rename) patch['name'] = plan.name;
|
||||
if (plan.topic && (found.topic ?? '') !== plan.topic) patch['topic'] = plan.topic;
|
||||
if (parentId && found.parent_id !== parentId) patch['parent_id'] = parentId;
|
||||
const merged = mergeOverwrites(found.permission_overwrites ?? [], plan.overwrites ?? [], roleId);
|
||||
if (merged) patch['permission_overwrites'] = merged;
|
||||
const tags = plan.tags?.length ? mergeTags(found.available_tags ?? [], plan.tags) : null;
|
||||
if (tags) patch['available_tags'] = tags;
|
||||
if (plan.reaction && found.default_reaction_emoji?.emoji_name !== plan.reaction) {
|
||||
patch['default_reaction_emoji'] = { emoji_id: null, emoji_name: plan.reaction };
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
steps.push({ kind, name: plan.name, change: 'unveraendert' });
|
||||
return found;
|
||||
}
|
||||
|
||||
const label = rename ? `${found.name} -> ${plan.name}` : plan.name;
|
||||
if (dry) {
|
||||
steps.push({ kind, name: label, change: 'angepasst', note: `Vorschau: ${Object.keys(patch).join(', ')}` });
|
||||
return found;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.patch(`/channels/${found.id}`, patch, 'Kanal aus dem Bauplan angeglichen');
|
||||
} catch (err) {
|
||||
// Umbenennungen sind bei Discord auf zwei je Kanal und zehn Minuten
|
||||
// begrenzt. Das darf den Rest des Laufs nicht aufhalten.
|
||||
steps.push({ kind, name: label, change: 'uebersprungen', note: (err as Error).message });
|
||||
log.warn(`${label}: ${(err as Error).message}`);
|
||||
return found;
|
||||
}
|
||||
steps.push({ kind, name: label, change: 'angepasst', note: Object.keys(patch).join(', ') });
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Webhooks
|
||||
|
||||
async function applyWebhooks(
|
||||
api: Rest, channel: ApiChannel, plan: ChannelPlan, steps: Step[], dry: boolean,
|
||||
urls: Map<WebhookSlot, string>,
|
||||
): Promise<void> {
|
||||
if (!plan.webhooks?.length) return;
|
||||
const existing = await api.get<ApiWebhook[]>(`/channels/${channel.id}/webhooks`);
|
||||
|
||||
for (const w of plan.webhooks) {
|
||||
const found = byName(existing, w.name);
|
||||
if (found?.token) {
|
||||
urls.set(w.slot, `https://discord.com/api/webhooks/${found.id}/${found.token}`);
|
||||
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'unveraendert' });
|
||||
continue;
|
||||
}
|
||||
if (found && !found.token) {
|
||||
// Von Hand angelegte Webhooks geben ihren Token nicht an uns heraus.
|
||||
steps.push({
|
||||
kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'uebersprungen',
|
||||
note: 'von Hand angelegt, URL nicht auslesbar - umbenennen oder loeschen',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (dry) {
|
||||
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'erstellt', note: 'Vorschau' });
|
||||
continue;
|
||||
}
|
||||
const made = await api.post<ApiWebhook>(`/channels/${channel.id}/webhooks`, { name: w.name }, 'Webhook aus dem Bauplan');
|
||||
if (made.token) urls.set(w.slot, `https://discord.com/api/webhooks/${made.id}/${made.token}`);
|
||||
steps.push({ kind: 'Webhook', name: `${w.name} in ${plan.name}`, change: 'erstellt' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- AutoMod
|
||||
|
||||
/**
|
||||
* Die Aktionen einer Regel: Nachricht blocken, und wenn es den Log-Kanal gibt,
|
||||
* dort melden. Ist die Regel schon da, behalten wir ihren bestehenden
|
||||
* Meldekanal - den kann jemand bewusst umgestellt haben.
|
||||
*/
|
||||
function actionsFor(
|
||||
rule: { name: string },
|
||||
logChannelId: string | null,
|
||||
vorhanden: { actions?: Array<{ type: number; metadata?: { channel_id?: string } }> } | undefined,
|
||||
): Array<Record<string, unknown>> {
|
||||
const alterKanal = vorhanden?.actions?.find((a) => a.type === 2)?.metadata?.channel_id;
|
||||
const ziel = alterKanal ?? logChannelId;
|
||||
return [
|
||||
{ type: 1, metadata: { custom_message: AUTOMOD_BLOCK } },
|
||||
...(ziel ? [{ type: 2, metadata: { channel_id: ziel } }] : []),
|
||||
];
|
||||
}
|
||||
|
||||
async function applyAutomod(
|
||||
api: Rest, guild: ApiGuild, logChannelId: string | null, steps: Step[], dry: boolean,
|
||||
): Promise<void> {
|
||||
type Action = { type: number; metadata?: { custom_message?: string; channel_id?: string } };
|
||||
type Rule = { id: string; name: string; actions?: Action[] };
|
||||
let existing: Rule[];
|
||||
try {
|
||||
existing = await api.get<Rule[]>(`/guilds/${guild.id}/auto-moderation/rules`);
|
||||
} catch (err) {
|
||||
steps.push({ kind: 'AutoMod', name: 'alle Regeln', change: 'uebersprungen', note: (err as Error).message });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rule of AUTOMOD) {
|
||||
// Wie bei Kanaelen: erst unter dem geplanten Namen suchen, dann unter dem
|
||||
// frueheren. Sonst steht nach einer Umbenennung dieselbe Regel zweimal da.
|
||||
const found = [rule.name, ...(rule.aliases ?? [])]
|
||||
.map((n) => byName(existing, n)).find((r) => r !== undefined);
|
||||
|
||||
if (found) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (!eq(found.name, rule.name)) patch['name'] = rule.name;
|
||||
// Der Blocktext steht im Bauplan, also gilt der Bauplan. Ohne diesen
|
||||
// Abgleich behaelt eine einmal angelegte Regel ihren alten Text fuer immer.
|
||||
const blockText = found.actions?.find((a) => a.type === 1)?.metadata?.custom_message;
|
||||
if (blockText !== AUTOMOD_BLOCK) patch['actions'] = actionsFor(rule, logChannelId, found);
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
steps.push({ kind: 'AutoMod', name: rule.name, change: 'unveraendert' });
|
||||
continue;
|
||||
}
|
||||
const label = patch['name'] ? `${found.name} -> ${rule.name}` : rule.name;
|
||||
const was = Object.keys(patch).join(', ');
|
||||
if (dry) {
|
||||
steps.push({ kind: 'AutoMod', name: label, change: 'angepasst', note: `Vorschau: ${was}` });
|
||||
continue;
|
||||
}
|
||||
await api.patch(`/guilds/${guild.id}/auto-moderation/rules/${found.id}`,
|
||||
patch, 'AutoMod-Regel aus dem Bauplan angeglichen');
|
||||
steps.push({ kind: 'AutoMod', name: label, change: 'angepasst', note: was });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
steps.push({ kind: 'AutoMod', name: rule.name, change: 'erstellt', note: 'Vorschau' });
|
||||
continue;
|
||||
}
|
||||
await api.post(`/guilds/${guild.id}/auto-moderation/rules`, {
|
||||
name: rule.name,
|
||||
event_type: 1, // beim Senden pruefen
|
||||
trigger_type: rule.trigger_type,
|
||||
trigger_metadata: rule.trigger_metadata,
|
||||
enabled: true,
|
||||
actions: actionsFor(rule, logChannelId, undefined),
|
||||
}, 'AutoMod-Regel aus dem Bauplan');
|
||||
steps.push({ kind: 'AutoMod', name: rule.name, change: 'erstellt' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Willkommen
|
||||
|
||||
/**
|
||||
* Der Begruessungstext.
|
||||
*
|
||||
* Nachrichten von Menschen fasst das Skript nie an. Die eigene schon: bei
|
||||
* Discord kann nur der Verfasser eine Nachricht bearbeiten, eine vom Bot
|
||||
* gepostete kann also niemand sonst veraendert haben. So bleibt der Bauplan
|
||||
* die massgebliche Fassung, auch wenn sich der Text spaeter noch aendert.
|
||||
*/
|
||||
async function applyWelcome(
|
||||
api: Rest, channel: ApiChannel, botUserId: string, mentions: Map<string, string>,
|
||||
steps: Step[], dry: boolean,
|
||||
): Promise<void> {
|
||||
const body = WELCOME.replace(/\{(\w[\w-]*)\}/g, (all, key: string) => {
|
||||
const id = mentions.get(key);
|
||||
return id ? `<#${id}>` : all;
|
||||
});
|
||||
const components = selfRoleComponents();
|
||||
const payload = {
|
||||
content: body,
|
||||
components,
|
||||
allowed_mentions: { parse: [] }, // der Begruessungstext pingt niemanden
|
||||
};
|
||||
|
||||
type Msg = { id: string; content: string; author?: { id: string }; components?: unknown[] };
|
||||
const seen = await api.get<Msg[]>(`/channels/${channel.id}/messages?limit=50`);
|
||||
const own = seen.find((m) => m.author?.id === botUserId);
|
||||
|
||||
if (own) {
|
||||
// Auch die Knopfleiste gehoert zum Soll-Zustand: fehlt sie, weil die
|
||||
// Nachricht aus einer Zeit vor den Knoepfen stammt, wird sie nachgereicht.
|
||||
const knoepfe = (own.components ?? []).length;
|
||||
if (own.content === body && knoepfe === components.length) {
|
||||
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'unveraendert' });
|
||||
return;
|
||||
}
|
||||
if (dry) {
|
||||
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'angepasst', note: 'Vorschau: Text' });
|
||||
return;
|
||||
}
|
||||
await api.patch(`/channels/${channel.id}/messages/${own.id}`, payload);
|
||||
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'angepasst' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (seen.length > 0) {
|
||||
steps.push({
|
||||
kind: 'Nachricht', name: 'Willkommenstext', change: 'uebersprungen',
|
||||
note: 'im Kanal steht schon etwas, das nicht vom Bot ist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (dry) {
|
||||
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'erstellt', note: 'Vorschau' });
|
||||
return;
|
||||
}
|
||||
await api.post(`/channels/${channel.id}/messages`, payload);
|
||||
steps.push({ kind: 'Nachricht', name: 'Willkommenstext', change: 'erstellt' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Hauptlauf
|
||||
|
||||
export async function applyPlan(
|
||||
api: Rest, guildId: string, opts: ApplyOptions = {},
|
||||
): Promise<ApplyResult> {
|
||||
const dry = opts.dryRun ?? false;
|
||||
const steps: Step[] = [];
|
||||
|
||||
const guild = await api.get<ApiGuild>(`/guilds/${guildId}`);
|
||||
const me = await api.get<ApiUser>('/users/@me');
|
||||
log.info(`Server "${guild.name}"${dry ? ' (Vorschau, es wird nichts geaendert)' : ''}`);
|
||||
|
||||
// 1. Servereinstellungen. Vergleichen statt blind schreiben: sonst meldet
|
||||
// jeder Lauf eine Aenderung, auch wenn es keine gab, und die Vorschau sagt
|
||||
// etwas anderes als der echte Lauf.
|
||||
const abweichend = (Object.entries(GUILD_SETTINGS) as Array<[keyof ApiGuild, number]>)
|
||||
.some(([k, v]) => guild[k] !== v);
|
||||
if (abweichend) {
|
||||
if (!dry) await api.patch(`/guilds/${guild.id}`, GUILD_SETTINGS, 'Servereinstellungen aus dem Bauplan');
|
||||
steps.push({ kind: 'Server', name: guild.name, change: dry ? 'erstellt' : 'angepasst' });
|
||||
} else {
|
||||
steps.push({ kind: 'Server', name: guild.name, change: 'unveraendert' });
|
||||
}
|
||||
|
||||
// 2. Rollen
|
||||
const roleIds = await applyRoles(api, guild, me.id, steps, dry);
|
||||
const roleId = (name: string): string | undefined => roleIds.get(name);
|
||||
|
||||
// 3. Kategorien, dann Kanaele
|
||||
const channels = await api.get<ApiChannel[]>(`/guilds/${guild.id}/channels`);
|
||||
const categoryIds = new Map<string, string>();
|
||||
for (const cat of CATEGORIES) {
|
||||
const made = await applyChannel(api, guild, channels, steps, dry, { ...cat, type: CH.CATEGORY }, null, roleId);
|
||||
if (made) categoryIds.set(cat.key, made.id);
|
||||
}
|
||||
|
||||
const urls = new Map<WebhookSlot, string>();
|
||||
const made = new Map<string, ApiChannel>();
|
||||
for (const plan of CHANNELS) {
|
||||
const parent = plan.category ? categoryIds.get(plan.category) ?? null : null;
|
||||
const channel = await applyChannel(api, guild, channels, steps, dry, plan, parent, roleId);
|
||||
if (!channel) continue;
|
||||
made.set(plan.key, channel);
|
||||
await applyWebhooks(api, channel, plan, steps, dry, urls);
|
||||
}
|
||||
|
||||
// 4. Beitrittsmeldungen, AutoMod und Begruessung - alles braucht Kanaele,
|
||||
// die es geben muss.
|
||||
const system = made.get(SYSTEM_CHANNEL);
|
||||
if (system && guild.system_channel_id !== system.id) {
|
||||
if (!dry) await api.patch(`/guilds/${guild.id}`, { system_channel_id: system.id }, 'Beitrittsmeldungen aus dem Bauplan');
|
||||
steps.push({ kind: 'Server', name: `Beitrittsmeldungen nach ${system.name}`, change: dry ? 'erstellt' : 'angepasst' });
|
||||
}
|
||||
await applyAutomod(api, guild, made.get('technik-log')?.id ?? null, steps, dry);
|
||||
const welcome = made.get('willkommen');
|
||||
if (welcome) {
|
||||
const mentions = new Map([...made].map(([key, c]) => [key, c.id]));
|
||||
await applyWelcome(api, welcome, me.id, mentions, steps, dry);
|
||||
}
|
||||
|
||||
// 5. Webhook-URLs wegschliessen. Sie werden nie ausgegeben - siehe ADR-0005.
|
||||
if (!dry && opts.saveSecrets !== false && urls.size > 0) {
|
||||
updateSecrets((s) => {
|
||||
s.discord = { ...s.discord, guildId: guild.id };
|
||||
for (const [slot, url] of urls) s.discord[slot] = url;
|
||||
});
|
||||
log.info(`${urls.size} Webhook-URLs im verschluesselten Speicher abgelegt`);
|
||||
}
|
||||
|
||||
return { guild: guild.name, steps, webhooks: urls.size };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Der laufende Teil der Discord-Anbindung: haelt die Gateway-Verbindung und
|
||||
// beantwortet Klicks auf die Rollen-Knoepfe.
|
||||
//
|
||||
// Laeuft im Ingest-Daemon mit. Ohne hinterlegtes Bot-Token passiert nichts -
|
||||
// das ist kein Fehler, sondern der Zustand vor der Einrichtung.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
import { Discord } from './api.ts';
|
||||
import { createGateway } from './gateway.ts';
|
||||
import { createRoleButtons } from './selfroles.ts';
|
||||
|
||||
const log = logger('discord:bot');
|
||||
|
||||
export type Bot = {
|
||||
run: (signal: AbortSignal) => Promise<void>;
|
||||
isConnected: () => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gibt null zurueck, wenn Token oder Server-ID fehlen. Der Aufrufer soll dann
|
||||
* einfach ohne Discord weiterlaufen.
|
||||
*/
|
||||
export function createBot(): Bot | null {
|
||||
const cfg = readSecrets().discord;
|
||||
if (!cfg?.botToken || !cfg.guildId) {
|
||||
log.info('Kein Discord-Bot hinterlegt - Rollen-Knoepfe bleiben aus. ' +
|
||||
'Einrichten mit: abdulive discord:setup');
|
||||
return null;
|
||||
}
|
||||
|
||||
const api = new Discord(cfg.botToken);
|
||||
const buttons = createRoleButtons(api, cfg.guildId);
|
||||
const gateway = createGateway(
|
||||
api.botToken,
|
||||
(path) => api.get<{ url: string }>(path),
|
||||
{
|
||||
onInteraction: (i) => buttons.handle(i),
|
||||
onReady: ({ user }) => log.info(`Rollen-Knoepfe sind scharf (${user.username})`),
|
||||
},
|
||||
);
|
||||
|
||||
return { run: (signal) => gateway.run(signal), isConnected: () => gateway.isConnected() };
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Discord-Gateway ueber WebSocket. Nur so viel, wie ein Knopf braucht:
|
||||
// verbinden, am Leben halten, Interaktionen durchreichen.
|
||||
//
|
||||
// Warum ueberhaupt eine dauerhafte Verbindung: Discord liefert Klicks auf
|
||||
// einen Knopf entweder ans Gateway oder an eine oeffentlich erreichbare
|
||||
// HTTPS-Adresse. Der Stream-PC hat keine, also Gateway. Die Verbindung haengt
|
||||
// im Ingest-Daemon mit drin und kostet dort praktisch nichts.
|
||||
//
|
||||
// Absichten (Intents) stehen auf 0: Interaktionen kommen immer an, ohne
|
||||
// Berechtigung fuer Nachrichteninhalte oder Mitgliederlisten. Was wir nicht
|
||||
// anfordern, koennen wir auch nicht versehentlich mitlesen.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { forever } from '#core/retry.ts';
|
||||
|
||||
const log = logger('discord:gateway');
|
||||
|
||||
// Betriebsarten des Gateways
|
||||
const OP = {
|
||||
DISPATCH: 0,
|
||||
HEARTBEAT: 1,
|
||||
IDENTIFY: 2,
|
||||
RESUME: 6,
|
||||
RECONNECT: 7,
|
||||
INVALID_SESSION: 9,
|
||||
HELLO: 10,
|
||||
HEARTBEAT_ACK: 11,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Schliessgruende, bei denen ein neuer Versuch nichts bringt: falsches Token,
|
||||
* falsche Absichten, falsche Fassung. Dagegen hilft nur eine Aenderung an der
|
||||
* App, und bis dahin waere jeder Wiederverbindungsversuch nur Laerm.
|
||||
*/
|
||||
const FATAL_CLOSE = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
|
||||
|
||||
export type Payload = { op: number; d?: unknown; s?: number | null; t?: string | null };
|
||||
|
||||
/** Nur die Felder einer Interaktion, die hier gebraucht werden. */
|
||||
export type Interaction = {
|
||||
id: string;
|
||||
token: string;
|
||||
type: number;
|
||||
application_id: string;
|
||||
guild_id?: string;
|
||||
channel_id?: string;
|
||||
data?: { custom_id?: string; component_type?: number };
|
||||
member?: { user: { id: string; username: string }; roles: string[] };
|
||||
user?: { id: string; username: string };
|
||||
};
|
||||
|
||||
export type GatewayHandlers = {
|
||||
onReady?: (info: { user: { id: string; username: string }; sessionId: string }) => void;
|
||||
onInteraction?: (interaction: Interaction) => Promise<void> | void;
|
||||
onClosed?: (code: number, reason: string) => void;
|
||||
};
|
||||
|
||||
export type GatewayClient = {
|
||||
/** Haelt die Verbindung; kehrt erst bei `signal.abort` oder einem harten Fehler zurueck. */
|
||||
run: (signal: AbortSignal) => Promise<void>;
|
||||
isConnected: () => boolean;
|
||||
};
|
||||
|
||||
/** Was ueber eine Trennung hinweg erhalten bleibt, damit fortgesetzt statt neu begonnen wird. */
|
||||
type Session = { id: string; resumeUrl: string; seq: number | null };
|
||||
|
||||
export function createGateway(
|
||||
token: string,
|
||||
restUrl: (path: string) => Promise<{ url: string }>,
|
||||
handlers: GatewayHandlers,
|
||||
): GatewayClient {
|
||||
let session: Session | null = null;
|
||||
let connected = false;
|
||||
|
||||
return {
|
||||
isConnected: () => connected,
|
||||
run: async (outer) => {
|
||||
// Bei einem harten Fehler brechen wir die Schleife selbst ab, statt
|
||||
// Discord alle paar Sekunden erneut mit demselben Token zu behelligen.
|
||||
const own = new AbortController();
|
||||
const signal = AbortSignal.any([outer, own.signal]);
|
||||
|
||||
await forever(
|
||||
async (sig) => {
|
||||
try {
|
||||
await connectOnce(token, restUrl, handlers, sig, {
|
||||
get: () => session,
|
||||
set: (s) => { session = s; },
|
||||
setConnected: (v) => { connected = v; },
|
||||
giveUp: () => own.abort(),
|
||||
});
|
||||
} finally {
|
||||
connected = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
signal,
|
||||
baseMs: 2000,
|
||||
maxMs: 120_000,
|
||||
onWait: (ms, n) => {
|
||||
if (own.signal.aborted) return;
|
||||
log.warn(`Gateway getrennt, neuer Versuch in ${Math.round(ms / 1000)} s (${n}.)`);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function connectOnce(
|
||||
botToken: string,
|
||||
getGateway: (path: string) => Promise<{ url: string }>,
|
||||
h: GatewayHandlers,
|
||||
signal: AbortSignal,
|
||||
store: {
|
||||
get: () => Session | null;
|
||||
set: (s: Session | null) => void;
|
||||
setConnected: (v: boolean) => void;
|
||||
/** Bricht die Wiederverbindungsschleife ab - nur bei harten Fehlern. */
|
||||
giveUp: () => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
return new Promise<void>((_resolve, reject) => {
|
||||
let ws: WebSocket | null = null;
|
||||
let beat: ReturnType<typeof setInterval> | null = null;
|
||||
let firstBeat: ReturnType<typeof setTimeout> | null = null;
|
||||
let acked = true;
|
||||
let seq: number | null = store.get()?.seq ?? null;
|
||||
let done = false;
|
||||
|
||||
const finish = (why: string): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (beat) clearInterval(beat);
|
||||
if (firstBeat) clearTimeout(firstBeat);
|
||||
store.setConnected(false);
|
||||
try { ws?.close(); } catch { /* schon zu */ }
|
||||
reject(new Error(why));
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', () => finish('abgebrochen'), { once: true });
|
||||
|
||||
const send = (payload: Payload): void => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const heartbeat = (): void => {
|
||||
if (!acked) {
|
||||
// Discord hat den letzten Schlag nicht bestaetigt. Die Verbindung
|
||||
// sieht offen aus, ist es aber nicht mehr - abreissen und neu.
|
||||
log.warn('Kein Herzschlag bestaetigt, Verbindung gilt als tot');
|
||||
finish('Herzschlag ohne Antwort');
|
||||
return;
|
||||
}
|
||||
acked = false;
|
||||
send({ op: OP.HEARTBEAT, d: seq });
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
let url: string;
|
||||
const resume = store.get();
|
||||
try {
|
||||
url = resume?.resumeUrl ?? (await getGateway('/gateway/bot')).url;
|
||||
} catch (err) {
|
||||
finish(`Gateway-Adresse nicht abrufbar: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
if (done) return;
|
||||
|
||||
ws = new WebSocket(`${url}?v=10&encoding=json`);
|
||||
|
||||
ws.addEventListener('close', (ev) => {
|
||||
const code = (ev as CloseEvent).code;
|
||||
const reason = (ev as CloseEvent).reason || '';
|
||||
h.onClosed?.(code, reason);
|
||||
if (FATAL_CLOSE.has(code)) {
|
||||
// Nicht fortsetzbar und nicht behebbar: Token oder Absichten.
|
||||
log.error(`Gateway hat endgueltig geschlossen (${code} ${reason}). ` +
|
||||
'Bot-Token pruefen und den Dienst neu starten.');
|
||||
store.set(null);
|
||||
store.giveUp();
|
||||
} else if (code === 4007 || code === 4009 || code === 1000 || code === 1001) {
|
||||
// Sitzung ist hin, aber ein neuer Anlauf lohnt.
|
||||
store.set(null);
|
||||
}
|
||||
finish(`geschlossen (${code})`);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => finish('Verbindungsfehler'));
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
let msg: Payload;
|
||||
try {
|
||||
msg = JSON.parse(String((ev as MessageEvent).data)) as Payload;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof msg.s === 'number') seq = msg.s;
|
||||
|
||||
switch (msg.op) {
|
||||
case OP.HELLO: {
|
||||
const interval = (msg.d as { heartbeat_interval: number }).heartbeat_interval;
|
||||
// Der erste Schlag kommt versetzt, damit nicht alle Verbindungen
|
||||
// der Welt im selben Moment klopfen - so schreibt es Discord vor.
|
||||
firstBeat = setTimeout(() => {
|
||||
heartbeat();
|
||||
beat = setInterval(heartbeat, interval);
|
||||
}, Math.floor(interval * Math.random()));
|
||||
|
||||
const s = store.get();
|
||||
if (s) {
|
||||
send({ op: OP.RESUME, d: { token: botToken, session_id: s.id, seq: s.seq } });
|
||||
} else {
|
||||
send({
|
||||
op: OP.IDENTIFY,
|
||||
d: {
|
||||
token: botToken,
|
||||
intents: 0, // nur Interaktionen, nichts mitlesen
|
||||
properties: { os: process.platform, browser: 'abdulive-toolkit', device: 'abdulive-toolkit' },
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case OP.HEARTBEAT:
|
||||
// Discord darf jederzeit einen Schlag anfordern.
|
||||
send({ op: OP.HEARTBEAT, d: seq });
|
||||
return;
|
||||
|
||||
case OP.HEARTBEAT_ACK:
|
||||
acked = true;
|
||||
return;
|
||||
|
||||
case OP.RECONNECT:
|
||||
log.info('Gateway bittet um Wiederverbindung');
|
||||
finish('Wiederverbindung angefordert');
|
||||
return;
|
||||
|
||||
case OP.INVALID_SESSION:
|
||||
// d = true heisst: die Sitzung laesst sich noch fortsetzen.
|
||||
if (msg.d !== true) store.set(null);
|
||||
finish('Sitzung ungueltig');
|
||||
return;
|
||||
|
||||
case OP.DISPATCH:
|
||||
onDispatch(msg);
|
||||
return;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
function onDispatch(msg: Payload): void {
|
||||
if (msg.t === 'READY') {
|
||||
const d = msg.d as {
|
||||
session_id: string; resume_gateway_url: string;
|
||||
user: { id: string; username: string };
|
||||
};
|
||||
store.set({ id: d.session_id, resumeUrl: d.resume_gateway_url, seq });
|
||||
store.setConnected(true);
|
||||
log.info(`Gateway verbunden als ${d.user.username}`);
|
||||
h.onReady?.({ user: d.user, sessionId: d.session_id });
|
||||
return;
|
||||
}
|
||||
if (msg.t === 'RESUMED') {
|
||||
store.setConnected(true);
|
||||
log.info('Gateway-Sitzung fortgesetzt');
|
||||
return;
|
||||
}
|
||||
if (msg.t === 'INTERACTION_CREATE') {
|
||||
const s = store.get();
|
||||
if (s) store.set({ ...s, seq });
|
||||
void Promise.resolve(h.onInteraction?.(msg.d as Interaction))
|
||||
.catch((err: unknown) => log.error(`Interaktion nicht verarbeitet: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Discord-Rechtebits. Die Werte sind ein 64-Bit-Feld und werden in der API
|
||||
// als Dezimalstring uebertragen - deshalb BigInt und nicht number.
|
||||
//
|
||||
// Kein `enum`: Node fuehrt die .ts-Dateien direkt aus und entfernt nur Typen,
|
||||
// ein enum waere zur Laufzeit weg (ADR-0001, `erasableSyntaxOnly`).
|
||||
|
||||
export const P = {
|
||||
CREATE_INSTANT_INVITE: 1n << 0n,
|
||||
KICK_MEMBERS: 1n << 1n,
|
||||
BAN_MEMBERS: 1n << 2n,
|
||||
ADMINISTRATOR: 1n << 3n,
|
||||
MANAGE_CHANNELS: 1n << 4n,
|
||||
MANAGE_GUILD: 1n << 5n,
|
||||
ADD_REACTIONS: 1n << 6n,
|
||||
VIEW_AUDIT_LOG: 1n << 7n,
|
||||
PRIORITY_SPEAKER: 1n << 8n,
|
||||
STREAM: 1n << 9n,
|
||||
VIEW_CHANNEL: 1n << 10n,
|
||||
SEND_MESSAGES: 1n << 11n,
|
||||
SEND_TTS_MESSAGES: 1n << 12n,
|
||||
MANAGE_MESSAGES: 1n << 13n,
|
||||
EMBED_LINKS: 1n << 14n,
|
||||
ATTACH_FILES: 1n << 15n,
|
||||
READ_MESSAGE_HISTORY: 1n << 16n,
|
||||
MENTION_EVERYONE: 1n << 17n,
|
||||
USE_EXTERNAL_EMOJIS: 1n << 18n,
|
||||
VIEW_GUILD_INSIGHTS: 1n << 19n,
|
||||
CONNECT: 1n << 20n,
|
||||
SPEAK: 1n << 21n,
|
||||
MUTE_MEMBERS: 1n << 22n,
|
||||
DEAFEN_MEMBERS: 1n << 23n,
|
||||
MOVE_MEMBERS: 1n << 24n,
|
||||
USE_VAD: 1n << 25n,
|
||||
CHANGE_NICKNAME: 1n << 26n,
|
||||
MANAGE_NICKNAMES: 1n << 27n,
|
||||
MANAGE_ROLES: 1n << 28n,
|
||||
MANAGE_WEBHOOKS: 1n << 29n,
|
||||
MANAGE_GUILD_EXPRESSIONS: 1n << 30n,
|
||||
USE_APPLICATION_COMMANDS: 1n << 31n,
|
||||
REQUEST_TO_SPEAK: 1n << 32n,
|
||||
MANAGE_EVENTS: 1n << 33n,
|
||||
MANAGE_THREADS: 1n << 34n,
|
||||
CREATE_PUBLIC_THREADS: 1n << 35n,
|
||||
CREATE_PRIVATE_THREADS: 1n << 36n,
|
||||
USE_EXTERNAL_STICKERS: 1n << 37n,
|
||||
SEND_MESSAGES_IN_THREADS: 1n << 38n,
|
||||
USE_EMBEDDED_ACTIVITIES: 1n << 39n,
|
||||
MODERATE_MEMBERS: 1n << 40n,
|
||||
USE_SOUNDBOARD: 1n << 42n,
|
||||
CREATE_EVENTS: 1n << 44n,
|
||||
USE_EXTERNAL_SOUNDS: 1n << 45n,
|
||||
SEND_VOICE_MESSAGES: 1n << 46n,
|
||||
SEND_POLLS: 1n << 49n,
|
||||
} as const;
|
||||
|
||||
export type PermName = keyof typeof P;
|
||||
|
||||
/** Vereinigt mehrere Rechte zu einem Bitfeld. */
|
||||
export function bits(...names: PermName[]): bigint {
|
||||
let out = 0n;
|
||||
for (const n of names) out |= P[n];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Wie die API es erwartet: Dezimalstring. */
|
||||
export const str = (v: bigint): string => v.toString();
|
||||
|
||||
/** Namen eines Bitfelds - nur fuer die Vorschau und Fehlermeldungen. */
|
||||
export function names(value: bigint): PermName[] {
|
||||
const out: PermName[] = [];
|
||||
for (const [name, bit] of Object.entries(P) as Array<[PermName, bigint]>) {
|
||||
if ((value & bit) === bit) out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user