Struktur, Doku und S0-Toolchain fuer das abdulive-Toolkit

Monorepo-Geruest fuer neun Streamer-Tools plus den Ingest-Daemon, der
unter sechs davon liegt. Enthaelt:

- docs/: Ueberblick, Architektur, Twitch-Integration, Datenmodell,
  Roadmap, Setup, Hebel-Analyse und den MVP-Plan "Clip-Knopf"
- docs/projects/: je eine Spezifikation pro Tool
- docs/decisions/: vier ADRs (TS-Monorepo, SQLite, EventSub/IRC, Docker)
- docker/: Image mit ffmpeg und whisper.cpp, abhaengigkeitsfreier
  Entrypoint mit check-env, fetch-model und calibrate
- config/layout.yaml: aus einem VOD-Screenshot eingemessener
  Facecam-Ausschnitt (440x330 oben links, Chat-Overlay ab x=1270)

Noch kein Anwendungscode - Scheibe S0 aus docs/07-mvp-plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 08:56:09 +02:00
commit d09e2b945a
98 files changed
+3273

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
node_modules
**/node_modules
**/dist
.git
data
logs
.env
docs
*.md
+41
View File
@@ -0,0 +1,41 @@
# Kopie als .env anlegen. Diese Datei enthaelt Secrets und gehoert nie ins Repo.
# Twitch-App fuer den Broadcaster-Account (abdulive_)
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
# Twitch-App fuer das Bot-Konto
TWITCH_BOT_CLIENT_ID=
TWITCH_BOT_CLIENT_SECRET=
# OAuth-Redirect (muss in beiden Apps eingetragen sein)
TWITCH_REDIRECT_URI=http://localhost:4780/callback
# obs-websocket v5
OBS_WS_URL=ws://127.0.0.1:4455
OBS_WS_PASSWORD=
# Discord-Webhooks
DISCORD_WEBHOOK_GOLIVE=
DISCORD_WEBHOOK_RECAP=
DISCORD_WEBHOOK_INTERNAL=
# Bluesky
BLUESKY_HANDLE=
BLUESKY_APP_PASSWORD=
# Mastodon (optional)
MASTODON_INSTANCE=
MASTODON_TOKEN=
# Push-Benachrichtigung aufs Handy (optional, ntfy.sh)
NTFY_TOPIC=
# Schluessel fuer die Token-Verschluesselung in data/db/tokens.json
TOKEN_ENCRYPTION_KEY=
LOG_LEVEL=info
# Windows-Pfad zum OBS-Wiedergabepuffer-Ordner (fuer den Bind-Mount)
# Forward-Slashes verwenden, z. B. C:/Users/abdu/Videos/replay
OBS_REPLAY_DIR=
+23
View File
@@ -0,0 +1,23 @@
node_modules/
dist/
build/
.turbo/
*.tsbuildinfo
# Secrets und lokale Konfiguration
.env
.env.local
config/config.yaml
config/raid-blocklist.yaml
# Personenbezogene Daten und Medien: gehoeren nie ins Repo
data/
logs/
*.db
*.db-wal
*.db-shm
.DS_Store
Thumbs.db
.vscode/
.idea/
+57
View File
@@ -0,0 +1,57 @@
# abdulive-toolkit
Werkzeugkasten für den Twitch-Kanal **abdulive_**. Ein Monorepo mit mehreren
kleinen Diensten, die sich einen gemeinsamen Kern teilen: Twitch-Auth,
Helix-Client, EventSub/IRC-Ingest und eine SQLite-Datenbank.
Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese
Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**.
## Die neun Tools
| # | Tool | Ordner | Zweck | Status |
|---|------|--------|-------|--------|
| 1 | Clip-to-Shorts | `apps/clipper` | Clips → 9:16 mit Untertiteln, Upload-fertig | geplant |
| 2 | Go-Live-Broadcaster | `apps/broadcaster` | Stream-Start → Discord/Bluesky/Instagram | geplant |
| 3 | Raid- & Netzwerk-Tool | `apps/raid-scout` | Raid-Ziele finden, Raid-Historie führen | geplant |
| 4 | Analytics-Dashboard | `apps/dashboard` | Viewer vs. Chat, Titel-Performance, Retention | geplant |
| 5 | VOD-Kapitel | `apps/vod-chapters` | Szenen-/Kategoriewechsel → Timestamps | geplant |
| 6 | Community-Gedächtnis | `apps/community` | Chatter-Profile, Begrüßungen, Overlay-Hinweise | geplant |
| 7 | Chat steuert Dinge | `apps/overlay-server` + `apps/overlay-web` | Overlay, Soundboard, Minigames, Umfragen | geplant |
| 8 | Punktesystem + Shop | `apps/loyalty` | Punkte mit echten Konsequenzen | geplant |
| 9 | Stream-Recap | `apps/recap` | Zusammenfassung nach Streamende in Discord | geplant |
Dazu der Dienst, der unter allen anderen liegt:
| — | **Ingest-Daemon** | `apps/ingest` | EventSub + IRC → Event-Bus + DB | **zuerst** |
## Doku
- [`docs/00-overview.md`](docs/00-overview.md) — Was hier gebaut wird und warum
- [`docs/01-architecture.md`](docs/01-architecture.md) — Aufbau, Datenfluss, Prozesse
- [`docs/02-twitch-integration.md`](docs/02-twitch-integration.md) — Auth, Scopes, EventSub, Rate Limits
- [`docs/03-data-model.md`](docs/03-data-model.md) — Datenbankschema
- [`docs/04-roadmap.md`](docs/04-roadmap.md) — Reihenfolge und Meilensteine
- [`docs/05-setup.md`](docs/05-setup.md) — Lokal einrichten
- [`docs/06-hebel-analyse.md`](docs/06-hebel-analyse.md) — **Womit anfangen und warum**
- [`docs/07-mvp-plan.md`](docs/07-mvp-plan.md) — **MVP „Clip-Knopf": Umfang, Scheiben, Zeitplan**
- [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool
- [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs)
## Schnellstart
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).
```powershell
Copy-Item .env.example .env
Copy-Item config\config.example.yaml config\config.yaml
docker compose build
docker compose run --rm toolkit check-env
```
## Aktueller Stand
Struktur und Dokumentation stehen, Code noch nicht. Als Nächstes:
**MVP „Clip-Knopf"** nach [`docs/07-mvp-plan.md`](docs/07-mvp-plan.md).
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/broadcaster
Go-Live-Ankuendigung auf Discord, Bluesky, Mastodon, plus Instagram-Story-Bild.
Spezifikation: [docs/projects/go-live-broadcaster.md](../../docs/projects/go-live-broadcaster.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/broadcaster",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/broadcaster — siehe docs/projects/go-live-broadcaster.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/clipper
Clip-to-Shorts-Pipeline: Trigger -> Replay/Clip -> 9:16 -> Untertitel -> Upload-Kandidat.
Spezifikation: [docs/projects/clip-shorts.md](../../docs/projects/clip-shorts.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/clipper",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/clipper — siehe docs/projects/clip-shorts.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/community
Community-Gedaechtnis: Begruessungen, Meilensteine, Overlay-Hinweis. Modul im overlay-server.
Spezifikation: [docs/projects/community-memory.md](../../docs/projects/community-memory.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/community",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/community — siehe docs/projects/community-memory.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/dashboard
Analytics-Dashboard und Freigabe-Ansicht fuer Clip-Kandidaten.
Spezifikation: [docs/projects/analytics.md](../../docs/projects/analytics.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/dashboard — siehe docs/projects/analytics.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/ingest
Daemon: IRC + EventSub + Helix-Polling + OBS -> DB und Event-Bus. Das Fundament.
Spezifikation: [docs/projects/00-ingest.md](../../docs/projects/00-ingest.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/ingest",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/ingest — siehe docs/projects/00-ingest.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/loyalty
Punktesystem mit Ledger und Shop mit echten Effekten. Modul im overlay-server.
Spezifikation: [docs/projects/loyalty-shop.md](../../docs/projects/loyalty-shop.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/loyalty",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/loyalty — siehe docs/projects/loyalty-shop.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/overlay-server
WebSocket-Backend und Command-Registry fuer alle Overlays.
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/overlay-server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/overlay-server — siehe docs/projects/chat-control.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/overlay-web
Browser-Sources fuer OBS: Alerts, Effekte, Community-Hinweis, Umfrage, Raid-Panel.
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/overlay-web",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/overlay-web — siehe docs/projects/chat-control.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/raid-scout
Raid-Ziele finden und bewerten, Raid-Historie und Erwiderungs-Erinnerung.
Spezifikation: [docs/projects/raid-network.md](../../docs/projects/raid-network.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/raid-scout",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/raid-scout — siehe docs/projects/raid-network.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/recap
Stream-Zusammenfassung nach Streamende als Discord-Post.
Spezifikation: [docs/projects/stream-recap.md](../../docs/projects/stream-recap.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/recap",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/recap — siehe docs/projects/stream-recap.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/vod-chapters
Kapitel-Timestamps aus Kategorie- und Szenenwechseln.
Spezifikation: [docs/projects/vod-chapters.md](../../docs/projects/vod-chapters.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/vod-chapters",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/vod-chapters — siehe docs/projects/vod-chapters.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+95
View File
@@ -0,0 +1,95 @@
# Kopie als config/config.yaml anlegen. Secrets gehoeren in .env, nicht hierher.
channel:
login: abdulive_
userId: "" # wird beim ersten `pnpm auth` gefuellt
language: de
timezone: Europe/Berlin
bot:
login: abdulive_bot
ingest:
viewerPollSeconds: 60
chattersPollSeconds: 300
sessionGapMinutes: 5 # kuerzere Luecken gelten als derselbe Stream
spike:
windowSeconds: 15
baselineMinutes: 10
factor: 3
minDelta: 4
cooldownSeconds: 60
armed: false # erst nach Kalibrierung auf true
laughEmotes: [KEKW, LUL, OMEGALUL, PogChamp, Pog]
obs:
url: ws://127.0.0.1:4455
replayBufferSeconds: 90
chapterScenes: [Gameplay, "Just Chatting", Startbildschirm]
clipper:
enabled: true
source: obs-replay # obs-replay | twitch
alsoCreateTwitchClip: true
maxDurationSeconds: 60
renderWhileLive: false # true = sofort rendern, false = erst nach Streamende
whisper:
binary: whisper-cli # whisper.cpp, im Image enthalten
model: ggml-small.bin
language: de
threads: 4
ffmpeg:
preset: veryfast
crf: 20
encoder: libx264 # keine NVIDIA-GPU da, QSV aus WSL2 unzuverlaessig
niceness: 15 # Encoding laeuft nachrangig zum Stream
triggers:
chatCommand: "!clip"
hotkeyName: SaveReplay
onSpike: false
broadcaster:
enabled: true
mode: draft # draft | live
delaySecondsAfterOnline: 60
cooldownHours: 6
platforms:
discord: true
bluesky: true
mastodon: false
instagramStoryImage: true
community:
greetings:
enabled: true
minSecondsBetween: 20
maxPerStream: 10
returningAfterDays: 21
milestones: [5, 10, 25, 50, 100]
suppressWhileStreamerSpeaking: true
overlayHint: true
raidScout:
minViewers: 5
maxViewers: 30
minUptimeMinutes: 30
maxUptimeMinutes: 300
languages: [de]
topN: 5
recap:
enabled: true
delayMinutesAfterOffline: 10
minStreamMinutes: 20
hideViewerNumbersIfWeak: true
topChatters: 3
topClips: 2
loyalty:
enabled: false # spaetere Ausbaustufe
overlay:
port: 4791
dashboard:
port: 4792
ingestServer:
port: 4790
+44
View File
@@ -0,0 +1,44 @@
# Eingemessen am 2026-09-09 aus einem VOD-Screenshot (GTA 5, Szene "Gameplay").
# Alle Werte in Koordinaten des fertigen Streambildes 1920x1080.
#
# Nachmessen: docker compose run --rm toolkit calibrate
# Hintergrund: docs/projects/clip-shorts.md
source:
width: 1920
height: 1080
# Ziel: 9:16 fuer Shorts/Reels/TikTok
target:
width: 1080
height: 1920
profiles:
# Standardprofil: Facecam oben links, Chat-Overlay rechts eingebrannt
default:
description: >
Facecam bündig oben links (440x330, 4:3). Chat-Overlay ist ins Streambild
eingebrannt und beginnt bei x=1270 - der Gameplay-Ausschnitt endet
deshalb bei x=1268. Minimap (x<360) und Handy-Overlay (x>1540) liegen
ausserhalb und fallen automatisch weg.
cam:
# Facecam im Streambild: x 0..440, y 0..330
# Davon wird ein Streifen genommen, der zum Zielband passt - NICHT
# verzerrt. 4:3 auf 16:9 zu strecken wuerde das Gesicht breit ziehen.
crop: { x: 0, y: 100, w: 440, h: 208 }
band: { w: 1080, h: 512 }
game:
# Sauberes Band zwischen Facecam (endet 440) und Chat (beginnt 1270)
crop: { x: 440, y: 0, w: 828, h: 1080 }
band: { w: 1080, h: 1408 }
# 512 + 1408 = 1920
# Messwerte aus dem Referenzbild, zur Nachvollziehbarkeit:
# Videobereich im Screenshot : x 0..1916, y 60..1137 (1917x1078)
# Facecam rechte Kante : x 439 (konstant bei y=120/200/280/360)
# Facecam untere Kante : y 391 (konstant bei x=40/140/240/340/420)
# Chat-Overlay beginnt : x 1268
# Gesicht (Cam-lokal) : y 126..300, Mitte ~213
+49
View File
@@ -0,0 +1,49 @@
# Stand: Scheibe S0 aus docs/07-mvp-plan.md.
# Es gibt noch keinen Daemon - der Container fuehrt einzelne Befehle aus
# (check-env, fetch-model, calibrate). Ab S1 wird daraus ein Langlaeufer.
#
# 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: ["help"]
# OBS laeuft auf dem Host, nicht im Container
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "127.0.0.1:4780:4780" # OAuth-Rueckruf, nur waehrend `auth` (ab S1)
- "127.0.0.1:4791:4791" # Overlay-Server (ab M3)
- "127.0.0.1:4792:4792" # Sichtungsseite (ab S5)
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
volumes:
abdulive-db:
abdulive-models:
abdulive-work:
+54
View File
@@ -0,0 +1,54 @@
# Scheibe S0 aus docs/07-mvp-plan.md: Toolchain-Image.
#
# Bewusst noch ohne `pnpm install` - es gibt noch keine Abhaengigkeiten und
# keine Lockfile. Der Entrypoint kommt mit reinem Node aus. Sobald in S1
# echter Code dazukommt, wird hier eine deps-Stufe ergaenzt.
# ---------- 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/ \
&& (cp build/src/libwhisper.so* /out/ 2>/dev/null || true) \
&& (cp build/ggml/src/libggml*.so /out/ 2>/dev/null || true)
# ---------- Laufzeit ----------
FROM node:22-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
COPY docker/ ./docker/
COPY package.json ./
# Die Datenbank und die 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
EXPOSE 4780 4790 4791 4792
ENTRYPOINT ["node", "/app/docker/entrypoint.js"]
CMD ["help"]
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env node
// Einstiegspunkt des Container-Images. Scheibe S0 aus docs/07-mvp-plan.md.
//
// Bewusst ohne npm-Abhaengigkeiten: dieses Skript muss laufen, bevor
// irgendetwas anderes installiert oder gebaut ist.
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 { parseYaml } from './lib/yaml.js';
const ENV = {
whisper: process.env.WHISPER_BIN || 'whisper-cli',
modelDir: process.env.MODEL_DIR || '/models',
dbDir: process.env.DB_DIR || '/db',
workDir: process.env.WORK_DIR || '/work',
clipsDir: process.env.CLIPS_DIR || '/app/data/clips',
replayDir: process.env.REPLAY_DIR || '/replay',
configDir: process.env.CONFIG_DIR || '/app/config',
obsHost: process.env.OBS_WS_HOST || 'host.docker.internal',
obsPort: Number(process.env.OBS_WS_PORT || 4455),
};
const MODELS = {
'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',
};
// ---------------------------------------------------------------- Ausgabe
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, label, detail = '') {
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) {
console.log(`\n${title}`);
console.log('-'.repeat(Math.max(title.length, 40)));
}
// ---------------------------------------------------------------- Helfer
function run(cmd, args, opts = {}) {
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
}
function runAsync(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024, ...opts }, (err, stdout, stderr) =>
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr })
);
});
}
async function canWrite(dir) {
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, port, timeout = 2500) {
return new Promise((resolve) => {
const sock = new net.Socket();
const done = (result) => { sock.destroy(); resolve(result); };
sock.setTimeout(timeout);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false));
sock.once('error', () => done(false));
sock.connect(port, host);
});
}
async function readLayout() {
const file = path.join(ENV.configDir, 'layout.yaml');
const text = await fs.readFile(file, 'utf8');
return { file, layout: parseYaml(text) };
}
// ---------------------------------------------------------------- check-env
async function checkEnv() {
console.log('\nabdulive-toolkit - Selbsttest der Umgebung');
section('Laufzeit');
const major = Number(process.versions.node.split('.')[0]);
report(major >= 22 ? 'ok' : 'bad', 'Node.js', `v${process.versions.node}`);
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 {
const out = run(ENV.whisper, ['--help'], { stdio: ['ignore', 'pipe', 'pipe'] });
report('ok', 'whisper-cli', out.split('\n')[0].slice(0, 40) || ENV.whisper);
} catch (err) {
// whisper-cli beendet sich bei --help teils mit Code != 0, Ausgabe zaehlt
const text = `${err.stdout || ''}${err.stderr || ''}`;
report(text.includes('usage') || text.includes('whisper') ? 'ok' : 'bad', 'whisper-cli',
text.includes('whisper') ? '' : 'nicht lauffaehig');
}
section('Sprachmodell');
let anyModel = false;
for (const name of Object.keys(MODELS)) {
try {
const st = await fs.stat(path.join(ENV.modelDir, name));
report('ok', name, `${(st.size / 1024 / 1024).toFixed(0)} MB`);
anyModel = true;
} catch { /* nicht vorhanden, ist in Ordnung */ }
}
if (!anyModel) report('warn', 'kein Modell vorhanden', 'holen mit: fetch-model ggml-small.bin');
section('Verzeichnisse');
for (const [label, dir] of [
['Datenbank (Volume)', ENV.dbDir],
['Arbeitsverzeichnis (Volume)', ENV.workDir],
['Sprachmodelle (Volume)', ENV.modelDir],
['Clips (Bind-Mount)', ENV.clipsDir],
]) {
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
}
for (const [label, dir, hard] of [
['OBS-Wiedergabepuffer', ENV.replayDir, false],
['Konfiguration', ENV.configDir, true],
]) {
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('Layout (Facecam-Ausschnitt)');
try {
const { layout } = await readLayout();
const prof = layout.profiles?.default;
if (!prof) throw new Error('Profil "default" fehlt');
const { cam, game } = prof;
const sum = cam.band.h + game.band.h;
report(sum === layout.target.height ? 'ok' : 'bad', 'Baender ergeben Zielhoehe',
`${cam.band.h} + ${game.band.h} = ${sum} (soll ${layout.target.height})`);
for (const [name, c] of [['Facecam', cam.crop], ['Gameplay', game.crop]]) {
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', cam.crop, cam.band], ['Gameplay', game.crop, game.band]]) {
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.message);
}
section('OBS');
const obsUp = await tcpProbe(ENV.obsHost, ENV.obsPort);
report(obsUp ? 'ok' : 'warn', 'obs-websocket erreichbar',
`${ENV.obsHost}:${ENV.obsPort}${obsUp ? '' : ' - OBS laeuft nicht oder WebSocket-Server ist aus'}`);
section('Zugangsdaten (.env)');
for (const key of ['TWITCH_CLIENT_ID', 'TWITCH_CLIENT_SECRET', 'OBS_WS_PASSWORD']) {
report(process.env[key] ? 'ok' : 'warn', key, process.env[key] ? 'gesetzt' : 'fehlt');
}
console.log('');
if (hardFailures > 0) {
console.log(`${C.bad}${hardFailures} harte Fehler${C.off}, ${warnings} Warnungen. Das Image ist noch nicht einsatzbereit.`);
process.exitCode = 1;
} else if (warnings > 0) {
console.log(`${C.ok}Keine harten Fehler${C.off}, ${warnings} Warnungen - die sind fuer S0 in Ordnung.`);
} else {
console.log(`${C.ok}Alles in Ordnung.${C.off}`);
}
}
// ---------------------------------------------------------------- fetch-model
async function fetchModel(name = 'ggml-small.bin') {
const url = MODELS[name];
if (!url) {
console.error(`Unbekanntes Modell "${name}". Bekannt: ${Object.keys(MODELS).join(', ')}`);
process.exitCode = 1;
return;
}
await fs.mkdir(ENV.modelDir, { recursive: true });
const target = path.join(ENV.modelDir, name);
try {
const st = await fs.stat(target);
console.log(`${name} liegt schon da (${(st.size / 1024 / 1024).toFixed(0)} MB).`);
return;
} catch { /* nicht da, also laden */ }
console.log(`Lade ${name} ...`);
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) 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`;
const out = createWriteStream(tmp);
const body = res.body.getReader ? res.body : res.body;
await pipeline(
(async function* () {
for await (const chunk of body) {
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;
}
})(),
out
);
await fs.rename(tmp, target);
console.log(`\rFertig: ${target} (${(seen / 1024 / 1024).toFixed(0)} MB)`);
}
// ---------------------------------------------------------------- calibrate
function buildFilter(prof) {
const { cam, game } = prof;
return [
`[0:v]crop=${cam.crop.w}:${cam.crop.h}:${cam.crop.x}:${cam.crop.y},scale=${cam.band.w}:${cam.band.h},setsar=1[cam]`,
`[0:v]crop=${game.crop.w}:${game.crop.h}:${game.crop.x}:${game.crop.y},scale=${game.band.w}:${game.band.h},setsar=1[game]`,
`[cam][game]vstack=inputs=2[v]`,
].join(';');
}
async function calibrate(input, profileName = 'default') {
const { layout } = await readLayout();
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(ENV.replayDir).catch(() => []))
.filter((f) => /\.(mp4|mkv|mov|flv|png|jpg)$/i.test(f))
.sort();
input = files.length ? path.join(ENV.replayDir, files[files.length - 1]) : null;
if (!input) throw new Error(`Keine Datei angegeben und nichts in ${ENV.replayDir} gefunden.`);
console.log(`Neueste Datei aus ${ENV.replayDir}: ${path.basename(input)}`);
}
const outDir = path.join(ENV.clipsDir, '_calibration');
await fs.mkdir(outDir, { recursive: true });
const isImage = /\.(png|jpe?g)$/i.test(input);
const seek = isImage ? [] : ['-ss', '5'];
// 1. Referenzbild aus der Quelle, unveraendert
const frame = path.join(outDir, 'frame.png');
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
...seek, '-i', input, '-frames:v', '1', frame]);
// 2. Vorschau der 9:16-Komposition mit den Werten aus layout.yaml
const preview = path.join(outDir, `preview-${profileName}.png`);
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
'-i', frame, '-filter_complex', buildFilter(prof), '-map', '[v]',
'-frames:v', '1', preview]);
// 3. Kontrollbild: Ausschnitte im Originalbild markiert
const marked = path.join(outDir, 'ausschnitte.png');
const box = (c, color) =>
`drawbox=x=${c.x}:y=${c.y}:w=${c.w}:h=${c.h}:color=${color}:t=4`;
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
'-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.');
}
// ---------------------------------------------------------------- help
function help() {
console.log(`
abdulive-toolkit - Container-Einstiegspunkt
check-env Umgebung pruefen (Werkzeuge, Volumes, Layout, OBS)
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
help Diese Uebersicht
Beispiele:
docker compose run --rm toolkit check-env
docker compose run --rm toolkit fetch-model ggml-small.bin
docker compose run --rm toolkit calibrate
Stand: Scheibe S0 aus docs/07-mvp-plan.md. Ingest, Clipping und Untertitel
folgen in S1 bis S4.
`);
}
// ---------------------------------------------------------------- main
const [cmd, ...args] = process.argv.slice(2);
try {
switch (cmd) {
case 'check-env': await checkEnv(); break;
case 'fetch-model': await fetchModel(args[0]); break;
case 'calibrate': await calibrate(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.message}`);
if (err.stderr) console.error(String(err.stderr).trim().split('\n').slice(-5).join('\n'));
process.exitCode = 1;
}
+118
View File
@@ -0,0 +1,118 @@
// 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 - sobald in S1
// echte Abhaengigkeiten dazukommen, wird das durch `yaml` aus npm ersetzt.
function scalar(raw) {
const s = raw.trim();
if (s === '' || s === '~' || s === 'null') return null;
if (s === 'true') return true;
if (s === 'false') return false;
if (/^-?\d+$/.test(s)) return Number.parseInt(s, 10);
if (/^-?\d*\.\d+$/.test(s)) return Number.parseFloat(s);
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
return s;
}
function flowMap(raw) {
const out = {};
for (const part of raw.slice(1, -1).split(',')) {
if (!part.trim()) continue;
const i = part.indexOf(':');
if (i === -1) continue;
out[part.slice(0, i).trim()] = scalar(part.slice(i + 1));
}
return out;
}
function stripComment(line) {
let quote = null;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quote) {
if (c === quote) quote = null;
} else if (c === '"' || c === "'") {
quote = c;
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
return line.slice(0, i);
}
}
return line;
}
export function parseYaml(text) {
const lines = text.split(/\r?\n/);
const root = {};
// Stapel aus [Einrueckung, Zielobjekt]
const stack = [[-1, root]];
let i = 0;
while (i < lines.length) {
const rawLine = lines[i];
const line = stripComment(rawLine);
if (!line.trim()) { i++; continue; }
const indent = line.length - line.trimStart().length;
const body = line.trim();
while (stack.length > 1 && indent <= stack[stack.length - 1][0]) stack.pop();
const parent = stack[stack.length - 1][1];
// Listeneintrag
if (body.startsWith('- ')) {
const key = stack[stack.length - 1][2];
if (key && Array.isArray(parent[key])) parent[key].push(scalar(body.slice(2)));
i++;
continue;
}
const colon = body.indexOf(':');
if (colon === -1) { i++; continue; }
const key = body.slice(0, colon).trim();
const rest = body.slice(colon + 1).trim();
if (rest === '') {
// Verschachtelte Map, leere Liste oder leerer Wert - anhand der
// naechsten inhaltstragenden Zeile entscheiden
let j = i + 1;
while (j < lines.length && !stripComment(lines[j]).trim()) j++;
const next = j < lines.length ? stripComment(lines[j]) : '';
const nextIndent = next ? next.length - next.trimStart().length : -1;
if (next && nextIndent > indent && next.trim().startsWith('- ')) {
parent[key] = [];
stack.push([indent, parent, key]);
} else if (next && nextIndent > indent) {
parent[key] = {};
stack.push([indent, parent[key]]);
} else {
parent[key] = null;
}
} else if (rest === '>' || rest === '|' || rest === '>-' || rest === '|-') {
// Blockskalar: alle staerker eingerueckten Zeilen einsammeln
const parts = [];
let j = i + 1;
for (; j < lines.length; j++) {
if (!lines[j].trim()) { parts.push(''); continue; }
const ind = lines[j].length - lines[j].trimStart().length;
if (ind <= indent) break;
parts.push(lines[j].trim());
}
parent[key] = rest.startsWith('>') ? parts.join(' ').trim() : parts.join('\n');
i = j;
continue;
} else if (rest.startsWith('{')) {
parent[key] = flowMap(rest);
} else if (rest.startsWith('[')) {
parent[key] = rest.slice(1, -1).split(',').map(scalar).filter((v) => v !== null);
} else {
parent[key] = scalar(rest);
}
i++;
}
return root;
}
+46
View File
@@ -0,0 +1,46 @@
# Überblick
## Ausgangslage
abdulive_ ist ein kleiner Twitch-Kanal in der Phase 5–30 gleichzeitiger
Zuschauer. In dieser Phase gelten andere Regeln als bei größeren Kanälen:
- **Twitch selbst bringt kaum Entdeckung.** Die Browse-Seite sortiert nach
Viewerzahl. Unter 30 CCV ist man dort faktisch unsichtbar. Reichweite muss
von außen kommen.
- **Jeder einzelne Zuschauer zählt überproportional.** Bei 10 Leuten ist der
Unterschied zwischen "hat zugeschaut" und "kommt wieder" der ganze Kanal.
- **Zeit ist der Engpass, nicht Ideen.** Alles, was manuell passieren muss,
passiert unregelmäßig oder gar nicht.
Daraus folgen drei Wirkrichtungen, und jedes Tool hier gehört in genau eine:
| Richtung | Frage | Tools |
|----------|-------|-------|
| **Reichweite** | Wie finden mich neue Leute? | Clip-to-Shorts, Go-Live-Broadcaster, Raid-Tool |
| **Bindung** | Warum kommen sie wieder? | Community-Gedächtnis, Chat-Steuerung, Punktesystem, Recap |
| **Steuerung** | Was funktioniert überhaupt? | Analytics-Dashboard, VOD-Kapitel |
## Leitprinzipien
1. **Ein Ingest, viele Konsumenten.** Chat und Events werden genau einmal
eingelesen. Jedes Tool liest aus derselben Datenbank bzw. demselben
Event-Bus. Kein Tool baut sich eine zweite IRC-Verbindung.
2. **Alles läuft lokal.** Ein Rechner, ein Prozessverbund, SQLite. Kein Cloud-
Deployment, keine Container-Orchestrierung. Das ist keine Sparmaßnahme,
sondern die richtige Größe.
3. **Jedes Tool ist einzeln benutzbar.** Kein Tool wartet auf ein anderes,
außer auf den Ingest-Daemon.
4. **Nichts postet ungefragt nach außen.** Alles, was den Kanal verlässt
(Social-Posts, Uploads, Raids), erzeugt standardmäßig einen Entwurf, den
Abdu freigibt. Auto-Post ist ein Schalter, kein Default.
5. **Rohdaten werden nie weggeworfen.** Jede Chat-Nachricht, jeder Viewer-Sample
landet in der DB. Auswertungen kann man nachträglich ändern, Daten nicht
nachträglich erheben.
## Was hier bewusst *nicht* gebaut wird
- Kein Multi-Tenant-System. Ein Kanal.
- Kein eigener Chatbot-Ersatz für Moderation (Nightbot/StreamElements bleiben,
wo sie sind, solange sie reichen).
- Keine Web-Auth/Userverwaltung im Dashboard — es läuft auf `localhost`.
+123
View File
@@ -0,0 +1,123 @@
# Architektur
## Bild
```
Twitch OBS
┌──────────────────┐ ┌───────────┐
│ EventSub (WS) │ │ obs- │
│ IRC / Chat │ │ websocket │
│ Helix REST │ └─────┬─────┘
└────────┬─────────┘ │
│ │
┌────────▼────────────────────────▼────────┐
│ apps/ingest (Daemon) │
│ normalisiert alles zu Events │
└────────┬─────────────────────┬───────────┘
│ │
schreibt │ │ publiziert
▼ ▼
┌─────────────────┐ ┌──────────────────────┐
│ SQLite │ │ Event-Bus │
│ data/db/ │ │ (in-proc + WS 4790) │
└────────┬────────┘ └──────────┬───────────┘
│ │
┌───────────┬───────┼───────────┬───────────┼───────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
dashboard recap raid-scout vod-chapters community overlay- clipper
server │
│ │
overlay-web ffmpeg
(Browser- whisper
Source) │
data/clips
│
broadcaster
```
## Prozesse
Der Verbund besteht aus wenigen langlaufenden Prozessen und mehreren
Kurzläufern, gestartet über `docker compose up -d`.
> **Im MVP gilt das noch nicht.** Dort laufen Ingest und Clipper als *ein*
> Prozess in *einem* Container; die Aufteilung unten ist der Zielzustand ab
> Meilenstein M3, wenn es mehr als einen Konsumenten gibt. Siehe
> [`07-mvp-plan.md`](07-mvp-plan.md).
**Langläufer (laufen immer, auch offline):**
| Prozess | Port | Aufgabe |
|---------|------|---------|
| `ingest` | 4790 (WS) | EventSub, IRC, OBS-WS, Viewer-Polling → DB + Bus |
| `overlay-server` | 4791 (HTTP+WS) | liefert Overlay-Seiten, nimmt Chat-Commands entgegen |
| `dashboard` | 4792 (HTTP) | Analytics-UI |
**Kurzläufer (event- oder zeitgetriggert):**
| Prozess | Trigger |
|---------|---------|
| `clipper` | Clip-Event vom Bus, oder manuell über CLI |
| `broadcaster` | `stream.online` |
| `recap` | `stream.offline` (+ 5 min Karenz) |
| `vod-chapters` | `stream.offline` |
| `raid-scout` | manuell / Chat-Command `!raidziel` |
`community` und `loyalty` sind keine eigenen Prozesse, sondern Module, die im
`overlay-server` mitlaufen — sie brauchen dieselbe Chat-Verbindung und
denselben WS-Kanal zum Overlay.
## Event-Bus
Der Ingest normalisiert alles auf ein einheitliches Event-Format:
```ts
type StreamEvent = {
id: string; // ULID
ts: string; // ISO-8601, UTC
type: EventType; // 'chat.message' | 'stream.online' | ...
streamId: string | null;
userId: string | null;
payload: unknown; // typisiert pro EventType
};
```
Transport: In-Prozess ein `EventEmitter`, prozessübergreifend ein
WebSocket-Broadcast auf `ws://127.0.0.1:4790`. Kein Kafka, kein Redis. Wenn ein
Konsument abstürzt, holt er sich beim Neustart die verpassten Events aus der
`events`-Tabelle über `ts > lastSeen`.
Vollständige Event-Liste: siehe [`03-data-model.md`](03-data-model.md).
## Pakete
| Paket | Inhalt |
|-------|--------|
| `packages/shared` | Typen, Event-Definitionen, Zod-Schemas |
| `packages/core` | Config-Laden, Logger, Event-Bus, Scheduler, Retry |
| `packages/twitch` | OAuth-Token-Store, Helix-Client, EventSub-WS, IRC-Client |
| `packages/db` | SQLite-Verbindung, Migrationen, Repositories |
Regel: Apps dürfen von Paketen abhängen, Pakete nie von Apps. Apps hängen nicht
voneinander ab — Kommunikation läuft über Bus und DB.
## Technologie
- **TypeScript / Node 22**, pnpm-Workspaces — siehe
[ADR-0001](decisions/ADR-0001-monorepo-typescript.md)
- **SQLite** über `better-sqlite3` — siehe [ADR-0002](decisions/ADR-0002-sqlite.md)
- **EventSub über WebSocket**, nicht Webhooks — siehe
[ADR-0003](decisions/ADR-0003-eventsub-websocket.md)
- **ffmpeg** (CLI) für Videoverarbeitung
- **whisper.cpp** (CLI) für Untertitel — kein Python, keine GPU
- **Vite + Preact** für Dashboard und Overlay-Seiten
- **Docker Compose** für den Betrieb — siehe [ADR-0004](decisions/ADR-0004-docker.md)
## Fehlerverhalten
- Jeder Netzwerkaufruf hat Retry mit exponentiellem Backoff (`packages/core/retry`).
- Der Ingest darf niemals wegen eines Konsumentenfehlers sterben. Konsumenten
laufen in eigenen Prozessen; ein Fehler dort schreibt in `logs/` und wird im
Dashboard als Warnung angezeigt.
- Wenn EventSub die Verbindung verliert: Reconnect mit Session-Resume, danach
Lücke über Helix-Polling schließen (`stream.online`-Status, Follower-Liste).
+122
View File
@@ -0,0 +1,122 @@
# Twitch-Integration
## Zwei Identitäten
Es werden zwei getrennte Twitch-Apps/Tokens gebraucht:
1. **Broadcaster-Token** (Konto `abdulive_`) — für alles, was den eigenen Kanal
betrifft: Clips erstellen, Titel ändern, Raids starten, EventSub abonnieren,
Analytics lesen.
2. **Bot-Token** (eigenes Bot-Konto, z. B. `abdulive_bot`) — für Chat-Nachrichten.
Trennung, damit Bot-Nachrichten als Bot erkennbar sind und der Broadcaster-
Token nicht im Chat-Client hängt.
Beide Tokens liegen verschlüsselt in `data/db/tokens.json`, werden über Refresh
Tokens automatisch erneuert (`packages/twitch/tokenStore.ts`). Client-Secret
kommt aus `.env`, niemals aus der DB, niemals ins Repo.
## Scopes
| Scope | Wofür | Token |
|-------|-------|-------|
| `chat:read` | IRC lesen | Bot |
| `chat:edit` | IRC schreiben | Bot |
| `user:write:chat` | Chat über Helix senden (Alternative zu IRC) | Bot |
| `moderator:read:chatters` | Zuschauerliste im Chat | Broadcaster |
| `clips:edit` | Clips erstellen | Broadcaster |
| `channel:manage:broadcast` | Titel/Kategorie ändern, Marker setzen | Broadcaster |
| `channel:read:subscriptions` | Sub-Events | Broadcaster |
| `moderator:read:followers` | Follow-Events (EventSub) | Broadcaster |
| `channel:manage:raids` | Raid starten/abbrechen | Broadcaster |
| `channel:read:redemptions` | Channel-Point-Einlösungen | Broadcaster |
| `channel:manage:redemptions` | eigene Punkte-Rewards verwalten | Broadcaster |
| `channel:manage:polls` | Umfragen | Broadcaster |
| `analytics:read:games` | Game-Analytics-Report | Broadcaster |
| `bits:read` | Bits-Events | Broadcaster |
Der Autorisierungs-Flow läuft einmalig über `docker compose run --rm --service-ports toolkit auth` → lokaler Redirect auf
`http://localhost:4780/callback`.
## EventSub (WebSocket)
Abonniert werden beim Start:
| Subscription | Version | Nutzen für |
|---|---|---|
| `stream.online` | 1 | Broadcaster, Analytics, Recap |
| `stream.offline` | 1 | Recap, VOD-Kapitel |
| `channel.update` | 2 | VOD-Kapitel (Kategorie/Titel), Analytics |
| `channel.follow` | 2 | Community, Recap |
| `channel.subscribe` / `.message` / `.gift` | 1 | Recap, Overlay |
| `channel.cheer` | 1 | Recap, Overlay |
| `channel.raid` (to + from) | 1 | Raid-Tool |
| `channel.channel_points_custom_reward_redemption.add` | 1 | Loyalty, Overlay |
| `channel.poll.begin/progress/end` | 1 | Overlay |
| `channel.chat.message` | 1 | **optional**, siehe unten |
| `channel.chat.notification` | 1 | Community (Erstzuschauer-Marker) |
**Chat: IRC oder EventSub?** Beides funktioniert. Wir lesen den Chat über
**IRC (`irc-ws.chat.twitch.tv:443`)**, weil die Tags dort (`first-msg`,
`returning-chatter`, `emotes`, `badges`, `msg-id`) reichhaltiger und stabiler
sind als im EventSub-Payload, und weil Rate Limits beim Senden dort klar
dokumentiert sind. EventSub übernimmt alles Nicht-Chat.
**Verbindungsregeln:** Eine WS-Session hält max. 300 Subscriptions. Twitch
sendet alle ~10 s ein Keepalive; bleibt es aus, neu verbinden.
`session_reconnect` liefert eine neue URL — dort verbinden, *dann* erst die
alte Verbindung schließen. Nach jedem Reconnect: Lücke per Helix-Polling füllen.
## Helix — die relevanten Endpunkte
| Endpunkt | Tool |
|---|---|
| `GET /helix/streams?user_id=` | Ingest (Viewer-Sampling, alle 60 s) |
| `GET /helix/streams?game_id=&first=100` | Raid-Scout |
| `GET /helix/channels` / `PATCH /helix/channels` | Loyalty (Titel-Shop), VOD-Kapitel |
| `POST /helix/clips` | Clipper |
| `GET /helix/clips?id=` | Clipper (Metadaten + Thumbnail-URL) |
| `GET /helix/videos?user_id=&type=archive` | VOD-Kapitel |
| `POST /helix/streams/markers` | VOD-Kapitel, Clipper |
| `GET /helix/chat/chatters` | Community (Anwesenheit, alle 5 min) |
| `GET /helix/channels/followers` | Recap, Community |
| `POST /helix/raids` | Raid-Tool |
| `GET /helix/users?login=` | überall (Login → ID) |
**Rate Limits:** 800 Punkte/Minute pro Client-ID (Standard: 1 Punkt pro Request).
Der Helix-Client in `packages/twitch` führt einen Token-Bucket und drosselt
selbst; `Ratelimit-Remaining` aus dem Response-Header wird ausgewertet.
Bei `429` → Backoff nach `Ratelimit-Reset`.
## Clips: die Fallstricke
Das ist der Teil, an dem die Clip-Pipeline scheitert, wenn man ihn nicht kennt:
1. `POST /helix/clips` schneidet **rückwirkend ~30 s** ab dem Aufrufzeitpunkt
(minus Stream-Delay). Man kann keinen Zeitbereich wählen.
2. Der Clip ist **nicht sofort verfügbar**. Nach dem POST bekommt man `id` und
`edit_url`; die Verarbeitung dauert typischerweise 5–20 s. Danach liefert
`GET /helix/clips?id=` erst Metadaten.
3. Die MP4-URL steht nirgends in der API. Sie wird aus `thumbnail_url`
abgeleitet: alles ab `-preview-` abschneiden, `.mp4` anhängen.
```
https://clips-media-assets2.twitch.tv/AT-cm%7C123-preview-480x272.jpg
→ https://clips-media-assets2.twitch.tv/AT-cm%7C123.mp4
```
Das ist undokumentiert und kann sich ändern — deshalb im Code an genau einer
Stelle (`packages/twitch/clips.ts`) gekapselt und mit Fallback versehen.
4. Der Clip enthält das **fertig komposite Bild** (1920×1080), nicht die
Einzelquellen. Facecam und Gameplay lassen sich nur per Crop trennen, und nur
wenn das OBS-Layout fest ist.
5. Bitrate/Qualität entspricht dem Transcode, nicht dem Original.
**Deshalb: OBS-Replay-Buffer ist der bessere Primärweg.** Details in
[`docs/projects/clip-shorts.md`](projects/clip-shorts.md).
## OBS-WebSocket
obs-websocket v5 auf `ws://127.0.0.1:4455`. Genutzt für:
- `CurrentProgramSceneChanged` → VOD-Kapitel
- `SaveReplayBuffer` → Clipper
- `TriggerHotkeyByName`, `SetInputSettings` → Chat-Steuerung, Loyalty-Shop
- `GetSourceScreenshot` → Go-Live-Broadcaster (Screenshot für den Post)
+239
View File
@@ -0,0 +1,239 @@
# Datenmodell
SQLite, Datei `data/db/abdulive.db`. WAL-Modus, damit mehrere Prozesse
gleichzeitig lesen können, während der Ingest schreibt. Migrationen als
nummerierte SQL-Dateien in `packages/db/migrations/`.
## Kerntabellen
### `events` — der Rohstrom
Append-only. Alles landet hier zuerst. Alle Auswertungen sind ableitbar.
```sql
CREATE TABLE events (
id TEXT PRIMARY KEY, -- ULID
ts TEXT NOT NULL, -- ISO-8601 UTC
type TEXT NOT NULL,
stream_id TEXT, -- FK streams.id, NULL wenn offline
user_id TEXT, -- Twitch user id
payload TEXT NOT NULL -- JSON
);
CREATE INDEX idx_events_ts ON events(ts);
CREATE INDEX idx_events_type ON events(type, ts);
CREATE INDEX idx_events_strm ON events(stream_id, ts);
```
### `streams` — eine Zeile pro Sendung
```sql
CREATE TABLE streams (
id TEXT PRIMARY KEY, -- Twitch stream id
started_at TEXT NOT NULL,
ended_at TEXT,
vod_id TEXT,
peak_viewers INTEGER DEFAULT 0,
avg_viewers REAL,
unique_chatters INTEGER DEFAULT 0,
new_followers INTEGER DEFAULT 0
);
```
### `stream_segments` — Titel/Kategorie über die Zeit
Basis für "welcher Titel hat was gebracht" und für VOD-Kapitel.
```sql
CREATE TABLE stream_segments (
id INTEGER PRIMARY KEY,
stream_id TEXT NOT NULL REFERENCES streams(id),
started_at TEXT NOT NULL,
ended_at TEXT,
title TEXT,
category_id TEXT,
category TEXT,
obs_scene TEXT
);
```
### `viewer_samples` — Viewerzahl im Zeitverlauf
Alle 60 s ein Sample aus `GET /helix/streams`.
```sql
CREATE TABLE viewer_samples (
stream_id TEXT NOT NULL REFERENCES streams(id),
ts TEXT NOT NULL,
viewers INTEGER NOT NULL,
PRIMARY KEY (stream_id, ts)
);
```
### `chat_messages` — jede Nachricht
```sql
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY, -- msg-id aus IRC
stream_id TEXT REFERENCES streams(id),
ts TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id),
text TEXT NOT NULL,
emotes TEXT, -- JSON: [{id,name,count}]
is_first INTEGER DEFAULT 0, -- first-msg Tag
is_command INTEGER DEFAULT 0,
bits INTEGER DEFAULT 0
);
CREATE INDEX idx_chat_stream_ts ON chat_messages(stream_id, ts);
CREATE INDEX idx_chat_user ON chat_messages(user_id, ts);
```
### `users` — das Community-Gedächtnis
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY, -- Twitch user id (stabil, nicht der Login!)
login TEXT NOT NULL,
display_name TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
streams_present INTEGER DEFAULT 0, -- an wie vielen Streams anwesend
streams_chatted INTEGER DEFAULT 0, -- an wie vielen geschrieben
message_count INTEGER DEFAULT 0,
is_follower INTEGER DEFAULT 0,
followed_at TEXT,
is_sub INTEGER DEFAULT 0,
sub_tier TEXT,
points INTEGER DEFAULT 0,
watch_minutes INTEGER DEFAULT 0,
greeting TEXT, -- individuelle Begrüßung
notes TEXT, -- freie Notizen des Streamers
pronouns TEXT,
muted_greeting INTEGER DEFAULT 0
);
```
> `login` ändert sich bei Namensänderung, `id` nie. Überall wird über `id`
> verknüpft, `login`/`display_name` bei jedem Kontakt aktualisiert.
### `user_streams` — Anwesenheit pro Stream
Grundlage für "Nummer 15 für dich" und für neue-vs.-wiederkehrende Chatter.
```sql
CREATE TABLE user_streams (
user_id TEXT NOT NULL REFERENCES users(id),
stream_id TEXT NOT NULL REFERENCES streams(id),
first_ts TEXT NOT NULL,
last_ts TEXT NOT NULL,
messages INTEGER DEFAULT 0,
minutes INTEGER DEFAULT 0,
PRIMARY KEY (user_id, stream_id)
);
```
## Tool-Tabellen
### `clips`
```sql
CREATE TABLE clips (
id TEXT PRIMARY KEY, -- interne ULID
twitch_clip_id TEXT,
stream_id TEXT REFERENCES streams(id),
created_at TEXT NOT NULL,
trigger TEXT NOT NULL, -- 'spike'|'command'|'manual'|'hotkey'
trigger_score REAL,
source TEXT NOT NULL, -- 'twitch'|'obs-replay'
raw_path TEXT,
vertical_path TEXT,
subtitle_path TEXT,
duration_s REAL,
title TEXT,
transcript TEXT,
status TEXT NOT NULL, -- 'pending'|'rendered'|'approved'|'uploaded'|'rejected'
score REAL, -- Ranking für Upload-Kandidaten
uploaded_to TEXT -- JSON: {"tiktok": "...", "yt": "..."}
);
```
### `raids`
```sql
CREATE TABLE raids (
id INTEGER PRIMARY KEY,
direction TEXT NOT NULL, -- 'in'|'out'
ts TEXT NOT NULL,
channel_id TEXT NOT NULL,
channel_login TEXT NOT NULL,
viewers INTEGER,
category TEXT,
reciprocated INTEGER DEFAULT 0, -- hat der zurückgeraidet?
notes TEXT
);
```
### `raid_candidates` — Scout-Ergebnisse
```sql
CREATE TABLE raid_candidates (
id INTEGER PRIMARY KEY,
scanned_at TEXT NOT NULL,
channel_id TEXT NOT NULL,
channel_login TEXT NOT NULL,
viewers INTEGER NOT NULL,
category TEXT,
language TEXT,
title TEXT,
uptime_min INTEGER,
score REAL,
history_raids INTEGER DEFAULT 0 -- schon mal Kontakt gehabt?
);
```
### `shop_items` / `redemptions`
```sql
CREATE TABLE shop_items (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
cost INTEGER NOT NULL,
action TEXT NOT NULL, -- 'set_title'|'obs_hotkey'|'sound'|'emote_rain'|'challenge'
params TEXT, -- JSON
cooldown_s INTEGER DEFAULT 0,
per_stream_limit INTEGER,
enabled INTEGER DEFAULT 1,
requires_approval INTEGER DEFAULT 0
);
CREATE TABLE redemptions (
id TEXT PRIMARY KEY,
ts TEXT NOT NULL,
stream_id TEXT REFERENCES streams(id),
user_id TEXT NOT NULL REFERENCES users(id),
item_id TEXT NOT NULL REFERENCES shop_items(id),
cost INTEGER NOT NULL,
status TEXT NOT NULL, -- 'pending'|'executed'|'refunded'
result TEXT
);
```
### `posts` — ausgehende Social-Posts
```sql
CREATE TABLE posts (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
kind TEXT NOT NULL, -- 'golive'|'recap'|'clip'
platform TEXT NOT NULL, -- 'discord'|'bluesky'|'instagram'
status TEXT NOT NULL, -- 'draft'|'sent'|'failed'
body TEXT,
media_path TEXT,
remote_url TEXT,
error TEXT
);
```
## Event-Typen
```
stream.online stream.offline stream.segment_changed
chat.message chat.first_message chat.cleared
chat.spike ← vom Ingest berechnet
user.arrived user.returning
follow subscribe subgift cheer
raid.in raid.out
points.redeemed shop.redeemed
poll.begin poll.end
obs.scene_changed obs.replay_saved
clip.requested clip.created clip.rendered
```
## Aufbewahrung
Nichts wird gelöscht. Die DB wächst bei ~10 Zuschauern um wenige MB pro Monat.
Nächtliches Backup nach `data/db/backup/` per `scripts/backup.ps1`
(SQLite `VACUUM INTO`, 30 Generationen).
+61
View File
@@ -0,0 +1,61 @@
# Roadmap
Begründung der Reihenfolge: [`06-hebel-analyse.md`](06-hebel-analyse.md).
## M1+M2 — MVP „Clip-Knopf" (Woche 1–3)
Zusammengefasst und detailliert ausgeplant in
**[`07-mvp-plan.md`](07-mvp-plan.md)** — sieben Scheiben von der Container-
Toolchain bis zur Sichtungsseite.
Kurz: Container mit ffmpeg und whisper.cpp, Twitch-Auth, minimale DB, IRC-Ingest,
OBS-Replay-Trigger über `!clip` und Hotkey, 9:16-Render, eingebrannte
Untertitel, statische Sichtungsseite. EventSub, Event-Bus und Dashboard-
Framework sind bewusst nicht dabei.
**Fertig, wenn:** nach einem Stream fünf fertige Shorts zur Auswahl bereitliegen
und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
## M3 — Community und Ankündigung (Woche 4)
- [ ] `apps/overlay-server` mit WS-Kanal und Reconnect-Client
- [ ] Community-Modul: Begrüßungslogik, Cooldowns, `!notiz`
- [ ] Overlay-Seite `/overlay/community` (Chatter-Hinweis für Abdu)
- [ ] `apps/broadcaster`: Discord + Bluesky, Story-Bild-Generator
- [ ] Draft-Modus, Kill-Switch, Doppelpost-Schutz
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
automatisch angekündigt.
## M4 — Auswertung (Woche 5–6)
- [ ] `apps/dashboard`: Stream-Zeitachse, Abgangs-Analyse
- [ ] Titel-/Kategorie-Performance mit Stichprobenhinweis
- [ ] Kohortenansicht neu vs. wiederkehrend
- [ ] Betriebsstatus-Seite
- [ ] Spike-Detektor an echten Daten kalibrieren und scharf schalten
## M5 — Netzwerk (Woche 7)
- [ ] `apps/raid-scout`: Kandidatensuche, Scoring, `!raidziel`
- [ ] Raid-Historie und Erwiderungs-Erinnerung
- [ ] Overlay-Panel für das Streamende
## M6 — Recap (Woche 8)
- [ ] `apps/recap`: Discord-Post nach Streamende
- [ ] Clip-Einbettung, Erstchatter-Begrüßung, Wochenvergleich
## Später
- [ ] Chat-Steuerung: Soundboard, Effekte, WLED, Umfragen mit Konsequenz
- [ ] Punktesystem und Shop (sinnvoll ab ~30 CCV)
- [ ] VOD-Kapitel (sinnvoll ab YouTube-Zweitverwertung)
- [ ] Automatischer Upload der Shorts (TikTok/YouTube-API)
- [ ] Minispiele
## Nicht geplant
- Cloud-Deployment
- Mehrkanal-Unterstützung
- Ersatz für Moderationsbots
+108
View File
@@ -0,0 +1,108 @@
# 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).
## Voraussetzungen
| Was | Version | Wo |
|---|---|---|
| Docker Desktop | aktuell, WSL2-Backend | Host |
| OBS Studio | 30+ mit obs-websocket v5 | Host |
| Git | beliebig | Host |
Alles Weitere (Node 22, pnpm, ffmpeg mit libx264/libass, whisper.cpp,
Sprachmodell) liefert das Image.
## WSL2 begrenzen
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
```
Danach `wsl --shutdown` und Docker Desktop neu starten.
## Twitch-Apps anlegen
Zwei Anwendungen unter https://dev.twitch.tv/console/apps:
1. `abdulive-toolkit` — OAuth Redirect `http://localhost:4780/callback`
2. `abdulive-bot` — gleicher Redirect
Client-ID und Client-Secret beider Apps in `.env` eintragen
(Vorlage: `.env.example`).
## OBS vorbereiten
1. **obs-websocket aktivieren:** Werkzeuge → WebSocket-Servereinstellungen →
Server aktivieren, Port 4455, Passwort setzen und in `.env` eintragen.
2. **Wiedergabepuffer aktivieren:** Einstellungen → Ausgabe → Wiedergabepuffer,
90 Sekunden. Den Zielordner merken und in `.env` als `OBS_REPLAY_DIR`
eintragen — mit Forward-Slashes, z. B. `C:/Users/abdu/Videos/replay`.
3. **Hotkey setzen:** Einstellungen → Hotkeys → „Wiedergabepuffer speichern".
Das ist der Clip-Knopf. Ein globaler Windows-Hotkey wird bewusst *nicht*
gebaut — aus einem Container ist er nicht abfangbar, und OBS meldet den
gespeicherten Puffer ohnehin über WebSocket.
4. **Browser-Sources** (erst ab Meilenstein M3 relevant): „Browser-Quelle beim
Szenenwechsel neu laden" **aus**, sonst brechen laufende Effekte ab.
## Erste Einrichtung
```powershell
Copy-Item .env.example .env # Secrets und OBS_REPLAY_DIR eintragen
Copy-Item config\config.example.yaml config\config.yaml
docker compose build
docker compose run --rm toolkit check-env # prueft ffmpeg, whisper, Modell, Volumes
docker compose run --rm --service-ports toolkit auth # Browser oeffnet sich, zweimal bestaetigen
docker compose run --rm toolkit db:migrate
```
Der `auth`-Lauf braucht `--service-ports`, damit Port 4780 für den
OAuth-Rücksprung erreichbar ist.
## Facecam einmessen
Einmalig, sobald eine Replay-Datei vorliegt:
```powershell
docker compose run --rm toolkit calibrate
```
Legt einen Frame als PNG in `data/clips/_calibration/` ab, fragt die vier
Crop-Werte ab und schreibt sie nach `config/layout.yaml`.
## Betrieb
```powershell
docker compose up -d # startet
docker compose logs -f # Logs
docker compose down # stoppt
```
Sichtungsseite: http://localhost:4792
## 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.
## Was nicht ins Repo gehört
`.env`, `config/config.yaml`, alles unter `data/`. Steht in `.gitignore`.
Die Datenbank enthält personenbezogene Daten (Chatverläufe, Notizen zu
Zuschauern) und darf nicht committet oder geteilt werden.
+103
View File
@@ -0,0 +1,103 @@
# Hebel-Analyse: womit anfangen
## Bewertung aller neun Ideen
Bewertet nach vier Achsen. **Wirkung** = erwarteter Effekt auf Kanalwachstum in
den nächsten 3 Monaten. **Aufwand** = Entwicklungszeit bis zu einer Version, die
im echten Stream läuft. **Risiko** = Wahrscheinlichkeit, dass es technisch oder
sozial nicht funktioniert. **Abhängig von** = was vorher stehen muss.
| Tool | Wirkung | Aufwand | Risiko | Abhängig von | Hebel |
|---|---|---|---|---|---|
| **Ingest-Daemon** | — (indirekt) | 2–3 Tage | niedrig | — | **Voraussetzung** |
| **Clip-to-Shorts** | sehr hoch | 8–12 Tage | mittel | Ingest, OBS | **★★★★★** |
| **Community-Gedächtnis** | hoch | 2–3 Tage | niedrig | Ingest | **★★★★★** |
| **Go-Live-Broadcaster** | mittel | 1–2 Tage | niedrig | Ingest | ★★★★☆ |
| **Analytics-Dashboard** | mittel (indirekt) | 4–6 Tage | niedrig | Ingest | ★★★☆☆ |
| **Raid-/Netzwerk-Tool** | mittel–hoch | 3–4 Tage | niedrig | Ingest | ★★★☆☆ |
| **Stream-Recap** | mittel | 2 Tage | niedrig | Ingest, Analytics | ★★★☆☆ |
| **Chat steuert Dinge** | mittel | 5–8 Tage | mittel | Overlay-Server | ★★☆☆☆ |
| **Punktesystem + Shop** | niedrig–mittel | 4–6 Tage | mittel | Overlay, Loyalty | ★★☆☆☆ |
| **VOD-Kapitel** | niedrig | 2 Tage | niedrig | Ingest | ★☆☆☆☆ |
## Die Antwort
**Der größte Hebel ist die Clip-to-Shorts-Pipeline.** Deine Einschätzung stimmt:
Bei 5–30 Zuschauern ist Twitchs eigene Entdeckung praktisch tot, weil die
Browse-Seite nach Viewerzahl sortiert. Off-Platform-Kurzvideo ist der einzige
Kanal, in dem ein kleiner Account nichtlineare Reichweite bekommen kann — ein
Short kann 50.000 Leute erreichen, während der Stream 11 hat. Kein anderes Tool
in dieser Liste hat diese Größenordnung.
**Aber du kannst nicht mit ihr anfangen.** Sie braucht einen Trigger, und der
Trigger braucht eine Chat-Verbindung, Stream-Session-Erkennung und einen
Event-Bus. Das ist der Ingest-Daemon — 2–3 Tage, die ohnehin zur Clip-Pipeline
gehören.
Und genau das macht die Reihenfolge einfach: **Wenn der Ingest steht, kostet das
Community-Gedächtnis noch zwei Tage extra**, weil es fast nur Auswertung
vorhandener Daten ist. Damit hast du in einem Monat beide Seiten abgedeckt:
- **Clips bringen Leute rein** (Reichweite)
- **Community-Gedächtnis sorgt dafür, dass sie bleiben** (Bindung)
Das eine ohne das andere verpufft. Ein viraler Clip, der 300 Leute in einen
Stream spült, in dem niemand ihre Namen kennt, bringt am nächsten Tag null
Rückkehrer. Umgekehrt bindet die beste Community-Pflege niemanden, der nie
ankommt.
## Zwei Dinge, die du an der Clip-Pipeline anders bauen solltest, als es
## zunächst naheliegt
**1. Nicht auf Chat-Spikes triggern — jedenfalls nicht zuerst.**
Bei 10 Zuschauern ist die Nachrichtenrate so niedrig, dass "Spike" statistisch
kaum von Rauschen zu trennen ist. Drei Leute, die gleichzeitig "lol" schreiben,
sind bereits eine Verdreifachung. Der Detektor läuft von Anfang an mit und
schreibt jeden Kandidaten mit Score in die DB — aber **scharf geschaltet wird er
erst, wenn du an echtem Material siehst, wo die Schwelle liegen muss.** Als
Trigger reichen anfangs `!clip` im Chat und ein Hotkey für dich. Der Hotkey ist
ohnehin das beste Signal, das es gibt: Du weißt am besten, wann gerade etwas
Gutes passiert ist.
**2. Nicht die Twitch-Clip-API als Videoquelle nehmen, sondern den
OBS-Replay-Buffer.** Der Twitch-Clip ist ein Transcode des fertig komponierten
1920×1080-Bildes — Facecam und Gameplay musst du per Crop wieder auseinander
schneiden, und die Qualität ist schon einmal durch einen Encoder gelaufen. Der
Replay-Buffer liefert dir dieselben 30–90 Sekunden in Aufnahmequalität, sofort,
ohne API-Wartezeit. Und wenn du in OBS eine zweite Aufnahmespur oder eine
Vertical-Szene einrichtest, sind Cam und Gameplay dort bereits getrennt — dann
entfällt das ganze Crop-Problem. Den Twitch-Clip erzeugst du trotzdem
gleichzeitig, weil er eine teilbare URL hat.
Beides steht ausführlich in [`projects/clip-shorts.md`](projects/clip-shorts.md).
## Was ich in dieser Reihenfolge nach hinten schieben würde
**Punktesystem und Chat-Minispiele.** Beides ist Spaß und beides macht viel
Arbeit, aber es wirkt nur auf Leute, die schon da sind — und bei 10 Zuschauern
sind das zu wenige, als dass ein Shop-Ökosystem trägt. Punkte werden erst
interessant, wenn genug Leute regelmäßig da sind, dass sie überhaupt genug
sammeln können. Das ist ein Werkzeug für 30–50 CCV, nicht für 10.
**VOD-Kapitel.** Nettes Detail, aber die VODs eines kleinen Kanals werden kaum
geschaut. Der Aufwand ist klein, deshalb kommt es trotzdem irgendwann — aber
nicht in den ersten zwei Monaten. Sinnvoll wird es in dem Moment, in dem du
anfängst, VODs auf YouTube zweitzuverwerten.
## Konkreter Vorschlag für die ersten vier Wochen
| Woche | Was | Ergebnis |
|---|---|---|
| **1** | Ingest-Daemon + DB-Schema + Auth | Jede Nachricht, jeder Viewer-Sample landet in der DB. Ab hier sammelst du Daten, auch wenn noch nichts damit passiert. |
| **2** | Clip-Pipeline Teil 1: `!clip` + Hotkey → OBS-Replay → `raw.mp4` | Auf Knopfdruck liegt ein Clip auf der Platte. |
| **3** | Clip-Pipeline Teil 2: 9:16-Crop + Whisper-Untertitel + Freigabe-Ansicht | Fertige Upload-Kandidaten nach jedem Stream. |
| **4** | Community-Gedächtnis + Go-Live-Broadcaster | Erstchatter werden erkannt, du siehst im Overlay, wer reinkommt, und der Stream-Start wird automatisch angekündigt. |
Danach, nach Bedarf: Analytics-Dashboard (Woche 5–6, wertet dann bereits vier
Wochen echte Daten aus — deshalb lohnt es sich, den Ingest jetzt zu bauen und
das Dashboard später), dann Raid-Tool, dann Recap.
**Der wichtigste Satz in diesem Dokument:** Der Ingest-Daemon in Woche 1 sammelt
Daten, die du später nicht rückwirkend erheben kannst. Selbst wenn danach zwei
Monate nichts passiert, ist er die beste Investition, weil jedes spätere Tool
dann sofort auf einem Berg echter Daten aufsetzt statt bei null anzufangen.
+207
View File
@@ -0,0 +1,207 @@
# MVP-Paket: „Clip-Knopf"
**Ziel in einem Satz:** Nach jedem Stream liegen fertige 9:16-Videos mit
eingebrannten Untertiteln zur Auswahl bereit — Abdu klickt zwei davon an und
lädt sie hoch.
Begründung der Auswahl: [`06-hebel-analyse.md`](06-hebel-analyse.md).
Detailspezifikation: [`projects/clip-shorts.md`](projects/clip-shorts.md).
## Warum genau dieser Zuschnitt
Der spürbare Effekt entsteht nicht dadurch, dass ein Clip *entsteht* — das kann
Twitch selbst. Er entsteht dadurch, dass die Strecke von „das war lustig" bis
„fertiges Short liegt bereit" von 30 Minuten Handarbeit auf 0 Minuten fällt.
Genau diese Strecke ist der MVP. Alles, was nicht auf dieser Strecke liegt,
fliegt raus.
Das MVP ist bewusst **ein einziger Prozess in einem einzigen Container**. Die in
[`01-architecture.md`](01-architecture.md) beschriebene Aufteilung in Ingest,
Overlay-Server und Konsumenten kommt erst, wenn es einen zweiten Konsumenten
gibt. Vorher ist sie nur Ballast.
## Rahmenbedingungen (geklärt)
| Punkt | Stand |
|---|---|
| Zielmaschine | Abdus Stream-PC, dort wo OBS läuft — alles lokal |
| OBS-Layout | Facecam bündig oben links, 440×330 — **eingemessen**, siehe unten |
| GPU | keine NVIDIA-Karte → CPU-Encoding mit libx264 |
| Untertitel | whisper.cpp (Binary, kein Python) statt faster-whisper |
| Betrieb | Docker Compose, siehe [ADR-0004](decisions/ADR-0004-docker.md) |
## Fortschritt
| Scheibe | Stand |
|---|---|
| S0 Container + Toolchain | in Arbeit |
| Layout eingemessen | **fertig** — `config/layout.yaml`, vorgezogen aus S3 |
| S1–S6 | offen |
Das Einmessen der Facecam war eigentlich Teil von S3, ließ sich aber aus dem
gelieferten Screenshot sofort erledigen. Dabei kamen zwei Dinge heraus, die das
geplante Layout verändert haben — **der Chat ist ins Streambild eingebrannt**
und **die Facecam ist 4:3, nicht 16:9**. Beides ist in
[`projects/clip-shorts.md`](projects/clip-shorts.md) dokumentiert und in
`config/layout.yaml` aufgelöst.
## Umfang
### Drin
- Trigger: `!clip` im Chat **und** OBS-Hotkey (über `ReplayBufferSaved`)
- Quelle: OBS-Replay-Buffer, parallel dazu ein Twitch-Clip für die teilbare URL
- Render: 1080×1920, Facecam oben, Gameplay unten, fester Crop aus Config
- Untertitel: whisper.cpp deutsch → SRT → ASS mit wortweisem Highlight → eingebrannt
- Ablage: `data/clips/<datum>/<clip-id>/` mit `final.mp4`, Transkript, Metadaten
- Sichtung: eine statische HTML-Seite mit Vorschau, Score und zwei Knöpfen
- Nebenbei: Chat, Zuschauerzahlen und Nutzer werden ab Tag 1 in die DB
geschrieben, auch wenn im MVP noch nichts damit passiert
### Bewusst draußen
| Nicht im MVP | Warum |
|---|---|
| EventSub | Für den Clip-Weg reicht IRC plus Helix-Polling. Spart einen ganzen Verbindungstyp mit Reconnect-Logik. |
| Event-Bus über WebSocket | Es gibt nur einen Prozess. Funktionsaufrufe genügen. |
| Dashboard mit Framework | Eine statische HTML-Seite reicht für zehn Videos. |
| Spike-Trigger scharf | Läuft mit und protokolliert, löst aber nichts aus — Schwelle wird erst an echten Daten kalibriert. |
| Automatischer Upload | Die Auswahl ist ohnehin manuell. Kein Gewinn, viel API-Aufwand. |
| Overlay, Punkte, Recap, Raid | Eigene Meilensteine. |
## Die sieben Scheiben
Jede Scheibe ist einzeln lauffähig und einzeln überprüfbar. Am Ende jeder
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
in ein eigenes Volume, `scripts/check-env` als Selbsttest im Container.
**Fertig, wenn:** `docker compose run --rm toolkit check-env` meldet ffmpeg,
whisper.cpp, Modell und die Schreibrechte auf allen Volumes als OK.
### S1 — Auth und Datenaufnahme · ~2 Tage
OAuth-Flow (`auth`-Kommando, Callback auf Port 4780), verschlüsselter
Token-Store mit automatischem Refresh, Helix-Client mit Rate-Limit-Bucket,
DB-Migrationen für `streams`, `users`, `user_streams`, `chat_messages`,
`viewer_samples`, `clips`. IRC-Client, der jede Nachricht schreibt.
Stream-Sessions über Helix-Polling im Minutentakt.
**Fertig, wenn:** Nach einem echten Stream stehen alle Nachrichten in
`chat_messages` und eine korrekte Zeile in `streams`.
> Diese Scheibe ist der Teil ohne sichtbaren Effekt — und trotzdem der, den man
> nicht verschieben darf. Was hier nicht mitgeschrieben wird, ist für immer weg.
### S2 — OBS-Anbindung und Trigger · ~1 Tag
obs-websocket v5 über `host.docker.internal:4455`, Reconnect-Schleife.
`SaveReplayBuffer` auslösen, auf `ReplayBufferSaved` warten, Dateipfad vom
Windows-Pfad auf den Container-Mount abbilden. Trigger: `!clip` im Chat
(Cooldown 60 s global, 300 s pro Nutzer, nur Follower) und der OBS-Hotkey.
Parallel `POST /helix/clips`, Ergebnis-ID in `clips` nachtragen.
Bestätigung im Chat, genau eine pro Auslösung.
**Fertig, wenn:** `!clip` im laufenden Stream erzeugt binnen 5 s eine
`raw.mp4` im Ablageordner und eine Chat-Rückmeldung.
### S3 — 9:16-Render · ~1,5 Tage
`calibrate`-Kommando: zieht einen Frame aus einer Replay-Datei, schreibt ihn
als PNG raus, nimmt die vier Crop-Werte entgegen und legt sie in
`config/layout.yaml` ab. Render-Schritt mit dem Filtergraph aus
[`projects/clip-shorts.md`](projects/clip-shorts.md), Kopie der Rohdatei ins
Arbeitsvolume vor der Verarbeitung.
**Fertig, wenn:** Aus einer Replay-Datei entsteht eine `vertical.mp4` mit
korrekt sitzender Facecam oben und Gameplay unten.
### S4 — Untertitel · ~1,5 Tage
Audiospur extrahieren (16 kHz Mono WAV), whisper.cpp mit `ggml-small` und
`--language de`, Ausgabe als SRT mit Wort-Zeitstempeln, Umwandlung in ASS mit
wortweisem Highlight, Stil aus `config/subtitle-style.ass`, Einbrennen.
Sicherheitsabstand nach unten mindestens 320 px.
**Fertig, wenn:** `final.mp4` hat lesbare, zeitlich passende deutsche
Untertitel, und der Text wird auf dem Handy von der TikTok-Oberfläche nicht
verdeckt.
### S5 — Sichtung · ~1 Tag
Ein kleiner HTTP-Server auf Port 4792 liefert eine Seite mit allen Kandidaten
des letzten Streams: Videovorschau, Score, Auslöser, der Chat-Ausschnitt aus
dem Clip-Fenster, Transkript, und die Knöpfe *Übernehmen* / *Verwerfen*.
Übernommene Clips wandern nach `data/clips/_upload/`.
**Fertig, wenn:** Nach einem Stream lässt sich in unter zwei Minuten
entscheiden, welche Clips hochgeladen werden.
### S6 — Erstchatter-Begrüßung · ~0,5 Tage · optional
Der IRC-Client aus S1 liegt ohnehin da, das `first-msg`-Tag ebenso. Eine
Begrüßung für Erstchatter, eine für Rückkehrer nach mehr als 21 Tagen, mit
Cooldown und Obergrenze pro Stream. Details in
[`projects/community-memory.md`](projects/community-memory.md).
Diese Scheibe steht am Ende, weil sie unabhängig ist und notfalls entfällt.
Sie kostet fast nichts und ist das Einzige im MVP, das die Zuschauer direkt
merken — deshalb ist sie trotzdem drin.
## Zeitplan
| Woche | Scheiben | Ergebnis |
|---|---|---|
| 1 | S0, S1 | Läuft im Container, schreibt jeden Stream mit |
| 2 | S2, S3 | Knopfdruck erzeugt ein fertiges 9:16-Video |
| 3 | S4, S5, S6 | Untertitel, Sichtungsseite, Begrüßung |
Rund neun Arbeitstage. Realistisch drei Kalenderwochen, wenn nebenher noch
etwas anderes läuft.
## Definition of Done für das gesamte MVP
Ein echter Stream, ohne Eingriff von außen:
1. Container läuft von Streambeginn bis -ende durch, auch über einen
Twitch-Reconnect hinweg.
2. Fünfmal ausgelöst (dreimal `!clip`, zweimal Hotkey) ergibt fünf
`final.mp4` mit sitzendem Crop und brauchbaren Untertiteln.
3. Kein Auslöser bleibt unbestätigt, kein Clip geht verloren.
4. Die Sichtungsseite zeigt alle fünf, sortiert nach Score.
5. Das Encoding kostet den Stream keine sichtbaren Frames (Kontrolle über
die OBS-Statistik: keine übersprungenen Frames wegen Encoder-Überlast).
6. Chat und Zuschauerzahlen des Streams sind vollständig in der DB.
## Risiken
| Risiko | Wahrscheinlichkeit | Gegenmaßnahme |
|---|---|---|
| Encoding kostet Frames im Stream | mittel | `-preset veryfast`, CPU-Limit im Compose-File, Schalter `renderWhileLive: false` — dann läuft alles nach Streamende |
| whisper.cpp versteht Spielgeräusche statt Sprache | mittel | Falls OBS getrennte Audiospuren aufnimmt, nur die Mikrofonspur transkribieren. Sonst Rauschgatter vorschalten. |
| 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 |
## Was Abdu vorher tun muss
1. Docker Desktop installieren, WSL2-Backend aktivieren
2. Zwei Twitch-Apps anlegen (siehe [`05-setup.md`](05-setup.md))
3. In OBS: obs-websocket aktivieren (Port 4455, Passwort), Wiedergabepuffer auf
90 Sekunden, Hotkey auf „Wiedergabepuffer speichern" legen
4. **Einen Screenshot der laufenden Szene** schicken — daraus wird der
Facecam-Crop eingemessen
5. Klären, ob OBS Mikrofon und Spiel auf getrennte Audiospuren aufnimmt (macht
die Untertitel deutlich besser)
## Danach
Wenn das MVP steht, ist der teuerste Teil erledigt: Auth, DB, Chat-Anbindung
und Container laufen. Die nächsten Meilensteine aus
[`04-roadmap.md`](04-roadmap.md) setzen darauf auf und werden dadurch
erheblich billiger — das Analytics-Dashboard wertet dann bereits mehrere Wochen
echter Daten aus, statt bei null anzufangen.
@@ -0,0 +1,39 @@
# ADR-0001: TypeScript-Monorepo statt gemischter Sprachen
**Status:** akzeptiert · **Datum:** 2026-09-09
## Kontext
Neun Tools, die sich Twitch-Anbindung, Datenbank und Event-Strom teilen. Teile
davon sind Medienverarbeitung (ffmpeg, Whisper), was klassisch nach Python
riecht; Teile sind Browser-Overlays, was TypeScript nahelegt.
## Entscheidung
Alles in TypeScript auf Node 22, als pnpm-Workspace-Monorepo. ffmpeg und
whisper.cpp werden als **CLI-Prozesse** aufgerufen, nicht über Sprachbindings.
> **Nachtrag 2026-09-09:** Ursprünglich war faster-whisper (Python) vorgesehen.
> Der Check der Zielmaschine ergab Python 3.14, für das `ctranslate2` noch keine
> verlässlichen Wheels liefert. Da ohnehin nur ein Kommandozeilenaufruf
> gebraucht wird, fällt die Wahl auf **whisper.cpp** — eine einzelne Binary
> ohne Laufzeitabhängigkeiten. Damit entfällt Python vollständig aus dem
> Projekt. Siehe [ADR-0004](ADR-0004-docker.md).
## Begründung
- Overlays sind zwingend Browser-Code. Ein Stack statt zwei bedeutet, dass
Typen (`packages/shared`) zwischen Server und Overlay geteilt werden können.
- Twitch-Bibliotheken sind im Node-Ökosystem am ausgereiftesten.
- Der Whisper-Anteil ist in Wahrheit klein: Er wird als Kommandozeilenwerkzeug
aufgerufen und liefert SRT. Dafür lohnt kein zweiter Stack mit eigener
Umgebung, eigenem Paketmanager und eigener Prozessverwaltung.
- Ein Entwickler. Kontextwechsel zwischen Sprachen ist der teuerste Teil.
## Konsequenzen
- `whisper-cli` und `ffmpeg` müssen im `PATH` liegen. Beide sind fest im
Container-Image enthalten und werden beim Start geprüft (`check-env`).
- Sollte später ein echtes ML-Modell im Prozess laufen müssen (z. B.
Szenenerkennung), wird das ein separater Python-Dienst hinter HTTP — dann
bewusst und isoliert, nicht schleichend.
+34
View File
@@ -0,0 +1,34 @@
# ADR-0002: SQLite statt Postgres
**Status:** akzeptiert · **Datum:** 2026-09-09
## Kontext
Mehrere Prozesse schreiben und lesen dieselben Daten. Datenvolumen: bei ~10
Zuschauern und 4 Streams pro Woche etwa 20.000 Chat-Nachrichten und 15.000
Viewer-Samples pro Monat — wenige MB.
## Entscheidung
SQLite (`better-sqlite3`) im WAL-Modus, eine Datei unter `data/db/`.
## Begründung
- Kein Server, kein Docker, kein Zustand außerhalb einer Datei. Backup ist ein
Dateikopie-Vorgang (`VACUUM INTO`).
- WAL erlaubt beliebig viele gleichzeitige Leser neben einem Schreiber. Genau
unsere Topologie: Der Ingest schreibt, alle anderen lesen.
- `better-sqlite3` ist synchron, was den Code im Ingest erheblich vereinfacht —
keine Race Conditions zwischen Event-Verarbeitung und Schreibvorgang.
- Die Datenmenge ist um Größenordnungen von SQLites Grenzen entfernt.
## Konsequenzen
- **Nur ein schreibender Prozess.** Der Ingest ist der einzige Schreiber für
`events`, `chat_messages`, `viewer_samples`, `streams`, `user_streams`.
Andere Apps schreiben ausschließlich in ihre eigenen Tabellen (`clips`,
`posts`, `redemptions`, `raids`). Diese Aufteilung ist verbindlich.
- Schreibende Zugriffe immer in Transaktionen bündeln, `busy_timeout` auf 5000 ms.
- Migration auf Postgres wäre nötig, wenn das Dashboard je remote laufen soll.
Steht nicht an; die Repository-Schicht in `packages/db` kapselt SQL genug,
dass es machbar bliebe.
@@ -0,0 +1,40 @@
# ADR-0003: EventSub über WebSocket, Chat über IRC
**Status:** akzeptiert · **Datum:** 2026-09-09
## Kontext
Twitch bietet drei Wege an Live-Daten: EventSub über Webhooks, EventSub über
WebSocket, und IRC für den Chat. Chat-Nachrichten gibt es inzwischen sowohl über
IRC als auch über EventSub (`channel.chat.message`).
## Entscheidung
- **Nicht-Chat-Events über EventSub-WebSocket.**
- **Chat über IRC** (`wss://irc-ws.chat.twitch.tv:443`).
## Begründung
**Gegen Webhooks:** Sie brauchen eine öffentlich erreichbare HTTPS-Adresse mit
gültigem Zertifikat. Für einen Dienst, der auf Abdus Rechner läuft, hieße das
Tunnel oder VPS — zusätzliche bewegliche Teile und eine dauerhafte Abhängigkeit
von einem Dritten. WebSocket verbindet sich ausgehend und braucht nichts davon.
**Für IRC beim Chat:** Die IRCv3-Tags enthalten mehr, als der EventSub-Payload
liefert — insbesondere `first-msg`, `returning-chatter`, `msg-id`, vollständige
`badges` und `emotes` mit Positionen. Das Community-Gedächtnis und der
Spike-Detektor hängen genau an diesen Feldern. Außerdem sind die Sende-Rate-
Limits über IRC klar dokumentiert (20 Nachrichten / 30 s, 100 für Mods), was
für den Begrüßungs-Bot relevant ist.
## Konsequenzen
- Zwei Verbindungen statt einer, beide mit eigenem Reconnect-Verhalten.
- `session_reconnect` von EventSub muss korrekt behandelt werden: erst zur neuen
URL verbinden, dann die alte schließen — sonst gehen Events verloren.
- Nach jedem Reconnect wird die Lücke per Helix-Polling geschlossen
(Stream-Status, Follower-Liste). Kurze Lücken bei Chat-Nachrichten sind
hinnehmbar und nicht nachholbar.
- Wenn Twitch IRC eines Tages abkündigt, ist der Umstieg auf
`channel.chat.message` auf `packages/twitch/chat.ts` beschränkt — die
Normalisierung auf das interne Event-Format findet dort statt.
+63
View File
@@ -0,0 +1,63 @@
# ADR-0004: Betrieb in Docker
**Status:** akzeptiert · **Datum:** 2026-09-09
## Kontext
Das Toolkit braucht ffmpeg, ein Whisper-Modell, Node und SQLite. Der Check der
Zielmaschine ergab: kein ffmpeg, kein pnpm, Python 3.14 (für das
`ctranslate2` — die Basis von faster-whisper — noch keine verlässlichen Wheels
liefert), keine NVIDIA-GPU. Das Toolkit soll auf Abdus Stream-PC laufen, also
auf einem Rechner, dessen Umgebung sich unabhängig vom Projekt ändert.
## Entscheidung
Alles läuft in Containern, orchestriert über `docker-compose`. Die einzige
Voraussetzung auf dem Stream-PC ist Docker Desktop und OBS.
## Begründung
- Die gesamte Toolchain (Node 22, ffmpeg mit libx264/libass, whisper.cpp, das
Sprachmodell) ist im Image festgeschrieben. Kein "läuft bei mir".
- Der Python-Konflikt entfällt vollständig, weil kein Python mehr gebraucht
wird (siehe [ADR-0001](ADR-0001-monorepo-typescript.md), aktualisiert).
- Updates sind ein `docker compose pull && up -d`, kein Nachinstallieren auf
einem Rechner, der gerade streamt.
## Konsequenzen — die vier Dinge, die man dabei falsch machen kann
**1. Die SQLite-Datei gehört nicht in einen Windows-Bind-Mount.**
Dateisperren über die 9p/virtiofs-Brücke zwischen Windows und WSL2 sind
unzuverlässig; SQLite im WAL-Modus reagiert darauf mit
`SQLITE_BUSY`/`database is locked` und im schlimmsten Fall mit Korruption.
Die Datenbank liegt deshalb in einem **benannten Docker-Volume**
(`abdulive-db`). Backups werden per Skript aus dem Volume nach Windows
kopiert, nicht durch direktes Mounten.
**2. Große Mediendateien nicht über den Bind-Mount verarbeiten.**
Das OBS-Replay-Verzeichnis wird nur lesend gemountet. Die Rohdatei wird
**einmal** in ein containerlokales Volume kopiert, dort verarbeitet, und nur
das fertige Ergebnis wird zurück nach `data/clips/` geschrieben. Direkt auf
dem Bind-Mount zu encodieren kostet ein Vielfaches der Zeit.
**3. OBS läuft auf dem Host, nicht im Container.**
Erreichbar über `host.docker.internal:4455`. In `docker-compose.yml` steht
`extra_hosts: ["host.docker.internal:host-gateway"]`, damit es auch außerhalb
von Docker Desktop funktioniert.
**4. Ein globaler Windows-Hotkey lässt sich aus einem Container nicht abfangen.**
Deshalb wird gar nicht erst versucht, einen eigenen Hotkey-Dienst zu bauen:
Der Hotkey wird **in OBS** auf "Wiedergabepuffer speichern" gelegt. OBS meldet
danach über obs-websocket das Ereignis `ReplayBufferSaved` mit dem Dateipfad —
das ist der Trigger. Kein Agent auf dem Host, kein zusätzlicher Autostart.
**Kosten, ehrlich benannt:** Docker Desktop startet eine WSL2-VM, die im
Leerlauf ein bis zwei GB RAM belegt und beim Encodieren CPU zieht, die dem
Stream fehlt. Gegenmaßnahmen: `.wslconfig` mit `memory=4GB` und
`processors=4`, ffmpeg mit `-preset veryfast` und niedriger Priorität, und
Rendering standardmäßig **nach** Streamende (`clipper.renderWhileLive: false`),
sofern die Kandidatenliste erst danach gebraucht wird.
**GPU:** Kein NVENC (keine NVIDIA-Karte), und Intel QSV ist aus WSL2 heraus
nicht verlässlich erreichbar. Es bleibt bei CPU-Encoding mit libx264. Für
Clips von 30–60 Sekunden ist das unkritisch.
+11
View File
@@ -0,0 +1,11 @@
# Architekturentscheidungen
| ADR | Thema | Status |
|---|---|---|
| [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 |
Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
Entscheidung / Begruendung / Konsequenzen.
+63
View File
@@ -0,0 +1,63 @@
# Ingest-Daemon (`apps/ingest`)
> Kein eigenes "Tool" im Sinne der Wunschliste, sondern das Fundament, auf dem
> sechs der neun Tools stehen. Wird zuerst gebaut.
## Aufgabe
Ein Prozess, der dauerhaft läuft und alle Datenquellen in einen einheitlichen
Event-Strom übersetzt: schreibt in SQLite, publiziert auf dem Bus.
## Quellen
| Quelle | Verbindung | liefert |
|---|---|---|
| Twitch IRC | `wss://irc-ws.chat.twitch.tv:443` | Chat-Nachrichten mit Tags |
| Twitch EventSub | `wss://eventsub.wss.twitch.tv/ws` | Follows, Subs, Raids, Kategorie, Online/Offline |
| Twitch Helix | REST, Polling 60 s | Viewerzahl, Chatter-Liste (5 min) |
| obs-websocket | `ws://127.0.0.1:4455` | Szenenwechsel, Replay-Buffer-Events |
## Was der Ingest selbst berechnet
**Stream-Sessions.** `stream.online` legt eine `streams`-Zeile an,
`stream.offline` schließt sie. Wenn der Daemon mitten im Stream startet, wird
die laufende Session über `GET /helix/streams` rekonstruiert. Kurze
Unterbrechungen (< 5 min) werden als *dieselbe* Session behandelt — sonst
zerfallen Streams bei jedem Verbindungsabbruch in Fragmente.
**Chat-Spikes.** Gleitendes Fenster über die Nachrichtenrate:
```
rate(t) = Nachrichten in den letzten 15 s
baseline(t) = Median von rate über die letzten 10 min
spike = rate >= max(baseline * 3, baseline + 4)
&& Zeit seit letztem Spike > 60 s
```
Zusätzlich gewichtet: Lach-Emotes (`KEKW`, `LUL`, `OMEGALUL`, Kanal-Emotes),
Ausrufe (`?!`, Großbuchstaben-Anteil), das Wort `clip`. Ergebnis ist ein
`chat.spike`-Event mit `score`. Wer darauf reagiert, entscheidet der Konsument.
> Bei 10 Zuschauern ist die Baseline klein und die Statistik dünn. Der Detektor
> ist deshalb bewusst konservativ und protokolliert *alle* Kandidaten mit Score,
> auch die unter der Schwelle — so lässt sich die Schwelle nachträglich am
> echten Material kalibrieren, statt sie zu raten.
**Anwesenheit.** Aus `GET /helix/chat/chatters` (alle 5 min) plus jeder
Nachricht wird `user_streams` fortgeschrieben: Ersterscheinung, letzte
Aktivität, Minuten. Daraus fällt `streams_present` für jeden User ab.
## Schnittstellen nach außen
- **WS `ws://127.0.0.1:4790`** — Broadcast aller Events als JSON-Zeilen.
Ein Client kann beim Connect `{"since":"<iso-ts>"}` senden und bekommt die
verpassten Events aus der DB nachgeliefert.
- **HTTP `GET /health`** — Status aller vier Verbindungen, für das Dashboard.
## Abnahmekriterien
- Läuft 7 Tage ohne Neustart durch, inklusive Twitch-Reconnects.
- Nach einem erzwungenen Prozess-Kill mitten im Stream: Neustart rekonstruiert
die laufende Session, keine doppelten `streams`-Zeilen, keine doppelten Events.
- Jede Chat-Nachricht eines Test-Streams ist in `chat_messages` (Abgleich gegen
ein parallel mitgeschriebenes Log).
+17
View File
@@ -0,0 +1,17 @@
# Spezifikationen
Ein Dokument pro Tool. Aufbau jeweils: Wirkrichtung, Ablauf, technische
Entscheidungen, Fallstricke, Abnahmekriterien.
| # | Dokument | App |
|---|---|---|
| — | [Ingest-Daemon](00-ingest.md) | `apps/ingest` |
| 1 | [Clip-to-Shorts](clip-shorts.md) | `apps/clipper` |
| 2 | [Go-Live-Broadcaster](go-live-broadcaster.md) | `apps/broadcaster` |
| 3 | [Raid- und Netzwerk-Tool](raid-network.md) | `apps/raid-scout` |
| 4 | [Analytics-Dashboard](analytics.md) | `apps/dashboard` |
| 5 | [VOD-Kapitel](vod-chapters.md) | `apps/vod-chapters` |
| 6 | [Community-Gedaechtnis](community-memory.md) | `apps/community` |
| 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` |
+68
View File
@@ -0,0 +1,68 @@
# 4 — Analytics-Dashboard (`apps/dashboard`)
**Wirkrichtung:** Steuerung. Beantwortet die Frage "was funktioniert
eigentlich", die Twitch selbst nicht beantwortet.
Läuft auf `http://localhost:4792`. Vite + Preact + uPlot, liest direkt aus
SQLite. Keine Authentifizierung, weil nur lokal erreichbar.
## Seiten
### Stream-Zeitachse (die wichtigste)
Eine gemeinsame X-Achse (Stream-Laufzeit), darauf übereinander:
- Viewerzahl (Linie, aus `viewer_samples`)
- Chat-Nachrichten pro Minute (Balken)
- unterschiedliche Chatter pro 5 min (Linie)
- Marker: Kategoriewechsel, Titelwechsel, Raids rein/raus, Clips, Follows, Subs
Damit sieht man direkt: An welcher Stelle kamen Leute rein, an welcher gingen
sie, und war der Chat da tot oder wach. **Zusammenhang von Viewerzahl und
Chat-Aktivität ist die Kernfrage** — 15 Zuschauer mit stillem Chat sind
weniger wert als 8 mit lebendigem.
### Abgangs-Analyse
Aus `viewer_samples`: Ableitung über die Zeit, die stärksten Abfälle markiert
und mit dem gleichzeitigen `stream_segments`-Eintrag und den Chat-Nachrichten
der Minute davor angereichert. Antwort auf "wann gehen die Leute".
Wichtige Einschränkung, die im UI stehen muss: `viewer_samples` ist eine
Momentaufnahme der Gesamtzahl, keine Ein-/Austrittsliste. Twitch liefert das
nicht. Ein Abfall von 12 auf 8 kann vier Abgänge oder sechs Abgänge plus zwei
Zugänge bedeuten. Für Chatter ist es exakt (über `user_streams`), für stille
Zuschauer geschätzt.
### Titel- und Kategorie-Performance
Aggregation über `stream_segments`: pro Titel/Kategorie die durchschnittliche
Viewerzahl, der Nettozufluss während des Segments, Chat-Rate, gewonnene
Follower pro Stunde. Sortierbar. Mit Hinweis auf die Stichprobengröße —
bei drei Streams pro Titel ist jede Zahl Rauschen, und das Dashboard sagt das,
statt eine Rangliste vorzutäuschen.
### Chatter: neu vs. wiederkehrend
Pro Stream ein Stapelbalken: Erstchatter / wiederkehrend (1–4 Streams) /
Stammgast (5+). Dazu eine Kohorten-Tabelle: Von den Erstchattern aus Stream N,
wie viele waren in N+1, N+2, N+3 wieder da. Das ist die Zahl, die tatsächlich
über Wachstum entscheidet.
### Betriebsstatus
`GET /health` vom Ingest, letzte Fehler aus `logs/`, Größe der DB,
Clip-Warteschlange.
## Clip-Freigabe
Zusätzlich hostet das Dashboard die Freigabe-Ansicht für die Clip-Pipeline
(siehe [`clip-shorts.md`](clip-shorts.md)): Kandidatenliste mit
Videovorschau, Score, Chat-Ausschnitt und den Knöpfen *Übernehmen* /
*Verwerfen*.
## Abnahmekriterien
- Die Zeitachse eines abgeschlossenen Streams lädt in unter 2 s.
- Alle Marker liegen zeitlich korrekt (Abgleich gegen das VOD).
- Die Kohorten-Tabelle stimmt mit einer manuellen SQL-Auszählung überein.
+95
View File
@@ -0,0 +1,95 @@
# 7 — Chat steuert Dinge (`apps/overlay-server` + `apps/overlay-web`)
**Wirkrichtung:** Bindung. Aus Zuschauen wird Mitmachen.
## Aufbau
```
Chat-Command ──► overlay-server (4791) ──► WS ──► Browser-Source in OBS
│
├──► obs-websocket (Szenen, Filter, Quellen, Hotkeys)
├──► Home Assistant / WLED (Licht)
└──► Helix (Umfragen, Titel)
```
`overlay-server` ist ein Fastify-Server, der drei Dinge tut: Overlay-Seiten
ausliefern, den WS-Kanal zu den Browser-Sources halten, und Commands vom
Event-Bus in Aktionen übersetzen.
## Overlay-Seiten (Browser-Sources in OBS)
| Pfad | Zweck | Größe |
|---|---|---|
| `/overlay/alerts` | Follow/Sub/Raid-Einblendungen | 1920x1080 |
| `/overlay/effects` | Emote-Regen, Soundboard-Feedback, Konfetti | 1920x1080 |
| `/overlay/community` | Chatter-Hinweis (nur für Abdu) | 480x220 |
| `/overlay/poll` | laufende Umfrage | 480x600 |
| `/overlay/game` | Minigame-Anzeige | 1920x1080 |
| `/overlay/raid-panel` | Raid-Ziele am Streamende | 900x600 |
Alle Seiten teilen sich einen WS-Client (`packages/shared/overlay-client.ts`)
mit automatischem Reconnect — eine Browser-Source, die nach einem Serverneustart
tot bleibt, ist der häufigste Fehler bei solchen Overlays.
## Command-Registry
Commands liegen deklarativ in `config/commands.yaml`:
```yaml
- trigger: "!sound"
args: [name]
cooldown: { global: 20, user: 120 }
permission: everyone
cost: 50 # Punkte, optional
action: { type: sound, file: "sounds/{name}.mp3" }
- trigger: "!licht"
args: [farbe]
cooldown: { global: 60 }
permission: subscriber
action: { type: wled, preset: "{farbe}" }
```
Aktionstypen: `sound`, `overlay-effect`, `obs-hotkey`, `obs-source-toggle`,
`obs-filter`, `wled`, `helix-poll`, `set-title`, `script`.
**Cooldowns und Rechte sind nicht optional.** Ein Soundboard ohne Cooldown ist
nach zwei Streams abgeschaltet. Default: 20 s global, 120 s pro User,
Sounds nur für Follower.
## Minispiele
Ausbaustufe 2. Kandidaten, die bei kleinem Chat funktionieren (wichtig: sie
dürfen mit 3 Mitspielern genauso gut sein wie mit 30):
- **Tipprunde** — `!tipp <zahl>`, Abdu löst auf, Punkte für die Nächsten
- **Kollektive Entscheidung** — Helix-Umfrage mit echter Konsequenz im Spiel
- **Boss-Bingo** — Karte im Overlay, Chat ruft Felder aus
## Umfragen mit Konsequenz
`!umfrage` startet eine Helix-Poll (`POST /helix/polls`). Bei `poll.end` führt
der Server die hinterlegte Aktion zur Gewinneroption aus — Szenenwechsel,
Schwierigkeitsgrad, nächstes Spiel. Der Unterschied zu einer normalen Umfrage
ist, dass das Ergebnis automatisch etwas tut, statt nur angezeigt zu werden.
## Sicherheit
Alles, was den Stream sichtbar verändert oder Geräte im Haus schaltet, ist
missbrauchbar. Deshalb:
- `overlay-server` bindet nur auf `127.0.0.1`.
- Der Panik-Command `!aus` (Mods) schaltet alle Effekte für 10 min ab.
- Aktionen mit `requires_approval: true` erscheinen erst in einer Warteschlange
im Overlay-Panel und laufen nach Abdus Klick.
- Kein Command darf beliebigen Text auf dem Bildschirm anzeigen. TTS und freie
Textanzeigen sind bewusst nicht Teil von Ausbaustufe 1.
## Abnahmekriterien
- `!sound huch` spielt innerhalb von 300 ms den Sound ab.
- Cooldown greift und wird im Chat sichtbar quittiert (einmal, nicht bei jedem
Versuch).
- OBS-Neustart oder Server-Neustart: Browser-Source verbindet sich innerhalb von
5 s automatisch neu.
- `!aus` stoppt sofort alle laufenden Effekte.
+175
View File
@@ -0,0 +1,175 @@
# 1 — Clip-to-Shorts-Pipeline (`apps/clipper`)
**Wirkrichtung:** Reichweite. Der einzige Kanal mit nichtlinearer Reichweite für
einen Kanal dieser Größe.
## Der ehrliche Engpass
Nicht das Zuschneiden ist schwer, sondern **die Auswahl**. Ein automatisch
erzeugter, mittelmäßiger Clip pro Stream bringt nichts; drei gute pro Woche
bringen viel. Die Pipeline ist deshalb so gebaut, dass sie **Kandidaten
produziert und rankt**, nicht dass sie blind veröffentlicht. Abdu sieht eine
Liste fertig gerenderter 9:16-Videos und wählt aus. Das dauert zwei Minuten
statt zwei Stunden — genau das ist der Hebel.
Zweiter ehrlicher Punkt: Bei 10 Zuschauern sind Chat-Spikes ein **schwaches
Signal**. Deshalb ist die Reihenfolge der Trigger:
1. **`!clip` im Chat** — funktioniert ab Tag 1, jeder Zuschauer kann auslösen
2. **Hotkey / Streamdeck** durch Abdu selbst — bestes Signal überhaupt
3. **Chat-Spike** — wird von Anfang an mitgeschrieben und protokolliert, aber
erst scharf geschaltet, wenn genug Material für eine Kalibrierung da ist
## Zwei Quellwege
### Weg A: OBS-Replay-Buffer (Primärweg)
Replay-Buffer in OBS auf 90 s. Trigger → `SaveReplayBuffer` über obs-websocket
→ MP4 in voller Aufnahmequalität landet lokal.
**Vorteile:** Originalqualität statt Twitch-Transcode. Kein API-Delay. Kein
Rate Limit. Und — der eigentliche Gewinn — man kann in OBS eine **zweite
Aufnahmespur oder eine dedizierte Vertical-Szene** einrichten, in der Facecam
und Gameplay bereits sauber getrennt sind. Dann entfällt das fehleranfällige
Zurückschneiden aus dem komposite Bild komplett.
**Nachteil:** Braucht Festplattenplatz und eine korrekte OBS-Konfiguration.
### Weg B: Twitch-Clip über Helix (Zweitweg)
`POST /helix/clips` → warten → `GET /helix/clips?id=` → MP4 über die aus
`thumbnail_url` abgeleitete URL (siehe
[`../02-twitch-integration.md`](../02-twitch-integration.md)).
**Wird trotzdem gebraucht,** weil ein Twitch-Clip eine teilbare URL hat, im
Kanal auffindbar ist und von Zuschauern weiterverbreitet werden kann. Deshalb:
**beide Wege gleichzeitig auslösen.** Twitch-Clip für die Plattform, OBS-Datei
für das Short.
## Layout und ffmpeg
Zielformat: 1080×1920, H.264, höchstens 60 s.
### Eingemessen am 2026-09-09
Aus einem VOD-Screenshot (GTA 5, Szene „Gameplay") ergaben sich diese Werte,
festgehalten in [`config/layout.yaml`](../../config/layout.yaml):
| Element | Lage im Streambild 1920×1080 |
|---|---|
| Facecam | bündig oben links, **440×330**, Seitenverhältnis 4:3 |
| Chat-Overlay | ins Bild eingebrannt, beginnt bei **x = 1270** |
| Minimap | unten links, endet bei x ≈ 360 |
| Handy-/Kontakt-Overlay | rechts unten, ab x ≈ 1540 |
Die Kanten der Facecam sind über die gesamte Höhe bzw. Breite konstant
(rechte Kante x = 439 bei y = 120/200/280/360; untere Kante y = 391 bei
x = 40/140/240/340/420) — ein sauberes Rechteck, also ein fester Crop.
**Zwei Befunde, die das ursprünglich geplante Layout verändert haben:**
**1. Der Chat ist ins Streambild eingebrannt.** Ein zentrierter Gameplay-Crop
hätte mitten durch die Chat-Zeilen geschnitten — abgeschnittene Buchstaben am
rechten Rand. Das nutzbare Band liegt zwischen Facecam (endet bei 440) und Chat
(beginnt bei 1270), also **828 px breit**. Das ist schmaler als die 888 px, die
für ein zentrales Drittel nötig wären, und legt damit die Bandaufteilung fest.
**2. Die Facecam ist 4:3, das Zielband ist deutlich breiter.** Ein 4:3-Bild auf
ein 16:9-Band zu skalieren zieht das Gesicht um ein Drittel in die Breite.
Deshalb wird aus der Cam ein **Streifen mit der Zielproportion** geschnitten
(440×208 ab y = 100), nicht das ganze Bild verzerrt. Der Versatz y = 100 sitzt
so, dass Kopf und Kinn vollständig drin sind und oben etwas Luft bleibt.
Daraus folgt die Aufteilung **512 px Cam + 1408 px Gameplay = 1920**:
```bash
ffmpeg -i in.mp4 -filter_complex "
[0:v]crop=440:208:0:100,scale=1080:512,setsar=1[cam];
[0:v]crop=828:1080:440:0,scale=1080:1408,setsar=1[game];
[cam][game]vstack=inputs=2[v]
" -map "[v]" -map 0:a \
-c:v libx264 -crf 20 -preset veryfast -pix_fmt yuv420p \
-c:a aac -b:a 160k -movflags +faststart out.mp4
```
Nachmessen und Vorschau rendern:
```powershell
docker compose run --rm toolkit calibrate
```
Das legt drei Bilder in `data/clips/_calibration/` ab: das Einzelbild, dasselbe
Bild mit eingezeichneten Ausschnitten, und die fertige 9:16-Vorschau.
Für andere Spiele lässt sich pro Kategorie ein eigenes Profil in
`layout.yaml` hinterlegen.
### Was das Layout verbessern würde
Der Crop skaliert die Facecam von 440 px auf 1080 px Breite — Faktor 2,45. Das
ist sichtbar weich. **Die eigentliche Lösung liegt in OBS, nicht in ffmpeg:**
eine zweite Aufnahmespur oder eine dedizierte Vertical-Szene, in der Cam und
Gameplay in voller Auflösung nebeneinander vorliegen. Dann entfällt sowohl der
Qualitätsverlust als auch der ganze Filtergraph. Das ist eine Aufgabe für die
Zeit nach dem MVP — der Crop-Weg funktioniert vorher gut genug.
Kleinigkeit mit großer Wirkung: **GTA-Untertitel im Spiel ausschalten.** Sie
laufen über die volle Bildbreite und werden vom Gameplay-Crop rechts
abgeschnitten. Die eigenen Whisper-Untertitel liegen ohnehin darüber.
Bei Quelle = getrennte Vertical-Szene aus OBS entfällt der ganze Filtergraph.
## Untertitel
`whisper.cpp` (`whisper-cli`), Modell `ggml-small` (deutsch, reicht; `medium`
bei Bedarf), auf der extrahierten Audiospur (16 kHz Mono WAV). Kein Python,
keine GPU nötig — auf CPU braucht ein 30-Sekunden-Clip unter einer Minute.
Ausgabe als SRT, Umwandlung in ASS mit Karaoke-Timing
(wortweises Highlight — der Stil, der auf Shorts funktioniert), dann einbrennen:
```bash
ffmpeg -i out.mp4 -vf "ass=subs.ass" -c:a copy -c:v libx264 -crf 20 final.mp4
```
Der Untertitel-Stil (Font, Größe, Position, Highlight-Farbe) liegt in
`config/subtitle-style.ass`. Sicherheitsabstand nach unten mindestens 320 px,
sonst verdeckt die TikTok-UI den Text.
## Ranking der Kandidaten
`score` aus: Spike-Höhe, Anzahl unterschiedlicher Chatter im Fenster,
Emote-Dichte, Lautstärkespitze im Audio (`ebur128`), Trigger-Art
(Hotkey > `!clip` > Spike), Menge erkannter Sprache im Transkript.
## Ausgabe
```
data/clips/<stream-datum>/<clip-id>/
raw.mp4 Quelle (OBS oder Twitch)
vertical.mp4 9:16 ohne Untertitel
final.mp4 9:16 mit eingebrannten Untertiteln <- Upload-Kandidat
subs.srt / subs.ass
transcript.txt
meta.json Score, Trigger, Chat-Ausschnitt, Titelvorschläge
thumb.jpg
```
Plus ein Eintrag in `clips` mit `status='rendered'`. Das Dashboard zeigt die
Kandidaten des letzten Streams, sortiert nach Score, mit Vorschau und den
Knöpfen *Übernehmen* / *Verwerfen*.
## Ausdrücklich nicht in Ausbaustufe 1
Automatischer Upload zu TikTok/YouTube/Reels. Die Upload-APIs sind aufwendig
(die TikTok Content Posting API braucht eine App-Review), und der Nutzen ist
gering, solange die Auswahl ohnehin manuell erfolgt. Ausbaustufe 2.
## Abnahmekriterien
- `!clip` im Chat, innerhalb von 3 Minuten liegt ein fertiges `final.mp4` vor.
- Untertitel sind zeitlich um weniger als 300 ms versetzt und bei normaler
Sprechlautstärke deutsch korrekt.
- Facecam-Crop sitzt bei drei verschiedenen OBS-Szenen richtig.
- Ein Clip läuft durch die Pipeline, während der Stream weiterläuft, ohne dass
die Encoding-Last Frames im Stream kostet (`-preset veryfast`, niedrige
Prozesspriorität, bei Bedarf NVENC).
+76
View File
@@ -0,0 +1,76 @@
# 6 — Community-Gedächtnis (`apps/community`)
**Wirkrichtung:** Bindung. Bei 10 Zuschauern der Unterschied zwischen
"Zuschauer" und "Community".
Läuft als Modul im `overlay-server`, nicht als eigener Prozess — es braucht
dieselbe Chat-Verbindung und denselben WS-Kanal zum Overlay.
## Datenlage
Alles Nötige liegt bereits in `users` und `user_streams` (siehe
[`../03-data-model.md`](../03-data-model.md)). Dieses Tool ist überwiegend
**Auswertung und Ausgabe**, kaum neue Datenerfassung — deshalb ist es billig,
sobald der Ingest steht.
## Die drei Ausgaben
### 1. Chat-Begrüßung
Ausgelöst durch die erste Nachricht eines Users im laufenden Stream:
| Fall | Bedingung | Beispiel |
|---|---|---|
| **Erstchatter** | `first-msg`-Tag oder `users.message_count = 0` | "Oh, @name schreibt zum ersten Mal — willkommen!" |
| **Rückkehrer** | letzter Besuch > 21 Tage her | "@name, lange nicht gesehen." |
| **Meilenstein** | `streams_chatted` in {5, 10, 25, 50, 100} | "Stream Nummer 25 für @name." |
| **Stammgast** | `streams_chatted >= 5` | individuelle Begrüßung aus `users.greeting` |
| sonst | — | nichts |
Regeln, damit es nicht nervt: höchstens eine Begrüßung alle 20 s, höchstens
zehn pro Stream, `muted_greeting` respektiert, und **keine Begrüßung, wenn Abdu
in den letzten 15 s selbst gesprochen hat** (der Ingest weiß über OBS bzw. über
eine Audiopegel-Schwelle, ob gerade geredet wird) — sonst redet der Bot dem
Streamer ins Wort.
### 2. Overlay-Hinweis für Abdu
Kleines, nur für Abdu sichtbares Panel (eigene Browser-Source in der
Studio-Ansicht oder zweiter Monitor). Wenn jemand schreibt:
```
┌──────────────────────────────────┐
│ maxi_2k Stream 15 · Sub 3M │
│ zuletzt: vor 2 Tagen │
│ Notiz: studiert Maschinenbau, │
│ spielt selbst Elden Ring │
│ erste Nachricht: 04.03.2026 │
└──────────────────────────────────┘
```
**Das ist der eigentliche Wert.** Nicht der Bot, der etwas schreibt, sondern
Abdu, der weiß, mit wem er redet. Ein Bot, der "willkommen zurück" sagt, ist
Deko; ein Streamer, der "na, wie war die Klausur" sagt, bindet Menschen.
### 3. Notizen
Chat-Command für Abdu und Mods: `!notiz <name> <text>` hängt an `users.notes` an.
Im Dashboard editierbar. Das ist die einzige Datenquelle, die Handarbeit
braucht, und die mit dem höchsten Nutzen pro Aufwand.
## Datenschutz
Die DB enthält personenbezogene Daten (Chatverläufe, Notizen). Sie liegt lokal,
wird nicht geteilt und nicht in ein Repo committet (`data/` ist in `.gitignore`).
`!vergissmich` löscht auf Wunsch alle Daten eines Users bis auf die anonyme
Aggregatstatistik. Notizen enthalten nichts, was man der Person nicht ins
Gesicht sagen würde.
## Abnahmekriterien
- Ein Testkonto, das zum ersten Mal schreibt, wird als Erstchatter erkannt und
genau einmal begrüßt.
- Nach einem Namenswechsel des Testkontos bleibt die Historie erhalten
(Verknüpfung über `user_id`, nicht `login`).
- Das Overlay-Panel zeigt den Hinweis innerhalb von 500 ms nach der Nachricht.
- Zwanzig Nachrichten in fünf Sekunden erzeugen höchstens eine Begrüßung.
+61
View File
@@ -0,0 +1,61 @@
# 2 — Go-Live-Broadcaster (`apps/broadcaster`)
**Wirkrichtung:** Reichweite. Billigster Hebel im ganzen Repo — Leute, die schon
folgen, aber die Twitch-Benachrichtigung nicht sehen.
## Ablauf
```
stream.online
-> 60 s warten (Titel/Kategorie/Thumbnail sind erst dann gesetzt)
-> GET /helix/channels (Titel, Kategorie)
-> OBS GetSourceScreenshot (Programm-Ausgabe, 1280x720)
-> Text aus Vorlage rendern
-> pro Plattform: Entwurf anlegen oder direkt senden
```
## Plattformen
| Plattform | Weg | Aufwand | Auto-Post |
|---|---|---|---|
| **Discord** | Webhook, Embed mit Bild + `@everyone`/Rolle | trivial | ja |
| **Bluesky** | AT-Protocol, `com.atproto.repo.createRecord` mit Blob-Upload | klein | ja |
| **Mastodon** | `POST /api/v1/statuses` mit Media | klein | ja |
| **X/Twitter** | API v2, kostenpflichtig ab Basic-Tier | mittel | optional |
| **Instagram Story** | **kein offizieller Weg** — siehe unten | groß | nein |
### Instagram: die Wahrheit
Die Instagram Graph API kann Stories nur für **Business-/Creator-Konten**
veröffentlichen, die mit einer Facebook-Seite verknüpft sind, und braucht eine
App-Review mit `instagram_content_publish`. Für einen kleinen Kanal ist das
unverhältnismäßig, und inoffizielle Wege riskieren die Kontosperre.
**Lösung in Ausbaustufe 1:** Der Broadcaster erzeugt ein fertiges
**1080x1920-Story-Bild** (Screenshot + Titel + Kategorie + "LIVE"-Badge) und
legt es in `data/posts/<datum>/story.png` ab, plus eine Push-Nachricht an Abdus
Handy (Discord-DM oder ntfy.sh). Hochladen dauert dann fünf Sekunden manuell.
Das ist 90 % des Nutzens für 5 % des Aufwands.
## Sicherung gegen Fehlposts
- **Ein Post pro Stream.** `posts`-Tabelle wird auf `stream_id` geprüft.
- **Cooldown 6 h.** Wenn Twitch nach einem Verbindungsabbruch ein zweites
`stream.online` schickt, wird nicht erneut gepostet.
- **Kill-Switch** in `config/config.yaml`: `broadcaster.enabled: false`.
- Beim ersten Einrichten läuft alles im Modus `draft` — Posts landen nur in der
DB und in einem privaten Discord-Kanal zur Kontrolle.
## Vorlagen
`config/templates/golive.*.md`, mit Platzhaltern `{{title}}`, `{{category}}`,
`{{url}}`, `{{weekday}}`. Mehrere Varianten pro Plattform, zufällig gewählt —
identische Posts jede Woche werden von Algorithmen und Menschen gleichermaßen
ignoriert.
## Abnahmekriterien
- Stream-Start erzeugt innerhalb von 90 s einen Discord-Post mit korrektem
Titel, Kategorie und aktuellem Screenshot.
- Ein simulierter doppelter `stream.online` erzeugt keinen zweiten Post.
- Story-Bild ist 1080x1920, Text ist auf dem Handy lesbar.
+71
View File
@@ -0,0 +1,71 @@
# 8 — Punktesystem mit Bedeutung (`apps/loyalty`)
**Wirkrichtung:** Bindung. Läuft als Modul im `overlay-server`.
## Warum nicht Channel Points?
Twitch-Channel-Points funktionieren, haben aber zwei Grenzen: Man kann die
Vergabelogik nicht anpassen (keine Boni für Verhalten, das man belohnen will),
und Einlösungen sind an das Twitch-UI gebunden. Eigene Punkte laufen daneben.
**Beides parallel betreiben.** Channel Points bleiben für simple Sachen,
eigene Punkte für alles, was Logik braucht. `channel.channel_points_custom_reward_redemption.add`
wird ohnehin schon ingested, also können beide Systeme dieselben Aktionen
auslösen.
## Vergabe
| Anlass | Punkte |
|---|---|
| pro 10 Minuten Anwesenheit | 10 |
| ...davon aktiv im Chat | +5 |
| erste Nachricht des Streams | 25 |
| Anwesenheit in Streamminute 1–10 | +50 (belohnt pünktliches Erscheinen) |
| Follow | 200 (einmalig) |
| Sub / Resub | 500 |
| Raid rein | 300 an den Raider |
| Clip, der übernommen wird | 250 an den Auslöser |
Anwesenheit kommt aus `user_streams.minutes` (Helix-Chatter-Liste alle 5 min),
nicht aus dem Chat allein — sonst bekommen stille Zuschauer nie etwas.
## Shop
Der Punkt der Sache ist, dass Einlösungen **wirklich etwas tun**:
| Artikel | Kosten | Aktion | Freigabe |
|---|---|---|---|
| Streamtitel für 10 min | 5.000 | `PATCH /helix/channels`, Timer setzt zurück | ja |
| Emote-Regen 30 s | 800 | Overlay-Effekt | nein |
| Sound aus dem Board | 300 | Overlay + Audio | nein |
| Challenge für Abdu | 3.000 | Karte im Overlay, Abdu bestätigt | ja |
| Nächstes Spiel bestimmen | 10.000 | Eintrag in die Warteschlange | ja |
| Facecam-Filter 60 s | 1.500 | obs-websocket Filter an | nein |
`requires_approval` ist bei allem gesetzt, was den Stream nach außen verändert
(Titel!) oder Abdu zu etwas verpflichtet. Titeländerung zusätzlich abgesichert:
Wortfilter, Längenbegrenzung, automatische Rückkehr zum Originaltitel per
Timer, und der Originaltitel wird vorher in `stream_segments` gesichert.
## Rückerstattung
Wenn eine Aktion fehlschlägt (OBS nicht erreichbar, Helix-Fehler, Abdu lehnt
ab), werden die Punkte automatisch zurückgebucht und der User im Chat
informiert. `redemptions.status = 'refunded'`.
## Bilanz-Integrität
Punkte werden nie direkt auf `users.points` geschrieben, sondern als Zeilen in
einer `points_ledger`-Tabelle geführt; `users.points` ist ein
Materialized-Summenfeld, das über einen Trigger aktualisiert wird. So ist jede
Punktzahl nachvollziehbar und ein Fehler in der Vergabelogik reparierbar, ohne
dass jemandem grundlos Punkte fehlen.
## Abnahmekriterien
- Ein Zuschauer, der 30 min anwesend ist und dreimal schreibt, hat exakt die
erwartete Punktzahl.
- Titel-Einlösung ändert den Titel und setzt ihn nach 10 min zurück, auch wenn
der Server dazwischen neu startet (Timer wird persistiert).
- Fehlgeschlagene Einlösung bucht zurück.
- `points_ledger`-Summe stimmt mit `users.points` überein.
+64
View File
@@ -0,0 +1,64 @@
# 3 — Raid- und Netzwerk-Tool (`apps/raid-scout`)
**Wirkrichtung:** Reichweite über Menschen statt über Algorithmen. In dieser
Kanalgröße die zuverlässigste Quelle für neue Stammzuschauer.
## Kandidaten finden
`GET /helix/streams?game_id=<aktuelle Kategorie>&first=100&language=de`,
seitenweise bis die Viewerzahl unter die Untergrenze fällt (Twitch sortiert
absteigend, also von hinten interessant). Filter:
- Viewer zwischen `min` und `max` (Default 5–30, konfigurierbar)
- Sprache `de`
- Uptime mindestens 30 min und höchstens 5 h — wer gerade erst angefangen hat,
verliert durch einen Raid nichts, wer gleich aufhört, kann nichts damit
anfangen
- nicht auf der Sperrliste (`config/raid-blocklist.yaml`)
## Bewertung
```
score = 0.35 * viewerNaehe (Ziel: aehnliche Groesse wie wir)
+ 0.25 * kategorieUeberlapp (gleiche Kategorie > gleiche Sparte)
+ 0.20 * beziehung (schon mal Kontakt gehabt? zurueckgeraidet?)
+ 0.10 * frische (lange nicht mehr geraidet)
+ 0.10 * uptimeFenster
```
`beziehung` kommt aus der `raids`-Tabelle: Wer uns geraidet hat und noch keinen
Raid zurückbekommen hat, steht ganz oben. **Das ist der eigentliche Kern des
Tools** — nicht das Finden von Fremden, sondern das Nicht-Vergessen von
Leuten, die schon Kontakt aufgenommen haben.
## Oberfläche
- **Chat-Command `!raidziel`** (nur Mods/Broadcaster): Bot postet die Top 3 mit
Login, Viewerzahl und Kategorie in den Chat.
- **Overlay-Panel**, das Abdu am Streamende einblenden kann: Top 5 als Karten,
Klick kopiert `/raid <login>` in die Zwischenablage. Bewusst **kein
automatisches Auslösen** — ein Raid ist eine soziale Geste, die einen
Menschen braucht.
- **Dashboard-Seite** mit der vollständigen Raid-Historie.
## Netzwerk-Gedächtnis
Für jeden Kanal, mit dem es je Kontakt gab:
| Feld | Quelle |
|---|---|
| Raids rein / raus, mit Datum und Viewerzahl | `channel.raid` EventSub |
| erwidert ja/nein | berechnet |
| Notizen | manuell im Dashboard |
| letzter Kontakt | berechnet |
Wöchentliche Erinnerung im Dashboard: "Diese 3 haben dich geraidet, du sie noch
nicht."
## Abnahmekriterien
- `!raidziel` liefert im laufenden Stream innerhalb von 3 s drei plausible
Kanäle in der aktuellen Kategorie.
- Ein eingehender Raid landet automatisch in `raids` mit `direction='in'`.
- Ein ausgehender Raid (manuell in Twitch ausgelöst) wird ebenfalls erfasst.
- Kanäle auf der Sperrliste tauchen nie auf.
+67
View File
@@ -0,0 +1,67 @@
# 9 — Stream-Recap (`apps/recap`)
**Wirkrichtung:** Bindung. Gibt Leuten einen Grund, im Discord zu sein, und
hält den Kanal zwischen zwei Streams präsent.
## Ablauf
`stream.offline` → 10 Minuten warten (VOD-Verarbeitung, letzte Clips rendern)
→ Zahlen aus der DB ziehen → Discord-Post → optional Bluesky-Kurzfassung.
## Inhalt
```
🔴 Stream vom 09.09. · 3 h 42 min · Elden Ring, Just Chatting
Peak 17 Zuschauer · Schnitt 11
23 Leute im Chat, davon 4 zum ersten Mal
+6 Follower
Top-Chatter
1. maxi_2k 84 Nachrichten
2. lenaaa 61
3. der_olli 47
Willkommen im Chat 👋
@neuer1 @neuer2 @neuer3 @neuer4
Beste Momente
▸ "Malenia Versuch 41" clips.twitch.tv/...
▸ "Der Hund im Hintergrund" clips.twitch.tv/...
VOD-Kapitel
00:00 Start
00:14 Elden Ring
01:32 Pause
...
Nächster Stream: Mittwoch 20 Uhr
```
## Regeln, damit es gelesen wird
- **Namen vor Zahlen.** Der Teil, der Leute in den Discord bringt, ist die
Liste mit ihren Namen darin — nicht die Peak-Viewer-Zahl. Erstchatter werden
namentlich begrüßt, Top-Chatter genannt.
- **Keine schlechten Streams schönreden, aber auch nicht ausstellen.** Wenn Peak
unter der Hälfte des Schnitts der letzten fünf Streams liegt, entfallen die
Viewer-Zahlen und der Post konzentriert sich auf Chat und Momente. Öffentlich
schwache Zahlen zu posten schadet mehr, als es nützt; im Dashboard stehen sie
trotzdem.
- **Kein Post ohne Inhalt.** Streams unter 20 Minuten oder ohne Chat-Aktivität
erzeugen keinen Recap.
- **Clips einbetten, nicht verlinken.** Discord bettet Twitch-Clips ab; die
besten zwei nach `clips.score`.
## Vergleich zur Vorwoche
Zweite Zeile, klein: "Chat +18 % ggü. letzten 4 Streams". Nur bei mindestens
vier Vergleichsstreams, sonst weggelassen — Prozentangaben auf Basis von zwei
Datenpunkten sind Unsinn.
## Abnahmekriterien
- Der Post erscheint innerhalb von 15 min nach Streamende.
- Alle Zahlen stimmen mit dem Dashboard überein.
- Ein Stream ohne Chat erzeugt keinen Post.
- Clip-Einbettungen funktionieren in Discord (nicht nur als nackte URL).
+54
View File
@@ -0,0 +1,54 @@
# 5 — VOD-Kapitel (`apps/vod-chapters`)
**Wirkrichtung:** Steuerung und ein wenig Reichweite (YouTube-Zweitverwertung).
## Was aufgezeichnet wird
Während des Streams sammelt der Ingest ohnehin schon:
| Signal | Quelle | wird Kapitel? |
|---|---|---|
| Kategoriewechsel | EventSub `channel.update` | immer |
| Titelwechsel | EventSub `channel.update` | wenn Kategorie gleich bleibt |
| OBS-Szenenwechsel | obs-websocket | nur bei Szenen aus der Whitelist |
| Stream-Marker | `POST /helix/streams/markers` | immer |
| erstellter Clip | `clips` | als Kapitel-Kandidat |
OBS-Szenen sind das rauschendste Signal — ein Wechsel auf "BRB" und zurück sind
zwei Wechsel, die kein Kapitel sind. Deshalb Whitelist in `config/obs-scenes.yaml`
plus Mindestabstand von 5 Minuten zwischen Kapiteln; kürzere Segmente werden mit
dem vorherigen verschmolzen.
## Ausgabe
Nach `stream.offline`: VOD über `GET /helix/videos?user_id=&type=archive&first=1`
holen, Zeitstempel relativ zu `streams.started_at` umrechnen, Text erzeugen:
```
00:00 Start & Begrüßung
00:14 Elden Ring — Malenia, Versuch 40
01:32 Pause
01:47 Elden Ring — sie ist tot
02:55 Just Chatting — Community-Fragen
```
Format wählbar: Twitch-VOD-Beschreibung, YouTube-Kapitel (identisch, muss aber
bei `00:00` beginnen und mindestens drei Einträge mit je mindestens 10 s haben,
sonst zeigt YouTube keine Kapitel an), Discord-Nachricht.
Twitch erlaubt kein Setzen der VOD-Beschreibung über die API — der Text landet
deshalb in `data/vods/<datum>/chapters.txt` und wird per Push-Nachricht
zugestellt, plus als Block im Recap-Post. Einfügen dauert zehn Sekunden.
## Kapitel-Titel
Der Rohtitel ist der Twitch-Titel bzw. der Kategoriename. Optional (Ausbaustufe 2)
eine Verdichtung aus dem Chat: die häufigsten Substantive im Segment als
Zusatz. Erst sinnvoll, wenn genug Chat-Volumen da ist.
## Abnahmekriterien
- Ein Testtream mit zwei Kategoriewechseln und drei Szenenwechseln erzeugt
genau die erwarteten Kapitel.
- Zeitstempel weichen vom VOD um weniger als 5 s ab.
- Kein Kapitel unter 5 Minuten Länge.
+31
View File
@@ -0,0 +1,31 @@
{
"name": "abdulive-toolkit",
"version": "0.1.0",
"private": true,
"description": "Werkzeugkasten fuer den Twitch-Kanal abdulive_",
"type": "module",
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@9.12.0",
"scripts": {
"auth": "pnpm --filter @abdulive/twitch run auth",
"db:migrate": "pnpm --filter @abdulive/db run migrate",
"db:backup": "powershell -File scripts/backup.ps1",
"dev:ingest": "pnpm --filter @abdulive/ingest run dev",
"dev:overlay": "pnpm --filter @abdulive/overlay-server run dev",
"dev:dashboard": "pnpm --filter @abdulive/dashboard run dev",
"clipper:calibrate": "pnpm --filter @abdulive/clipper run calibrate",
"clipper:run": "pnpm --filter @abdulive/clipper run once",
"raid:scout": "pnpm --filter @abdulive/raid-scout run once",
"recap:run": "pnpm --filter @abdulive/recap run once",
"build": "pnpm -r run build",
"typecheck": "pnpm -r run typecheck",
"lint": "eslint .",
"test": "pnpm -r run test",
"start": "docker compose up -d",
"stop": "docker compose down",
"status": "docker compose ps",
"logs": "docker compose logs -f"
}
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/core
Config-Laden, Logger, Event-Bus, Scheduler, Retry mit Backoff.
Spezifikation: [docs/01-architecture.md](../../docs/01-architecture.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/core",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/core — siehe docs/01-architecture.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/db
SQLite-Verbindung, Migrationen, Repositories. Einziger Ort mit SQL.
Spezifikation: [docs/03-data-model.md](../../docs/03-data-model.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/db",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/db — siehe docs/03-data-model.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/shared
Gemeinsame Typen, Event-Definitionen und Zod-Schemas. Haengt von nichts ab.
Spezifikation: [docs/01-architecture.md](../../docs/01-architecture.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/shared — siehe docs/01-architecture.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
# @abdulive/twitch
OAuth-Token-Store, Helix-Client mit Rate-Limit-Bucket, EventSub-WS, IRC-Client.
Spezifikation: [docs/02-twitch-integration.md](../../docs/02-twitch-integration.md)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@abdulive/twitch",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @abdulive/twitch — siehe docs/02-twitch-integration.md
export {};
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+3
View File
@@ -0,0 +1,3 @@
packages:
- 'packages/*'
- 'apps/*'
+12
View File
@@ -0,0 +1,12 @@
# scripts
Skripte laufen entweder im Container (`docker compose run --rm toolkit <name>`)
oder auf dem Host, wo sie mit Docker sprechen.
| Skript | Wo | Zweck |
|---|---|---|
| `check-env` | Container | Prueft ffmpeg, whisper-cli, Sprachmodell, Volumes, OBS-Erreichbarkeit |
| `auth` | Container | OAuth-Flow fuer Broadcaster- und Bot-Token (braucht `--service-ports`) |
| `db:migrate` | Container | Migrationen anwenden |
| `calibrate` | Container | Facecam-Crop einmessen, schreibt `config/layout.yaml` |
| `backup.ps1` | Host | `VACUUM INTO` aus dem Volume `abdulive-db` nach `data/db/backup/`, 30 Generationen |
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}