From d09e2b945a6d9c28a380f7233d2e4f023e002b61 Mon Sep 17 00:00:00 2001 From: ZeasyAlex Date: Wed, 9 Sep 2026 08:56:09 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB --- .dockerignore | 9 + .env.example | 41 ++ .gitignore | 23 ++ README.md | 57 +++ apps/broadcaster/README.md | 5 + apps/broadcaster/package.json | 12 + apps/broadcaster/src/index.ts | 2 + apps/broadcaster/tsconfig.json | 5 + apps/clipper/README.md | 5 + apps/clipper/package.json | 12 + apps/clipper/src/index.ts | 2 + apps/clipper/tsconfig.json | 5 + apps/community/README.md | 5 + apps/community/package.json | 12 + apps/community/src/index.ts | 2 + apps/community/tsconfig.json | 5 + apps/dashboard/README.md | 5 + apps/dashboard/package.json | 12 + apps/dashboard/src/index.ts | 2 + apps/dashboard/tsconfig.json | 5 + apps/ingest/README.md | 5 + apps/ingest/package.json | 12 + apps/ingest/src/index.ts | 2 + apps/ingest/tsconfig.json | 5 + apps/loyalty/README.md | 5 + apps/loyalty/package.json | 12 + apps/loyalty/src/index.ts | 2 + apps/loyalty/tsconfig.json | 5 + apps/overlay-server/README.md | 5 + apps/overlay-server/package.json | 12 + apps/overlay-server/src/index.ts | 2 + apps/overlay-server/tsconfig.json | 5 + apps/overlay-web/README.md | 5 + apps/overlay-web/package.json | 12 + apps/overlay-web/src/index.ts | 2 + apps/overlay-web/tsconfig.json | 5 + apps/raid-scout/README.md | 5 + apps/raid-scout/package.json | 12 + apps/raid-scout/src/index.ts | 2 + apps/raid-scout/tsconfig.json | 5 + apps/recap/README.md | 5 + apps/recap/package.json | 12 + apps/recap/src/index.ts | 2 + apps/recap/tsconfig.json | 5 + apps/vod-chapters/README.md | 5 + apps/vod-chapters/package.json | 12 + apps/vod-chapters/src/index.ts | 2 + apps/vod-chapters/tsconfig.json | 5 + config/config.example.yaml | 95 +++++ config/layout.yaml | 44 +++ docker-compose.yml | 49 +++ docker/Dockerfile | 54 +++ docker/entrypoint.js | 353 ++++++++++++++++++ docker/lib/yaml.js | 118 ++++++ docs/00-overview.md | 46 +++ docs/01-architecture.md | 123 ++++++ docs/02-twitch-integration.md | 122 ++++++ docs/03-data-model.md | 239 ++++++++++++ docs/04-roadmap.md | 61 +++ docs/05-setup.md | 108 ++++++ docs/06-hebel-analyse.md | 103 +++++ docs/07-mvp-plan.md | 207 ++++++++++ .../decisions/ADR-0001-monorepo-typescript.md | 39 ++ docs/decisions/ADR-0002-sqlite.md | 34 ++ docs/decisions/ADR-0003-eventsub-websocket.md | 40 ++ docs/decisions/ADR-0004-docker.md | 63 ++++ docs/decisions/README.md | 11 + docs/projects/00-ingest.md | 63 ++++ docs/projects/README.md | 17 + docs/projects/analytics.md | 68 ++++ docs/projects/chat-control.md | 95 +++++ docs/projects/clip-shorts.md | 175 +++++++++ docs/projects/community-memory.md | 76 ++++ docs/projects/go-live-broadcaster.md | 61 +++ docs/projects/loyalty-shop.md | 71 ++++ docs/projects/raid-network.md | 64 ++++ docs/projects/stream-recap.md | 67 ++++ docs/projects/vod-chapters.md | 54 +++ package.json | 31 ++ packages/core/README.md | 5 + packages/core/package.json | 12 + packages/core/src/index.ts | 2 + packages/core/tsconfig.json | 5 + packages/db/README.md | 5 + packages/db/package.json | 12 + packages/db/src/index.ts | 2 + packages/db/tsconfig.json | 5 + packages/shared/README.md | 5 + packages/shared/package.json | 12 + packages/shared/src/index.ts | 2 + packages/shared/tsconfig.json | 5 + packages/twitch/README.md | 5 + packages/twitch/package.json | 12 + packages/twitch/src/index.ts | 2 + packages/twitch/tsconfig.json | 5 + pnpm-workspace.yaml | 3 + scripts/README.md | 12 + tsconfig.base.json | 17 + 98 files changed, 3273 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/broadcaster/README.md create mode 100644 apps/broadcaster/package.json create mode 100644 apps/broadcaster/src/index.ts create mode 100644 apps/broadcaster/tsconfig.json create mode 100644 apps/clipper/README.md create mode 100644 apps/clipper/package.json create mode 100644 apps/clipper/src/index.ts create mode 100644 apps/clipper/tsconfig.json create mode 100644 apps/community/README.md create mode 100644 apps/community/package.json create mode 100644 apps/community/src/index.ts create mode 100644 apps/community/tsconfig.json create mode 100644 apps/dashboard/README.md create mode 100644 apps/dashboard/package.json create mode 100644 apps/dashboard/src/index.ts create mode 100644 apps/dashboard/tsconfig.json create mode 100644 apps/ingest/README.md create mode 100644 apps/ingest/package.json create mode 100644 apps/ingest/src/index.ts create mode 100644 apps/ingest/tsconfig.json create mode 100644 apps/loyalty/README.md create mode 100644 apps/loyalty/package.json create mode 100644 apps/loyalty/src/index.ts create mode 100644 apps/loyalty/tsconfig.json create mode 100644 apps/overlay-server/README.md create mode 100644 apps/overlay-server/package.json create mode 100644 apps/overlay-server/src/index.ts create mode 100644 apps/overlay-server/tsconfig.json create mode 100644 apps/overlay-web/README.md create mode 100644 apps/overlay-web/package.json create mode 100644 apps/overlay-web/src/index.ts create mode 100644 apps/overlay-web/tsconfig.json create mode 100644 apps/raid-scout/README.md create mode 100644 apps/raid-scout/package.json create mode 100644 apps/raid-scout/src/index.ts create mode 100644 apps/raid-scout/tsconfig.json create mode 100644 apps/recap/README.md create mode 100644 apps/recap/package.json create mode 100644 apps/recap/src/index.ts create mode 100644 apps/recap/tsconfig.json create mode 100644 apps/vod-chapters/README.md create mode 100644 apps/vod-chapters/package.json create mode 100644 apps/vod-chapters/src/index.ts create mode 100644 apps/vod-chapters/tsconfig.json create mode 100644 config/config.example.yaml create mode 100644 config/layout.yaml create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 docker/entrypoint.js create mode 100644 docker/lib/yaml.js create mode 100644 docs/00-overview.md create mode 100644 docs/01-architecture.md create mode 100644 docs/02-twitch-integration.md create mode 100644 docs/03-data-model.md create mode 100644 docs/04-roadmap.md create mode 100644 docs/05-setup.md create mode 100644 docs/06-hebel-analyse.md create mode 100644 docs/07-mvp-plan.md create mode 100644 docs/decisions/ADR-0001-monorepo-typescript.md create mode 100644 docs/decisions/ADR-0002-sqlite.md create mode 100644 docs/decisions/ADR-0003-eventsub-websocket.md create mode 100644 docs/decisions/ADR-0004-docker.md create mode 100644 docs/decisions/README.md create mode 100644 docs/projects/00-ingest.md create mode 100644 docs/projects/README.md create mode 100644 docs/projects/analytics.md create mode 100644 docs/projects/chat-control.md create mode 100644 docs/projects/clip-shorts.md create mode 100644 docs/projects/community-memory.md create mode 100644 docs/projects/go-live-broadcaster.md create mode 100644 docs/projects/loyalty-shop.md create mode 100644 docs/projects/raid-network.md create mode 100644 docs/projects/stream-recap.md create mode 100644 docs/projects/vod-chapters.md create mode 100644 package.json create mode 100644 packages/core/README.md create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/db/README.md create mode 100644 packages/db/package.json create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/tsconfig.json create mode 100644 packages/shared/README.md create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/twitch/README.md create mode 100644 packages/twitch/package.json create mode 100644 packages/twitch/src/index.ts create mode 100644 packages/twitch/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/README.md create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..381af1e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +**/node_modules +**/dist +.git +data +logs +.env +docs +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fe28754 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..561fd59 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..39fe973 --- /dev/null +++ b/README.md @@ -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). diff --git a/apps/broadcaster/README.md b/apps/broadcaster/README.md new file mode 100644 index 0000000..7416c6c --- /dev/null +++ b/apps/broadcaster/README.md @@ -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) diff --git a/apps/broadcaster/package.json b/apps/broadcaster/package.json new file mode 100644 index 0000000..5d49c50 --- /dev/null +++ b/apps/broadcaster/package.json @@ -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" + } +} diff --git a/apps/broadcaster/src/index.ts b/apps/broadcaster/src/index.ts new file mode 100644 index 0000000..fb4f0f1 --- /dev/null +++ b/apps/broadcaster/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/broadcaster — siehe docs/projects/go-live-broadcaster.md +export {}; diff --git a/apps/broadcaster/tsconfig.json b/apps/broadcaster/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/broadcaster/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/clipper/README.md b/apps/clipper/README.md new file mode 100644 index 0000000..de39171 --- /dev/null +++ b/apps/clipper/README.md @@ -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) diff --git a/apps/clipper/package.json b/apps/clipper/package.json new file mode 100644 index 0000000..7240b54 --- /dev/null +++ b/apps/clipper/package.json @@ -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" + } +} diff --git a/apps/clipper/src/index.ts b/apps/clipper/src/index.ts new file mode 100644 index 0000000..9a489ae --- /dev/null +++ b/apps/clipper/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/clipper — siehe docs/projects/clip-shorts.md +export {}; diff --git a/apps/clipper/tsconfig.json b/apps/clipper/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/clipper/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/community/README.md b/apps/community/README.md new file mode 100644 index 0000000..4014368 --- /dev/null +++ b/apps/community/README.md @@ -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) diff --git a/apps/community/package.json b/apps/community/package.json new file mode 100644 index 0000000..1677865 --- /dev/null +++ b/apps/community/package.json @@ -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" + } +} diff --git a/apps/community/src/index.ts b/apps/community/src/index.ts new file mode 100644 index 0000000..b6b359d --- /dev/null +++ b/apps/community/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/community — siehe docs/projects/community-memory.md +export {}; diff --git a/apps/community/tsconfig.json b/apps/community/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/community/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md new file mode 100644 index 0000000..84f1b15 --- /dev/null +++ b/apps/dashboard/README.md @@ -0,0 +1,5 @@ +# @abdulive/dashboard + +Analytics-Dashboard und Freigabe-Ansicht fuer Clip-Kandidaten. + +Spezifikation: [docs/projects/analytics.md](../../docs/projects/analytics.md) diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 0000000..e2c1f92 --- /dev/null +++ b/apps/dashboard/package.json @@ -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" + } +} diff --git a/apps/dashboard/src/index.ts b/apps/dashboard/src/index.ts new file mode 100644 index 0000000..8af4327 --- /dev/null +++ b/apps/dashboard/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/dashboard — siehe docs/projects/analytics.md +export {}; diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/dashboard/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/ingest/README.md b/apps/ingest/README.md new file mode 100644 index 0000000..8d14356 --- /dev/null +++ b/apps/ingest/README.md @@ -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) diff --git a/apps/ingest/package.json b/apps/ingest/package.json new file mode 100644 index 0000000..cc0d7cb --- /dev/null +++ b/apps/ingest/package.json @@ -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" + } +} diff --git a/apps/ingest/src/index.ts b/apps/ingest/src/index.ts new file mode 100644 index 0000000..10eac48 --- /dev/null +++ b/apps/ingest/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/ingest — siehe docs/projects/00-ingest.md +export {}; diff --git a/apps/ingest/tsconfig.json b/apps/ingest/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/ingest/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/loyalty/README.md b/apps/loyalty/README.md new file mode 100644 index 0000000..1c24726 --- /dev/null +++ b/apps/loyalty/README.md @@ -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) diff --git a/apps/loyalty/package.json b/apps/loyalty/package.json new file mode 100644 index 0000000..8108c52 --- /dev/null +++ b/apps/loyalty/package.json @@ -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" + } +} diff --git a/apps/loyalty/src/index.ts b/apps/loyalty/src/index.ts new file mode 100644 index 0000000..ccf730d --- /dev/null +++ b/apps/loyalty/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/loyalty — siehe docs/projects/loyalty-shop.md +export {}; diff --git a/apps/loyalty/tsconfig.json b/apps/loyalty/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/loyalty/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/overlay-server/README.md b/apps/overlay-server/README.md new file mode 100644 index 0000000..6c240bd --- /dev/null +++ b/apps/overlay-server/README.md @@ -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) diff --git a/apps/overlay-server/package.json b/apps/overlay-server/package.json new file mode 100644 index 0000000..600ad34 --- /dev/null +++ b/apps/overlay-server/package.json @@ -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" + } +} diff --git a/apps/overlay-server/src/index.ts b/apps/overlay-server/src/index.ts new file mode 100644 index 0000000..5c2713c --- /dev/null +++ b/apps/overlay-server/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/overlay-server — siehe docs/projects/chat-control.md +export {}; diff --git a/apps/overlay-server/tsconfig.json b/apps/overlay-server/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/overlay-server/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/overlay-web/README.md b/apps/overlay-web/README.md new file mode 100644 index 0000000..1c2b215 --- /dev/null +++ b/apps/overlay-web/README.md @@ -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) diff --git a/apps/overlay-web/package.json b/apps/overlay-web/package.json new file mode 100644 index 0000000..7fd0d69 --- /dev/null +++ b/apps/overlay-web/package.json @@ -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" + } +} diff --git a/apps/overlay-web/src/index.ts b/apps/overlay-web/src/index.ts new file mode 100644 index 0000000..5afe313 --- /dev/null +++ b/apps/overlay-web/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/overlay-web — siehe docs/projects/chat-control.md +export {}; diff --git a/apps/overlay-web/tsconfig.json b/apps/overlay-web/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/overlay-web/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/raid-scout/README.md b/apps/raid-scout/README.md new file mode 100644 index 0000000..02c07be --- /dev/null +++ b/apps/raid-scout/README.md @@ -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) diff --git a/apps/raid-scout/package.json b/apps/raid-scout/package.json new file mode 100644 index 0000000..3e058c6 --- /dev/null +++ b/apps/raid-scout/package.json @@ -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" + } +} diff --git a/apps/raid-scout/src/index.ts b/apps/raid-scout/src/index.ts new file mode 100644 index 0000000..476211c --- /dev/null +++ b/apps/raid-scout/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/raid-scout — siehe docs/projects/raid-network.md +export {}; diff --git a/apps/raid-scout/tsconfig.json b/apps/raid-scout/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/raid-scout/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/recap/README.md b/apps/recap/README.md new file mode 100644 index 0000000..74cc2bd --- /dev/null +++ b/apps/recap/README.md @@ -0,0 +1,5 @@ +# @abdulive/recap + +Stream-Zusammenfassung nach Streamende als Discord-Post. + +Spezifikation: [docs/projects/stream-recap.md](../../docs/projects/stream-recap.md) diff --git a/apps/recap/package.json b/apps/recap/package.json new file mode 100644 index 0000000..88c09ae --- /dev/null +++ b/apps/recap/package.json @@ -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" + } +} diff --git a/apps/recap/src/index.ts b/apps/recap/src/index.ts new file mode 100644 index 0000000..9dcdd63 --- /dev/null +++ b/apps/recap/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/recap — siehe docs/projects/stream-recap.md +export {}; diff --git a/apps/recap/tsconfig.json b/apps/recap/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/recap/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/vod-chapters/README.md b/apps/vod-chapters/README.md new file mode 100644 index 0000000..e020d42 --- /dev/null +++ b/apps/vod-chapters/README.md @@ -0,0 +1,5 @@ +# @abdulive/vod-chapters + +Kapitel-Timestamps aus Kategorie- und Szenenwechseln. + +Spezifikation: [docs/projects/vod-chapters.md](../../docs/projects/vod-chapters.md) diff --git a/apps/vod-chapters/package.json b/apps/vod-chapters/package.json new file mode 100644 index 0000000..b1ace0b --- /dev/null +++ b/apps/vod-chapters/package.json @@ -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" + } +} diff --git a/apps/vod-chapters/src/index.ts b/apps/vod-chapters/src/index.ts new file mode 100644 index 0000000..748535e --- /dev/null +++ b/apps/vod-chapters/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/vod-chapters — siehe docs/projects/vod-chapters.md +export {}; diff --git a/apps/vod-chapters/tsconfig.json b/apps/vod-chapters/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/apps/vod-chapters/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 0000000..e529f0b --- /dev/null +++ b/config/config.example.yaml @@ -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 diff --git a/config/layout.yaml b/config/layout.yaml new file mode 100644 index 0000000..3d218b3 --- /dev/null +++ b/config/layout.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..80d3f58 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..489b807 --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/entrypoint.js b/docker/entrypoint.js new file mode 100644 index 0000000..e69b265 --- /dev/null +++ b/docker/entrypoint.js @@ -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; +} diff --git a/docker/lib/yaml.js b/docker/lib/yaml.js new file mode 100644 index 0000000..50c940c --- /dev/null +++ b/docker/lib/yaml.js @@ -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; +} diff --git a/docs/00-overview.md b/docs/00-overview.md new file mode 100644 index 0000000..07dce57 --- /dev/null +++ b/docs/00-overview.md @@ -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`. diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 0000000..cb63aa6 --- /dev/null +++ b/docs/01-architecture.md @@ -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). diff --git a/docs/02-twitch-integration.md b/docs/02-twitch-integration.md new file mode 100644 index 0000000..c1793d7 --- /dev/null +++ b/docs/02-twitch-integration.md @@ -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) diff --git a/docs/03-data-model.md b/docs/03-data-model.md new file mode 100644 index 0000000..5cf20d4 --- /dev/null +++ b/docs/03-data-model.md @@ -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). diff --git a/docs/04-roadmap.md b/docs/04-roadmap.md new file mode 100644 index 0000000..113ea14 --- /dev/null +++ b/docs/04-roadmap.md @@ -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 diff --git a/docs/05-setup.md b/docs/05-setup.md new file mode 100644 index 0000000..7f1057e --- /dev/null +++ b/docs/05-setup.md @@ -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. diff --git a/docs/06-hebel-analyse.md b/docs/06-hebel-analyse.md new file mode 100644 index 0000000..bfd2951 --- /dev/null +++ b/docs/06-hebel-analyse.md @@ -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. diff --git a/docs/07-mvp-plan.md b/docs/07-mvp-plan.md new file mode 100644 index 0000000..27ecea2 --- /dev/null +++ b/docs/07-mvp-plan.md @@ -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///` 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. diff --git a/docs/decisions/ADR-0001-monorepo-typescript.md b/docs/decisions/ADR-0001-monorepo-typescript.md new file mode 100644 index 0000000..98d217f --- /dev/null +++ b/docs/decisions/ADR-0001-monorepo-typescript.md @@ -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. diff --git a/docs/decisions/ADR-0002-sqlite.md b/docs/decisions/ADR-0002-sqlite.md new file mode 100644 index 0000000..6d2e818 --- /dev/null +++ b/docs/decisions/ADR-0002-sqlite.md @@ -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. diff --git a/docs/decisions/ADR-0003-eventsub-websocket.md b/docs/decisions/ADR-0003-eventsub-websocket.md new file mode 100644 index 0000000..165c622 --- /dev/null +++ b/docs/decisions/ADR-0003-eventsub-websocket.md @@ -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. diff --git a/docs/decisions/ADR-0004-docker.md b/docs/decisions/ADR-0004-docker.md new file mode 100644 index 0000000..ec53911 --- /dev/null +++ b/docs/decisions/ADR-0004-docker.md @@ -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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..0a5a304 --- /dev/null +++ b/docs/decisions/README.md @@ -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--.md`, Aufbau Kontext / +Entscheidung / Begruendung / Konsequenzen. diff --git a/docs/projects/00-ingest.md b/docs/projects/00-ingest.md new file mode 100644 index 0000000..c92ff8f --- /dev/null +++ b/docs/projects/00-ingest.md @@ -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":""}` 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). diff --git a/docs/projects/README.md b/docs/projects/README.md new file mode 100644 index 0000000..1f657b9 --- /dev/null +++ b/docs/projects/README.md @@ -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` | diff --git a/docs/projects/analytics.md b/docs/projects/analytics.md new file mode 100644 index 0000000..e9ac019 --- /dev/null +++ b/docs/projects/analytics.md @@ -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. diff --git a/docs/projects/chat-control.md b/docs/projects/chat-control.md new file mode 100644 index 0000000..1a050b4 --- /dev/null +++ b/docs/projects/chat-control.md @@ -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 `, 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. diff --git a/docs/projects/clip-shorts.md b/docs/projects/clip-shorts.md new file mode 100644 index 0000000..7213f5f --- /dev/null +++ b/docs/projects/clip-shorts.md @@ -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/// + 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). diff --git a/docs/projects/community-memory.md b/docs/projects/community-memory.md new file mode 100644 index 0000000..0777c67 --- /dev/null +++ b/docs/projects/community-memory.md @@ -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 ` 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. diff --git a/docs/projects/go-live-broadcaster.md b/docs/projects/go-live-broadcaster.md new file mode 100644 index 0000000..0a6a95b --- /dev/null +++ b/docs/projects/go-live-broadcaster.md @@ -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//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. diff --git a/docs/projects/loyalty-shop.md b/docs/projects/loyalty-shop.md new file mode 100644 index 0000000..c8f60d8 --- /dev/null +++ b/docs/projects/loyalty-shop.md @@ -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. diff --git a/docs/projects/raid-network.md b/docs/projects/raid-network.md new file mode 100644 index 0000000..b07241b --- /dev/null +++ b/docs/projects/raid-network.md @@ -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=&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 ` 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. diff --git a/docs/projects/stream-recap.md b/docs/projects/stream-recap.md new file mode 100644 index 0000000..bcfa567 --- /dev/null +++ b/docs/projects/stream-recap.md @@ -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). diff --git a/docs/projects/vod-chapters.md b/docs/projects/vod-chapters.md new file mode 100644 index 0000000..6cb0e86 --- /dev/null +++ b/docs/projects/vod-chapters.md @@ -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//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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..3f8fb5f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..9c5c70f --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,5 @@ +# @abdulive/core + +Config-Laden, Logger, Event-Bus, Scheduler, Retry mit Backoff. + +Spezifikation: [docs/01-architecture.md](../../docs/01-architecture.md) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..bb70216 --- /dev/null +++ b/packages/core/package.json @@ -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" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..6ad32fb --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/core — siehe docs/01-architecture.md +export {}; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/db/README.md b/packages/db/README.md new file mode 100644 index 0000000..8726b37 --- /dev/null +++ b/packages/db/README.md @@ -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) diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..50ced17 --- /dev/null +++ b/packages/db/package.json @@ -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" + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..d2e82ed --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/db — siehe docs/03-data-model.md +export {}; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/shared/README.md b/packages/shared/README.md new file mode 100644 index 0000000..f56c2ce --- /dev/null +++ b/packages/shared/README.md @@ -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) diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..2aac20b --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..eae8eae --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/shared — siehe docs/01-architecture.md +export {}; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/twitch/README.md b/packages/twitch/README.md new file mode 100644 index 0000000..669451c --- /dev/null +++ b/packages/twitch/README.md @@ -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) diff --git a/packages/twitch/package.json b/packages/twitch/package.json new file mode 100644 index 0000000..6dd267a --- /dev/null +++ b/packages/twitch/package.json @@ -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" + } +} diff --git a/packages/twitch/src/index.ts b/packages/twitch/src/index.ts new file mode 100644 index 0000000..e517285 --- /dev/null +++ b/packages/twitch/src/index.ts @@ -0,0 +1,2 @@ +// @abdulive/twitch — siehe docs/02-twitch-integration.md +export {}; diff --git a/packages/twitch/tsconfig.json b/packages/twitch/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/twitch/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..4e708bd --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'packages/*' + - 'apps/*' diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..2ae379c --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,12 @@ +# scripts + +Skripte laufen entweder im Container (`docker compose run --rm toolkit `) +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 | diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..ec45ef9 --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +}