5 Commits
Author SHA1 Message Date
ZeasyAlexandClaude Opus 5.5 d00550aa37 Rueckbau: gestrichene Werkzeuge entfernt, Hub schreibt keinen Chat mehr mit
Raid-Scout, Auswertung, VOD-Kapitel, Community-Gedaechtnis, Punkteshop
und Recap samt Platzhaltern, Spezifikationen und Konfiguration. Der Hub
schickt am Chat zusaetzlich die Sendezeit mit, fuer die Uhrzeit im Pult.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:16:04 +01:00
ZeasyAlexandClaude Opus 5.5 227ecfdcfe Pult: Chat nur lesend, am Tablet Chat neben dem Reiter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:16:04 +01:00
ZeasyAlexandClaude Opus 5.5 9c64309c51 Uebergabe: Stream-PC verbunden, Chat-Abzeichen als Bilder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:19:10 +01:00
ZeasyAlexandClaude Opus 5.5 67ca472317 Chat-Overlay: echte Twitch-Abzeichen statt farbiger Punkte
Der Hub loest die Abzeichen aus dem IRC-Tag ueber Helix (global und
Kanal) zu Bildern auf und schickt sie mit jeder Chatzeile mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:12:37 +01:00
ZeasyAlexandClaude Opus 5.5 884e4517d8 Uebergabe: Pult-Freigabe als Abdus Handgriff, Abnahme an echtem OBS
Playwright-Mitschrift aus dem Repo genommen und ausgeschlossen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:00:50 +01:00
41 changed files with 561 additions and 653 deletions

No files matched your search

+3
View File
@@ -32,3 +32,6 @@ docs/handbuch/*.log
docs/handbuch/*.out docs/handbuch/*.out
docs/handbuch/*.toc docs/handbuch/*.toc
docs/handbuch/*.png docs/handbuch/*.png
# Mitschriften der Browser-Automatisierung
.playwright-mcp/
@@ -1,93 +0,0 @@
[ 54050ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_RESET @ http://127.0.0.1:4791/events?quelle=pult:0
[ 54054ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 55062ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 55068ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 56069ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 56071ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 57087ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 57089ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 58102ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 58104ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 59105ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 59107ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 60048ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 60109ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 60111ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 61142ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 61160ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 62276ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 62524ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 63583ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 63620ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 64985ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 65532ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 66017ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 66032ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 67490ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 67498ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 68503ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 68506ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 69510ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 69512ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 70526ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 70529ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 71536ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 71538ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 72551ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 72554ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 73562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 73565ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 74571ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 74574ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 75587ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 75590ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 76603ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 76606ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 77607ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 77608ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 78614ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 78619ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 79627ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 79629ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 80112ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 80649ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 80663ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 82183ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 82313ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 84634ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 84970ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 85652ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 85687ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 87032ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 87063ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 88257ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 88277ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 89301ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 89323ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 90312ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 90315ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 91330ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 91333ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 93470ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 94174ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 94469ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 94472ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 95485ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 95487ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 96489ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 96491ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 97495ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 97500ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 98504ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 98507ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 99514ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 99519ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 100093ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 100096ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 100104ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 103134ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 103167ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 106151ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 106162ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 109164ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 109166ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+10 -15
View File
@@ -11,23 +11,18 @@ Betrieb über Docker Compose.
Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese
Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**. Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**.
## Die neun Tools ## Was es gibt
| # | Tool | Ordner | Zweck | Status | | Teil | Ordner | Zweck |
|---|------|--------|-------|--------| |------|--------|-------|
| 1 | Clip-to-Shorts | `apps/clipper` | Clips → 9:16 mit Untertiteln, Upload-fertig | geplant | | Hub | `apps/hub` | Läuft zentral im LXC: Chat, Overlay für OBS, Pult, Kanalpunkte, Discord, Go-Live-Ping |
| 2 | Go-Live-Broadcaster | `apps/broadcaster` | Stream-Start → Discord/Bluesky/Instagram | geplant | | Overlay und Pult | `apps/overlay-server` + `apps/overlay-web` | Browser-Sources für OBS, Pult am Handy und Tablet |
| 3 | Raid- & Netzwerk-Tool | `apps/raid-scout` | Raid-Ziele finden, Raid-Historie führen | geplant | | Go-Live-Ping | `apps/broadcaster` | Stream-Start → Discord |
| 4 | Analytics-Dashboard | `apps/dashboard` | Viewer vs. Chat, Titel-Performance, Retention | geplant | | Ingest | `apps/ingest` | Läuft auf dem Stream-PC: OBS-Anbindung, `!clip`, 9:16-Render mit Untertiteln |
| 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: Die früher geplanten Werkzeuge (Raid-Scout, Auswertung, VOD-Kapitel,
Community-Gedächtnis, Punkteshop, Recap) sind gestrichen und mit ihren
| — | **Ingest-Daemon** | `apps/ingest` | IRC + Helix → DB, Einrichtungsseite | **läuft** | Spezifikationen entfernt.
## Doku ## Doku
-5
View File
@@ -1,5 +0,0 @@
# @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)
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/community
Community-Gedaechtnis: Begruessungen, Meilensteine, Overlay-Hinweis. Modul im overlay-server.
Spezifikation: [docs/projects/community-memory.md](../../docs/projects/community-memory.md)
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/dashboard
Analytics-Dashboard und Freigabe-Ansicht fuer Clip-Kandidaten.
Spezifikation: [docs/projects/analytics.md](../../docs/projects/analytics.md)
+20 -7
View File
@@ -30,6 +30,7 @@ import { createBot } from '#discord/bot.ts';
import { helix } from '#twitch/helix.ts'; import { helix } from '#twitch/helix.ts';
import { readSecrets } from '#twitch/secrets.ts'; import { readSecrets } from '#twitch/secrets.ts';
import { createEventSub } from '#twitch/eventsub.ts'; import { createEventSub } from '#twitch/eventsub.ts';
import { createBadges } from '#twitch/badges.ts';
import { createIrc, type UserNotice } from '#twitch/irc.ts'; import { createIrc, type UserNotice } from '#twitch/irc.ts';
import { streamWechsel } from '#twitch/stream-state.ts'; import { streamWechsel } from '#twitch/stream-state.ts';
import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts'; import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
@@ -245,6 +246,21 @@ export async function runHub(): Promise<void> {
const bot = createBot(); const bot = createBot();
if (bot) void bot.run(abort.signal); if (bot) void bot.run(abort.signal);
// ---- Abzeichen-Bilder fuer den Chat. Twitch aendert sie selten, ein
// neues Abo-Abzeichen des Kanals ist nach spaetestens sechs Stunden da.
const badges = createBadges();
void (async () => {
while (!abort.signal.aborted) {
if (helix.canReadStream()) {
const id = state.broadcasterId || cfg.channel.userId ||
(await helix.userByLogin(cfg.channel.login).catch(() => null))?.id || null;
if (id) state.broadcasterId = id;
await badges.laden(id);
}
try { await sleep(6 * 60 * 60_000, abort.signal); } catch { return; }
}
})();
// ---- Chat // ---- Chat
const irc = createIrc(cfg.channel.login, { const irc = createIrc(cfg.channel.login, {
onConnected: (anonymous) => { onConnected: (anonymous) => {
@@ -253,11 +269,9 @@ export async function runHub(): Promise<void> {
}, },
onMessage: (msg) => { onMessage: (msg) => {
try { try {
repo.recordMessage(msg, state.streamId); // Chatnachrichten landen nicht mehr in der Datenbank. Gelesen haben
// sie nur Recap und Auswertung, und die gibt es nicht mehr.
state.messages++; state.messages++;
repo.event('chat.message', {
text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits,
}, state.streamId, msg.userId, msg.ts);
bus.publish({ bus.publish({
kind: 'chat', kind: 'chat',
@@ -273,7 +287,9 @@ export async function runHub(): Promise<void> {
mod: msg.isMod, mod: msg.isMod,
sub: msg.isSub, sub: msg.isSub,
broadcaster: msg.isBroadcaster, broadcaster: msg.isBroadcaster,
badges: badges.aufloesen(msg.badges),
bits: msg.bits, bits: msg.bits,
ts: msg.ts,
}); });
if (msg.isFirst) { if (msg.isFirst) {
@@ -549,9 +565,6 @@ async function pollStream(
state.startedLiveAt = live.started_at; state.startedLiveAt = live.started_at;
state.lastSeenLive = now; state.lastSeenLive = now;
state.viewers = live.viewer_count; state.viewers = live.viewer_count;
if (state.streamId) {
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
}
sendState(true); sendState(true);
} }
} }
+1 -1
View File
@@ -21,7 +21,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage {
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId, id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId,
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [], login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [],
isFirst: false, isReturning: false, isCommand: text.startsWith('!'), isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null, isMod: false, isSub: false, isBroadcaster: false, badges: [], bits: 0, color: null,
}; };
} }
-5
View File
@@ -1,5 +0,0 @@
# @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)
+1 -1
View File
@@ -10,7 +10,7 @@ function nachricht(over: Partial<ChatEvent> = {}): ChatEvent {
return { return {
kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin', kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin',
text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false, text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false,
sub: false, broadcaster: false, bits: 0, sub: false, broadcaster: false, badges: [], bits: 0,
...over, ...over,
}; };
} }
+7
View File
@@ -29,7 +29,14 @@ export type ChatEvent = {
mod: boolean; mod: boolean;
sub: boolean; sub: boolean;
broadcaster: boolean; broadcaster: boolean;
/** Abzeichen als fertige Bilder, aufgeloest im Hub (#twitch/badges.ts). */
badges: Array<{ url: string; title: string }>;
bits: number; bits: number;
/**
* Sendezeit laut Twitch. Fuer die Uhrzeit im Pult: ohne sie stuende nach
* dem Neuverbinden an allen Nachrichten aus dem Schnappschuss dieselbe.
*/
ts?: string;
}; };
export type AlertKind = export type AlertKind =
+18 -2
View File
@@ -96,6 +96,23 @@ export const TEST_KNOEPFE: TestKnopf[] = [
{ art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' }, { art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' },
]; ];
// Twitchs globale Abzeichen. Die Adressen sind fest, so zeigt die Vorfuehrung
// echte Bilder, ohne Helix fragen zu muessen.
const ABZEICHEN = 'https://static-cdn.jtvnw.net/badges/v1/';
const MOD = { url: `${ABZEICHEN}3267646d-33f0-4b17-b3df-f923a41db1d0/2`, title: 'Moderator' };
const VIP = { url: `${ABZEICHEN}b817aba4-fad8-49e2-b88a-7cc744dfa6ec/2`, title: 'VIP' };
const ABO = { url: `${ABZEICHEN}5d9f2208-5dd8-11e7-8513-2ff4adfae661/2`, title: 'Subscriber' };
function zufallsAbzeichen(): Pick<ChatEvent, 'mod' | 'sub' | 'badges'> {
const mod = Math.random() < 0.15;
const vip = !mod && Math.random() < 0.1;
const sub = Math.random() < 0.3;
return {
mod, sub,
badges: [...(mod ? [MOD] : []), ...(vip ? [VIP] : []), ...(sub ? [ABO] : [])],
};
}
function chat(over: Partial<ChatEvent> = {}): ChatEvent { function chat(over: Partial<ChatEvent> = {}): ChatEvent {
const name = zufall(NAMEN); const name = zufall(NAMEN);
return { return {
@@ -109,8 +126,7 @@ function chat(over: Partial<ChatEvent> = {}): ChatEvent {
emotes: [], emotes: [],
gifs: [], gifs: [],
first: false, first: false,
mod: Math.random() < 0.15, ...zufallsAbzeichen(),
sub: Math.random() < 0.3,
broadcaster: false, broadcaster: false,
bits: 0, bits: 0,
...over, ...over,
+12
View File
@@ -100,8 +100,20 @@ einen Knopfdruck aus dem Speicher.
| Sounds | Tasten fürs Soundboard. „+ Sound“ lädt MP3, WAV, OGG, WebM oder M4A vom Handy hoch (bis 8 MB). „Bearbeiten“: Name, Emoji, Farbe, Lautstärke, Reihenfolge, Löschen, am Handy anhören. „Stopp“ bricht alle Sounds ab. | | Sounds | Tasten fürs Soundboard. „+ Sound“ lädt MP3, WAV, OGG, WebM oder M4A vom Handy hoch (bis 8 MB). „Bearbeiten“: Name, Emoji, Farbe, Lautstärke, Reihenfolge, Löschen, am Handy anhören. „Stopp“ bricht alle Sounds ab. |
| Szenen | OBS-Szenen umschalten, Clip aus dem Wiedergabepuffer speichern, Toneingänge stumm schalten. Braucht den Stream-PC (siehe unten). | | Szenen | OBS-Szenen umschalten, Clip aus dem Wiedergabepuffer speichern, Toneingänge stumm schalten. Braucht den Stream-PC (siehe unten). |
| Stream | Liegestütze zählen, Vorlesen freigeben, „Sofort still“. | | Stream | Liegestütze zählen, Vorlesen freigeben, „Sofort still“. |
| Chat | Der Twitch-Chat, **nur lesend**: Abzeichen, Emotes, GIFs, Erstchatter hervorgehoben, Abos, Raids und Belohnungen als eigene Zeile, von Mods Gelöschtes durchgestrichen. |
| Test | Dieselben Testknöpfe wie auf der Übersicht. | | Test | Dieselben Testknöpfe wie auf der Übersicht. |
**Am Tablet** (ab 760 px Breite und 500 px Höhe) steht der Chat fest rechts
und links der gewählte Reiter, der Chat-Reiter entfällt. Ohne gemerkten
Reiter geht das Pult dort auf Szenen auf. Quer gehaltene Handys sind zu
niedrig dafür und behalten die Reiter.
Der Chat hängt am selben Ereignisstrom wie die Browser-Sources und schreibt
nie. Er braucht kein Bot-Konto und keine weiteren Rechte bei Twitch. Nach
dem Neuverbinden kommen die letzten 30 Nachrichten aus dem Schnappschuss,
im Pult stehen höchstens 250. Wer hochscrollt, bleibt dort; „Neue
Nachrichten“ springt zurück ans Ende.
Die Sonne oben rechts hält den Bildschirm an (Wake Lock), solange das Pult Die Sonne oben rechts hält den Bildschirm an (Wake Lock), solange das Pult
offen ist. offen ist.
+14 -13
View File
@@ -53,18 +53,15 @@
background: rgba(0, 0, 0, .35); background: rgba(0, 0, 0, .35);
} }
/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten /* Twitchs eigene Abzeichen. Die Bilder loest der Hub ueber Helix auf und
* ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */ * schickt sie mit jeder Zeile mit (#twitch/badges.ts). */
.abzeichen { .abzeichen {
display: inline-block; height: 1.05em;
width: .5em; height: .5em; width: auto;
border-radius: 50%; margin-right: .3em;
margin-right: .4em; vertical-align: -.17em;
vertical-align: .08em;
} }
.abzeichen.mod { background: var(--gruen); } .abzeichen:last-of-type { margin-right: .45em; }
.abzeichen.abo { background: var(--lila); }
.abzeichen.host { background: var(--rot); }
/* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in /* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in
* dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante * dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante
@@ -152,9 +149,13 @@
el.dataset.user = ev.userId; el.dataset.user = ev.userId;
var abzeichen = ''; var abzeichen = '';
if (ev.broadcaster) abzeichen += '<span class="abzeichen host"></span>'; var bilder = ev.badges || [];
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>'; for (var i = 0; i < bilder.length; i++) {
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>'; // Nur Twitchs eigene Bildadressen: was hier steht, laedt OBS.
if (!/^https:\/\/static-cdn\.jtvnw\.net\//.test(bilder[i].url)) continue;
abzeichen += '<img class="abzeichen" src="' + Overlay.esc(bilder[i].url) +
'" alt="' + Overlay.esc(bilder[i].title) + '">';
}
el.innerHTML = el.innerHTML =
abzeichen + abzeichen +
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "abdulive Pult", "name": "abdulive Pult",
"short_name": "Pult", "short_name": "Pult",
"description": "Soundboard, Szenen, Liegestütze und Vorlesen für den Stream von abdulive_.", "description": "Soundboard, Szenen, Chat, Liegestütze und Vorlesen für den Stream von abdulive_.",
"lang": "de", "lang": "de",
"id": "/pult", "id": "/pult",
"start_url": "/pult", "start_url": "/pult",
@@ -16,6 +16,7 @@
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } { "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
], ],
"shortcuts": [ "shortcuts": [
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] } { "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] },
{ "name": "Chat", "url": "/pult#chat", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
] ]
} }
+75 -1
View File
@@ -327,11 +327,85 @@ main {
.gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; } .gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; } .gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; }
/* ------------------------------------------------------------ Chat */
/* Ein fester Kasten zwischen Kopf und Reitern, damit nur die Liste
* scrollt und nicht die Seite: wer im Chat hochscrollt, soll den Kopf mit
* LIVE-Anzeige und Verbindungspunkt weiter sehen. */
.tab.chat.an { display: flex; }
.chat {
position: fixed; z-index: 10; left: 0; right: 0;
top: calc(var(--kopf-h) + var(--oben));
bottom: calc(var(--reiter-h) + var(--unten));
flex-direction: column;
background: var(--grund);
}
.chat-kopf {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
padding: 14px 16px 10px; border-bottom: 1px solid var(--linie);
}
.chat-kopf .abschnitt, .chat-kopf .klein { margin: 0; }
/* Wenige Nachrichten stehen unten wie in jedem Chat, nicht oben unter dem
* Kopf. margin-top: auto statt justify-content: flex-end - mit letzterem
* laesst sich in manchen Browsern nicht mehr nach oben scrollen. */
.chat-liste {
display: flex; flex-direction: column;
flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
padding: 8px 10px 14px; font-size: 15px; line-height: 1.42;
}
.chat-leer { position: absolute; top: 50%; left: 0; right: 0; text-align: center;
color: var(--leise); font-size: 14px; margin: 0; }
.cz { flex: none; padding: 5px 8px; border-radius: 8px; overflow-wrap: anywhere; }
.cz:first-child { margin-top: auto; }
.cz .zeit { color: var(--leise); font-size: 12px; font-variant-numeric: tabular-nums; margin-right: 6px; }
.cz .abz { height: 18px; width: auto; vertical-align: -3px; margin-right: 3px; }
.cz .wer { font-weight: 700; }
.cz .emote { height: 28px; width: auto; vertical-align: -8px; }
.cz .gif { display: block; max-width: min(100%, 220px); max-height: 140px; border-radius: 6px; margin: 4px 0 2px; }
.cz.erstmals { background: rgba(74, 222, 128, .1); box-shadow: inset 3px 0 0 var(--gruen); }
.cz.erstmals::before {
content: "Zum ersten Mal hier"; display: block; margin-bottom: 1px;
font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--gruen);
}
.cz.bits { background: rgba(255, 197, 61, .1); }
.cz.weg { opacity: .45; }
.cz.weg .text { text-decoration: line-through; }
.cz.system {
margin: 6px 0; font-size: 13px; color: var(--schrift);
background: rgba(169, 112, 255, .1); border: 1px solid rgba(169, 112, 255, .35);
}
.chat-runter {
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
padding: 9px 16px; border-radius: 999px; cursor: pointer;
font: 600 14px/1 var(--font); color: #fff;
background: var(--lila-tief); border: 1px solid var(--lila);
box-shadow: 0 8px 24px rgba(0, 0, 0, .45);
}
/* Ab Tablet-Groesse: Chat fest rechts, links der gewaehlte Reiter. So
* stehen Szenen und Chat auf einem Bildschirm. Die Hoehe haelt quer
* gehaltene Handys heraus, dort bliebe fuer beides zu wenig. Muss in
* pult.js (`geteilt`) gleich lauten. */
@media (min-width: 760px) and (min-height: 500px) {
:root { --chat-b: clamp(300px, 36vw, 440px); }
main { max-width: none; margin: 0; padding-right: calc(var(--chat-b) + 16px); }
.tab.chat {
display: flex; left: auto; width: var(--chat-b); bottom: 0;
padding-bottom: var(--unten); border-left: 1px solid var(--linie); animation: none;
}
.chat-runter { bottom: calc(14px + var(--unten)); }
.reiter { right: var(--chat-b); }
#nav-chat { display: none; }
.meldung { left: calc((100% - var(--chat-b)) / 2); }
}
/* ------------------------------------------------------------ Reiter */ /* ------------------------------------------------------------ Reiter */
.reiter { .reiter {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
display: grid; grid-template-columns: repeat(4, 1fr); display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
height: calc(var(--reiter-h) + var(--unten)); height: calc(var(--reiter-h) + var(--unten));
padding-bottom: var(--unten); padding-bottom: var(--unten);
background: rgba(18, 14, 29, .94); background: rgba(18, 14, 29, .94);
+17
View File
@@ -131,6 +131,18 @@
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button> <button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p> <p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
</section> </section>
<!-- ======================================================== Chat
Am Handy ein Reiter, ab Tablet-Breite steht er rechts daneben. -->
<section id="tab-chat" class="tab chat" aria-labelledby="nav-chat">
<div class="chat-kopf">
<h3 class="abschnitt">Chat</h3>
<span class="klein">nur lesen</span>
</div>
<div id="chat-liste" class="chat-liste" role="log" aria-live="off"></div>
<p id="chat-leer" class="chat-leer">Noch keine Nachrichten.</p>
<button id="chat-runter" class="chat-runter" type="button" hidden>Neue Nachrichten ↓</button>
</section>
</main> </main>
<div id="meldung" class="meldung" role="status" aria-live="polite"></div> <div id="meldung" class="meldung" role="status" aria-live="polite"></div>
@@ -150,6 +162,11 @@
Stream Stream
<i id="nav-tts" class="punkt" hidden></i> <i id="nav-tts" class="punkt" hidden></i>
</button> </button>
<button id="nav-chat" data-tab="chat" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/></svg>
Chat
<i id="nav-chat-punkt" class="punkt" hidden></i>
</button>
<button id="nav-test" data-tab="test" type="button"> <button id="nav-test" data-tab="test" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg>
Test Test
+183 -1
View File
@@ -1,4 +1,5 @@
/* Das Pult: Soundboard, Szenen, Liegestuetze, Vorlesen - am Handy. /* Das Pult: Soundboard, Szenen, Chat, Liegestuetze, Vorlesen - am Handy,
* ab Tablet-Groesse mit dem Chat daneben.
* *
* Zwei Wege zum Hub: * Zwei Wege zum Hub:
* - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei * - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei
@@ -12,6 +13,8 @@
'use strict'; 'use strict';
var $ = function (id) { return document.getElementById(id); }; var $ = function (id) { return document.getElementById(id); };
/** Gleiche Grenze wie die Media Query in pult.css. */
var geteilt = window.matchMedia('(min-width: 760px) and (min-height: 500px)');
var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau']; var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'];
var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉']; var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
@@ -86,6 +89,9 @@
// ------------------------------------------------------------ Reiter // ------------------------------------------------------------ Reiter
function zeigeReiter(name) { function zeigeReiter(name) {
// Ab Tablet-Breite steht der Chat ohnehin daneben, links bleibt dann
// Platz fuer das, womit man ihn zusammen braucht.
if (name === 'chat' && geteilt.matches) name = 'szenen';
var reiter = document.querySelectorAll('.reiter button'); var reiter = document.querySelectorAll('.reiter button');
for (var i = 0; i < reiter.length; i++) { for (var i = 0; i < reiter.length; i++) {
reiter[i].classList.toggle('an', reiter[i].dataset.tab === name); reiter[i].classList.toggle('an', reiter[i].dataset.tab === name);
@@ -94,6 +100,7 @@
for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name); for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name);
try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ } try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ }
if (name === 'stream') ladeWartend(); if (name === 'stream') ladeWartend();
if (name === 'chat') chatGesehen();
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
document.querySelector('.reiter').addEventListener('click', function (e) { document.querySelector('.reiter').addEventListener('click', function (e) {
@@ -558,6 +565,173 @@
rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); }); rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); });
}); });
// ------------------------------------------------------------ Chat
// Nur lesend. Die Nachrichten kommen ueber denselben Ereignisstrom wie bei
// der Chat-Browser-Source; das Pult schreibt nie in den Chat und braucht
// dafuer weder Bot-Konto noch weitere Rechte bei Twitch.
var CHAT_MAX = 250;
var chatListe = $('chat-liste');
/** Steht die Liste am Ende? Dann wandert sie mit, sonst nicht. */
var chatKlebt = true;
/* Wie in chat.html: dunkle Twitch-Farben aufhellen statt verwerfen. */
function lesbar(hex) {
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) return '#c9a7ff';
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16),
b = parseInt(hex.slice(5, 7), 16);
var hell = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
if (hell >= 0.5) return hex;
var f = 0.5 / Math.max(hell, 0.08);
return '#' + [r, g, b].map(function (v) {
return Math.min(255, Math.round(v * f)).toString(16).padStart(2, '0');
}).join('');
}
/** Text mit Emotes als Knoten. Ersetzt wird der Name als ganzes Wort,
* wie in client.js - Twitch liefert keine Positionen. */
function emoteKnoten(ziel, text, emotes) {
var ids = {};
(emotes || []).forEach(function (e) { if (e && e.id && e.name) ids[e.name] = e.id; });
text.split(/(\s+)/).forEach(function (wort) {
if (!wort) return;
if (!Object.prototype.hasOwnProperty.call(ids, wort)) {
ziel.appendChild(document.createTextNode(wort));
return;
}
var img = el('img', 'emote');
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
encodeURIComponent(ids[wort]) + '/default/dark/2.0';
img.alt = wort;
img.title = wort;
ziel.appendChild(img);
});
}
/** GIFs kommen mit Zeichenbereich und ersetzen ihren Platzhalter im Text. */
function textKnoten(ziel, text, gifs, emotes) {
var zeichen = Array.from(text);
var pos = 0;
(gifs || []).slice().sort(function (a, b) { return a.from - b.from; }).forEach(function (g) {
if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') return;
if (g.from > pos) emoteKnoten(ziel, zeichen.slice(pos, g.from).join(''), emotes);
var img = el('img', 'gif');
img.src = g.url;
img.alt = 'GIF';
img.onerror = function () { img.replaceWith(document.createTextNode('[GIF]')); };
ziel.appendChild(img);
pos = Math.max(pos, g.to + 1);
});
if (pos < zeichen.length) emoteKnoten(ziel, zeichen.slice(pos).join(''), emotes);
}
function uhrzeit(ts) {
var d = ts ? new Date(ts) : new Date();
if (isNaN(d.getTime())) d = new Date();
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function chatSichtbar() {
return geteilt.matches || $('tab-chat').classList.contains('an');
}
function nachUnten() {
chatListe.scrollTop = chatListe.scrollHeight;
chatKlebt = true;
$('chat-runter').hidden = true;
}
function chatGesehen() {
$('nav-chat-punkt').hidden = true;
nachUnten();
}
function chatAnhaengen(zeile, still) {
chatListe.appendChild(zeile);
while (chatListe.children.length > CHAT_MAX) chatListe.firstChild.remove();
$('chat-leer').hidden = true;
if (chatKlebt) nachUnten();
else $('chat-runter').hidden = false;
if (!still && !chatSichtbar()) $('nav-chat-punkt').hidden = false;
}
function chatNachricht(ev, still) {
if (!ev || !ev.id) return;
if (chatListe.querySelector('[data-id="' + CSS.escape(ev.id) + '"]')) return;
var z = el('div', 'cz' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : ''));
z.dataset.id = ev.id;
z.dataset.user = ev.userId || '';
z.appendChild(el('span', 'zeit', uhrzeit(ev.ts)));
(ev.badges || []).forEach(function (b) {
// Nur Twitchs eigene Bildadressen, wie in chat.html.
if (!b || !/^https:\/\/static-cdn\.jtvnw\.net\//.test(b.url)) return;
var img = el('img', 'abz');
img.src = b.url;
img.alt = b.title || '';
img.title = b.title || '';
z.appendChild(img);
});
var wer = el('span', 'wer', ev.name || ev.login || '?');
wer.style.color = lesbar(ev.color);
z.appendChild(wer);
z.appendChild(document.createTextNode(': '));
var text = el('span', 'text');
textKnoten(text, String(ev.text || ''), ev.gifs, ev.emotes);
z.appendChild(text);
chatAnhaengen(z, still);
}
/** Was im Stream passiert, als eigene Zeile zwischen den Nachrichten.
* Erstchatter und Bits fehlen bewusst: die zeigt schon die Nachricht. */
function alarmText(a) {
var n = a.name || 'Jemand';
switch (a.type) {
case 'sub': return n + ' hat abonniert' + (a.tier ? ' (Stufe ' + String(a.tier).charAt(0) + ')' : '');
case 'resub': return n + ' abonniert weiter' + (a.months ? ', seit ' + a.months + ' Monaten' : '');
case 'subgift': return n + ' verschenkt ein Abo' + (a.recipient ? ' an ' + a.recipient : '');
case 'massgift': return n + ' verschenkt ' + (a.amount || 'mehrere') + ' Abos';
case 'raid': return 'Raid von ' + n + (a.amount ? ' mit ' + a.amount + ' Zuschauern' : '');
case 'follow': return n + ' folgt jetzt';
case 'reward': return n + ' löst „' + (a.reward || 'Belohnung') + '“ ein';
default: return null;
}
}
function chatAlarm(a) {
var text = alarmText(a);
if (!text) return;
var z = el('div', 'cz system');
z.appendChild(el('span', 'zeit', uhrzeit()));
z.appendChild(document.createTextNode(text + (a.message ? ': ' + a.message : '')));
chatAnhaengen(z);
}
/** Von einem Mod geloescht: durchstreichen statt verschwinden lassen,
* damit man am Pult sieht, dass da etwas war. */
function chatLoeschen(ev) {
var zeilen = chatListe.querySelectorAll('.cz:not(.system)');
for (var i = 0; i < zeilen.length; i++) {
var z = zeilen[i];
if ((!ev.messageId && !ev.userId) ||
(ev.messageId && z.dataset.id === ev.messageId) ||
(ev.userId && z.dataset.user === ev.userId)) z.classList.add('weg');
}
}
chatListe.addEventListener('scroll', function () {
chatKlebt = chatListe.scrollHeight - chatListe.scrollTop - chatListe.clientHeight < 40;
if (chatKlebt) $('chat-runter').hidden = true;
}, { passive: true });
// Emotes und GIFs laden nach und machen die Zeile hoeher. Ohne das
// rutscht die neueste Nachricht unter den Rand.
chatListe.addEventListener('load', function () { if (chatKlebt) nachUnten(); }, true);
$('chat-runter').addEventListener('click', function () { tippen(); nachUnten(); });
geteilt.addEventListener('change', function () {
if (geteilt.matches && $('tab-chat').classList.contains('an')) zeigeReiter('szenen');
if (geteilt.matches) chatGesehen();
});
// ------------------------------------------------------------ Test // ------------------------------------------------------------ Test
function baueTest(knoepfe, demoLaeuft) { function baueTest(knoepfe, demoLaeuft) {
@@ -628,7 +802,13 @@
zeigeStream(ev.stream); zeigeStream(ev.stream);
if (ev.obs) zeigeObs(ev.obs); if (ev.obs) zeigeObs(ev.obs);
if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze); if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze);
// Nach dem Neuverbinden: was fehlt, nachtragen. Schon Gezeigtes
// bleibt stehen, sonst waere der Rueckblick bei jedem Abriss weg.
(ev.chat || []).forEach(function (m) { chatNachricht(m, true); });
break; break;
case 'chat': chatNachricht(ev, false); break;
case 'alert': chatAlarm(ev); break;
case 'clear': chatLoeschen(ev); break;
case 'stream': zeigeStream(ev); break; case 'stream': zeigeStream(ev); break;
case 'obs': zeigeObs(ev); break; case 'obs': zeigeObs(ev); break;
case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break; case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break;
@@ -684,6 +864,8 @@
// dem zuletzt benutzten. // dem zuletzt benutzten.
var gemerkt = location.hash.slice(1) || null; var gemerkt = location.hash.slice(1) || null;
if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } } if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } }
// Am Tablet ist der naheliegende Anfang Szenen neben dem Chat.
if (!gemerkt && geteilt.matches) gemerkt = 'szenen';
if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt); if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt);
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/raid-scout
Raid-Ziele finden und bewerten, Raid-Historie und Erwiderungs-Erinnerung.
Spezifikation: [docs/projects/raid-network.md](../../docs/projects/raid-network.md)
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/recap
Stream-Zusammenfassung nach Streamende als Discord-Post.
Spezifikation: [docs/projects/stream-recap.md](../../docs/projects/stream-recap.md)
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/vod-chapters
Kapitel-Timestamps aus Kategorie- und Szenenwechseln.
Spezifikation: [docs/projects/vod-chapters.md](../../docs/projects/vod-chapters.md)
-28
View File
@@ -95,34 +95,6 @@ broadcaster:
mastodon: false mastodon: false
instagramStoryImage: false instagramStoryImage: false
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
# Kanalpunkt-Belohnungen und was sie ausloesen. # Kanalpunkt-Belohnungen und was sie ausloesen.
# #
# Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein- # Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein-
+12 -13
View File
@@ -2,6 +2,9 @@
## Bild ## Bild
Stand vor dem Umzug in den Hub (ADR-0008). Raid-Scout, Auswertung,
VOD-Kapitel, Community-Gedaechtnis und Recap sind seit 2026-10-27 gestrichen.
``` ```
Twitch OBS Twitch OBS
┌──────────────────┐ ┌───────────┐ ┌──────────────────┐ ┌───────────┐
@@ -20,19 +23,15 @@
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐ ┌──────────────────────┐
│ SQLite │ │ Event-Bus │ │ SQLite │ │ Event-Bus │
│ data/db/ │ │ (in-proc + WS 4790) │ │ data/db/ │ │ (in-proc + WS 4790) │
└────────┬────────┘ └──────────┬───────────┘ └─────────────────┘ └──────────┬───────────┘
│ │ │
┌───────────┬───────┼───────────┬───────────┼───────────┬──────────┐ ┌─────────┴─────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
dashboard recap raid-scout vod-chapters community overlay- clipper overlay-server clipper-Kette
server │ (Pult, Sounds) ffmpeg, whisper
│ │ │ │
overlay-web ffmpeg overlay-web data/clips
(Browser- whisper (Browser-Source)
Source) │
data/clips
│
broadcaster
``` ```
## Prozesse ## Prozesse
+4 -17
View File
@@ -29,24 +29,11 @@ und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird **Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
automatisch angekündigt. automatisch angekündigt.
## M4 — Auswertung (Woche 5–6) ## M4 bis M6 — gestrichen
- [ ] `apps/dashboard`: Stream-Zeitachse, Abgangs-Analyse Auswertung (`apps/dashboard`), Netzwerk (`apps/raid-scout`) und Recap
- [ ] Titel-/Kategorie-Performance mit Stichprobenhinweis (`apps/recap`) sind seit 2026-10-27 gestrichen, samt Spezifikationen. Der Hub
- [ ] Kohortenansicht neu vs. wiederkehrend bleibt bei OBS-Anbindung, Overlay, Pult, Kanalpunkten und Discord.
- [ ] 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 ## Später
+2 -2
View File
@@ -260,8 +260,8 @@ in unter zwei Minuten entscheiden, welche Clips hochgeladen werden.
Der IRC-Client aus S1 liegt ohnehin da, das `first-msg`-Tag ebenso. Eine 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 Begrüßung für Erstchatter, eine für Rückkehrer nach mehr als 21 Tagen, mit
Cooldown und Obergrenze pro Stream. Details in Cooldown und Obergrenze pro Stream. (Die ausführliche Spezifikation lag in
[`projects/community-memory.md`](projects/community-memory.md). `projects/community-memory.md` und ist mit dem Community-Gedächtnis gestrichen.)
Diese Scheibe steht am Ende, weil sie unabhängig ist und notfalls entfällt. 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 Sie kostet fast nichts und ist das Einzige im MVP, das die Zuschauer direkt
+59 -12
View File
@@ -1,6 +1,6 @@
# Übergabe # Übergabe
Stand **2026-10-26**, letztes gebautes Paket 0.5.7. Wer hier weitermacht, Stand **2026-10-27**, letztes gebautes Paket 0.5.7. Wer hier weitermacht,
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
selbst gibt es das [Handbuch](handbuch/handbuch.pdf). selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
@@ -20,7 +20,7 @@ Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
| S5 Onboarding + Sichtung | offen | | | S5 Onboarding + Sichtung | offen | |
| S6 Erstchatter-Begrüßung | offen | | | S6 Erstchatter-Begrüßung | offen | |
288 Tests, alle grün. Was noch fehlt, steht vollständig in 322 Tests, alle grün. Was noch fehlt, steht vollständig in
[`offene-punkte.md`](offene-punkte.md). [`offene-punkte.md`](offene-punkte.md).
## Neu: der Dienst läuft zentral ## Neu: der Dienst läuft zentral
@@ -96,6 +96,15 @@ aus `theme.css`, `?probe=1` zum Ansehen im Browser. Die Ereignisse auf dem Bus
(`apps/overlay-server/src/bus.ts`) sind die Schnittstelle, an der sich die (`apps/overlay-server/src/bus.ts`) sind die Schnittstelle, an der sich die
neuen Seiten festhalten können. neuen Seiten festhalten können.
**Chat-Abzeichen sind seit 2026-10-27 Twitchs echte Bilder** statt farbiger
Punkte. IRC liefert nur `moderator/1`, `subscriber/3012` usw.; der Hub löst das
über Helix (global und Kanal, Kanal gewinnt) zu Bildadressen auf und hängt sie
an jede Chatzeile (`packages/twitch/src/badges.ts`, Feld `badges` im
`ChatEvent`). Geladen beim Start und alle sechs Stunden, beim Ausrollen
kamen 552. Unbekanntes fällt weg, die Seite nimmt nur Adressen von
`static-cdn.jtvnw.net`. Im echten Stream noch nicht angesehen; zeigt OBS
weiter Punkte, die Browser-Source einmal aktualisieren.
Die Ereignisse kommen über Server-Sent Events statt WebSocket. Warum, und was Die Ereignisse kommen über Server-Sent Events statt WebSocket. Warum, und was
gegen die tote Browser-Source nach einem Neustart getan ist, steht in gegen die tote Browser-Source nach einem Neustart getan ist, steht in
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md). [`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
@@ -121,14 +130,27 @@ Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
## Das Pult: Handy als Stream Deck ## Das Pult: Handy als Stream Deck
Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.7, **nicht an echtem Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.7. **Seit 2026-10-27
OBS geprüft**. Auf Abdus Rechner ist nichts auszuführen: das Update kommt über hängt Abdus Stream-PC dran:** obs-websocket an, Passwort auf der
Einrichtungsseite hinterlegt, der PC (`DESKTOP-DGJ4HC6`) hat sich beim Hub
gemeldet und ist freigegeben, im Hub-Log steht `Stream-PC verbunden`. Ob
Szenenwechsel, Stummschalter, Sounds und das Anlegen der Quelle in OBS
greifen, ist damit noch nicht beobachtet. Auf Abdus Rechner ist nichts auszuführen: das Update kommt über
die tägliche Aufgabe, danach meldet sich der PC von selbst, Abdu tippt im Pult die tägliche Aufgabe, danach meldet sich der PC von selbst, Abdu tippt im Pult
einmal „Freigeben“, und die Sound-Quelle legt `ingest` selbst in OBS an. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit einmal „Freigeben“, und die Sound-Quelle legt `ingest` selbst in OBS an. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit
Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen, Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen,
Vorlese-Freigabe und den Testknöpfen. Beschreibung in Vorlese-Freigabe und den Testknöpfen. Beschreibung in
[`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult). [`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult).
**Seit 2026-10-27 mit Chat, nur lesend.** Eigener Reiter am Handy; ab
Tablet-Größe (760×500) steht der Chat fest rechts neben dem gewählten
Reiter, also etwa Szenen und Chat auf einem Bildschirm. Reiner Client-Code in
`pult.js`: der Chat kam schon immer über den Ereignisstrom des Pults, es
braucht weder neue Routen noch Rechte. Neu am Bus ist nur `ts` an der
Chatzeile, für die Uhrzeit. Geprüft im Browser mit `overlay --demo` in vier
Größen (Handy hoch und quer, iPad hoch und quer); auf einem echten Tablet
und im Stream noch nicht.
- **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf - **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf
dem Hub unter `<db>/sounds/`, gespielt wird über die Browser-Source dem Hub unter `<db>/sounds/`, gespielt wird über die Browser-Source
`abdulive Sounds`, die `ingest` selbst in OBS anlegt. `abdulive Sounds`, die `ingest` selbst in OBS anlegt.
@@ -144,6 +166,13 @@ Vorlese-Freigabe und den Testknöpfen. Beschreibung in
Sound-Quelle, Szenenwechsel, Stummschalten, Hochladen, Abspielen); 8 MB Sound-Quelle, Szenenwechsel, Stummschalten, Hochladen, Abspielen); 8 MB
Upload durch den Proxy. Ungeprüft: echtes OBS (vor allem das Anlegen der Upload durch den Proxy. Ungeprüft: echtes OBS (vor allem das Anlegen der
Quelle), iPhone/Android-Installation. Quelle), iPhone/Android-Installation.
- **Die Adresse in der Anfragekarte** stimmt: bei Abdus echter Anfrage stand
seine öffentliche Adresse da, nicht die Docker-Brücke (`172.18.0.1` kam nur
bei der Probe aus dem eigenen Netz). Sie ist reine Anzeige - die Freigabe
hängt am Schlüssel, eine wechselnde IP stört nicht, eine feste braucht er
nicht.
- Auf dem Hub stand am 2026-10-26 kurz eine Probe-Anfrage „PRUEFUNG-CLAUDE“.
Sie ist nach zehn Minuten von selbst verfallen und wurde nicht freigegeben.
## Kanalpunkte: „Nachricht vorlesen" läuft ## Kanalpunkte: „Nachricht vorlesen" läuft
@@ -237,7 +266,7 @@ aufgetragen wird er mit `abdulive discord:setup`. Wiederholbar: gefunden wird
nichts, gelöscht wird nie. nichts, gelöscht wird nie.
Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für
[Go-Live](projects/go-live-broadcaster.md), [Recap](projects/stream-recap.md) [Go-Live](projects/go-live-broadcaster.md), Recap (inzwischen gestrichen)
und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher
unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge
baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten. baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten.
@@ -351,6 +380,11 @@ npx tsc --noEmit
## Ausliefern ## Ausliefern
Zwei Wege, je nachdem was sich ändert: Hub-Teil (Overlay, Pult, Discord, Chat)
ist mit `deploy-hub.sh` sofort live, Abdu muss nichts tun. Der PC-Teil
(`ingest`) kommt nur mit einem neuen Paket an, also nach einem Versions-Tag;
ein Push auf `main` allein erreicht seinen Rechner nicht.
`build-package.ts` lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern, `build-package.ts` lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern,
packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
`abdulive update` auf Abdus Rechner ab. `abdulive update` auf Abdus Rechner ab.
@@ -358,7 +392,7 @@ packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau
ersetzt, damit die Adresse gleich bleibt. ersetzt, damit die Adresse gleich bleibt.
`.gitea/workflows/release.yml` macht das beim Tag automatisch. **Seit `.gitea/workflows/release.yml` macht das beim Tag automatisch. **Seit
2026-10-26 läuft sie** (0.5.6). Der erste Lauf (`v0.5.5`) scheiterte daran, 2026-10-26 läuft sie** (0.5.6, danach 0.5.7). Der erste Lauf (`v0.5.5`) scheiterte daran,
dass whisper.cpp seine Windows-Programme nicht mehr an `latest` hängt, sondern dass whisper.cpp seine Windows-Programme nicht mehr an `latest` hängt, sondern
an eigene `b…`-Veröffentlichungen; `githubAsset()` sucht jetzt die neueste an eigene `b…`-Veröffentlichungen; `githubAsset()` sucht jetzt die neueste
mit passendem Archiv. Unter `v0.5.5` liegt kein Paket. mit passendem Archiv. Unter `v0.5.5` liegt kein Paket.
@@ -456,7 +490,7 @@ Umlaute und das Trennzeichen `│` (U+2502) bleiben unverändert stehen.
dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von
oben. oben.
## Die drei Dinge, die Abdu tun muss ## Was Abdu tun muss
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest 1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
@@ -481,6 +515,9 @@ oben.
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren. Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach 3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
`abdulive calibrate` zur Kontrolle. `abdulive calibrate` zur Kontrolle.
4. ~~**Stream-PC im Pult freigeben.**~~ Erledigt am 2026-10-27, ebenso
obs-websocket und das Passwort aus Punkt 2. Offen aus Punkt 2 bleiben
Wiedergabepuffer, Hotkey und Audiospuren - nicht nachgeprüft.
Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
Chat anonym mit und kann nichts senden. Chat anonym mit und kann nichts senden.
@@ -509,6 +546,12 @@ in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
malt. Das war vorher nicht zu erraten. malt. Das war vorher nicht zu erraten.
**Das Pult an Abdus OBS abnehmen.** Die Freigabe ist erteilt. Jetzt nachsehen, ob die
Szene `abdulive Ton` mit der Quelle `abdulive Sounds` angelegt und in jede
Szene eingehängt ist, ob ein Sound im Stream zu hören ist und ob Szenenwechsel
und Stummschalter greifen. Das fällt mit der Abnahme von S2 zusammen, beides
braucht nur ein laufendes OBS.
**Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal **Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
`abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann `abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten `mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
@@ -518,11 +561,15 @@ Wirkung.
als Entwurf geht darin auf, die Statusleiste soll ersetzt werden. Danach die als Entwurf geht darin auf, die Statusleiste soll ersetzt werden. Danach die
Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten. Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten.
**`apps/recap` bauen.** Angefangen, aber nicht committet: `apps/recap/src/recap.ts` **Recap und die übrigen geplanten Werkzeuge sind gestrichen** (2026-10-27).
und Ergänzungen in `packages/db/src/repos.ts` liegen nur im Arbeitsstand Raid-Scout, Auswertung, VOD-Kapitel, Community-Gedächtnis, Punkteshop und
(die Typprüfung meckert dort noch über `url: undefined`). Der Webhook liegt Recap sind samt Platzhaltern, Spezifikationen und Konfigurationsabschnitten
bereit, der Hub hat die Daten. Seit 2026-10-04 wird aus einem sauberen entfernt. Der Hub schreibt keine Chatnachrichten und Zuschauerproben mehr in
Checkout ausgerollt, der halbe Recap liegt also nicht mehr im LXC. die Datenbank, gelesen haben die nur diese Werkzeuge; die Tabellen bleiben
stehen. Der angefangene Recap liegt nur im Git-Stash
`recap WIP (vor dem Rueckbau 2026-10-27)` des Rechners, auf dem er
angefangen wurde. Der Webhook `recapWebhook` steht weiter im Discord-Bauplan,
weil das Setup-Skript nie löscht.
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne **Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
+1 -7
View File
@@ -6,13 +6,7 @@ Entscheidungen, Fallstricke, Abnahmekriterien.
| # | Dokument | App | | # | Dokument | App |
|---|---|---| |---|---|---|
| — | [Ingest-Daemon](00-ingest.md) | `apps/ingest` | | — | [Ingest-Daemon](00-ingest.md) | `apps/ingest` |
| 1 | [Clip-to-Shorts](clip-shorts.md) | `apps/clipper` | | 1 | [Clip-to-Shorts](clip-shorts.md) | `apps/ingest` |
| 2 | [Go-Live-Broadcaster](go-live-broadcaster.md) | `apps/broadcaster` | | 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` | | 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
| — | [Discord-Community](discord-community.md) | `packages/discord` | | — | [Discord-Community](discord-community.md) | `packages/discord` |
-68
View File
@@ -1,68 +0,0 @@
# 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.
-76
View File
@@ -1,76 +0,0 @@
# 6 — Community-Gedächtnis (`apps/community`)
**Wirkrichtung:** Bindung. Bei 10 Zuschauern der Unterschied zwischen
"Zuschauer" und "Community".
Läuft als Modul im `overlay-server`, nicht als eigener Prozess — es braucht
dieselbe Chat-Verbindung und denselben WS-Kanal zum Overlay.
## Datenlage
Alles Nötige liegt bereits in `users` und `user_streams` (siehe
[`../03-data-model.md`](../03-data-model.md)). Dieses Tool ist überwiegend
**Auswertung und Ausgabe**, kaum neue Datenerfassung — deshalb ist es billig,
sobald der Ingest steht.
## Die drei Ausgaben
### 1. Chat-Begrüßung
Ausgelöst durch die erste Nachricht eines Users im laufenden Stream:
| Fall | Bedingung | Beispiel |
|---|---|---|
| **Erstchatter** | `first-msg`-Tag oder `users.message_count = 0` | "Oh, @name schreibt zum ersten Mal — willkommen!" |
| **Rückkehrer** | letzter Besuch > 21 Tage her | "@name, lange nicht gesehen." |
| **Meilenstein** | `streams_chatted` in {5, 10, 25, 50, 100} | "Stream Nummer 25 für @name." |
| **Stammgast** | `streams_chatted >= 5` | individuelle Begrüßung aus `users.greeting` |
| sonst | — | nichts |
Regeln, damit es nicht nervt: höchstens eine Begrüßung alle 20 s, höchstens
zehn pro Stream, `muted_greeting` respektiert, und **keine Begrüßung, wenn Abdu
in den letzten 15 s selbst gesprochen hat** (der Ingest weiß über OBS bzw. über
eine Audiopegel-Schwelle, ob gerade geredet wird) — sonst redet der Bot dem
Streamer ins Wort.
### 2. Overlay-Hinweis für Abdu
Kleines, nur für Abdu sichtbares Panel (eigene Browser-Source in der
Studio-Ansicht oder zweiter Monitor). Wenn jemand schreibt:
```
┌──────────────────────────────────┐
│ maxi_2k Stream 15 · Sub 3M │
│ zuletzt: vor 2 Tagen │
│ Notiz: studiert Maschinenbau, │
│ spielt selbst Elden Ring │
│ erste Nachricht: 04.03.2026 │
└──────────────────────────────────┘
```
**Das ist der eigentliche Wert.** Nicht der Bot, der etwas schreibt, sondern
Abdu, der weiß, mit wem er redet. Ein Bot, der "willkommen zurück" sagt, ist
Deko; ein Streamer, der "na, wie war die Klausur" sagt, bindet Menschen.
### 3. Notizen
Chat-Command für Abdu und Mods: `!notiz <name> <text>` hängt an `users.notes` an.
Im Dashboard editierbar. Das ist die einzige Datenquelle, die Handarbeit
braucht, und die mit dem höchsten Nutzen pro Aufwand.
## Datenschutz
Die DB enthält personenbezogene Daten (Chatverläufe, Notizen). Sie liegt lokal,
wird nicht geteilt und nicht in ein Repo committet (`data/` ist in `.gitignore`).
`!vergissmich` löscht auf Wunsch alle Daten eines Users bis auf die anonyme
Aggregatstatistik. Notizen enthalten nichts, was man der Person nicht ins
Gesicht sagen würde.
## Abnahmekriterien
- Ein Testkonto, das zum ersten Mal schreibt, wird als Erstchatter erkannt und
genau einmal begrüßt.
- Nach einem Namenswechsel des Testkontos bleibt die Historie erhalten
(Verknüpfung über `user_id`, nicht `login`).
- Das Overlay-Panel zeigt den Hinweis innerhalb von 500 ms nach der Nachricht.
- Zwanzig Nachrichten in fünf Sekunden erzeugen höchstens eine Begrüßung.
+1 -1
View File
@@ -5,7 +5,7 @@ ist der Ort, an dem sie zwischen den Streams noch da sind. Ohne den zweiten Ort
faengt jeder Stream bei null an. faengt jeder Stream bei null an.
Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin: Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin:
[Go-Live-Broadcaster](go-live-broadcaster.md), [Stream-Recap](stream-recap.md) [Go-Live-Broadcaster](go-live-broadcaster.md), Stream-Recap (gestrichen)
und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo
und nicht nur in einer Oberflaeche. und nicht nur in einer Oberflaeche.
-71
View File
@@ -1,71 +0,0 @@
# 8 — Punktesystem mit Bedeutung (`apps/loyalty`)
**Wirkrichtung:** Bindung. Läuft als Modul im `overlay-server`.
## Warum nicht Channel Points?
Twitch-Channel-Points funktionieren, haben aber zwei Grenzen: Man kann die
Vergabelogik nicht anpassen (keine Boni für Verhalten, das man belohnen will),
und Einlösungen sind an das Twitch-UI gebunden. Eigene Punkte laufen daneben.
**Beides parallel betreiben.** Channel Points bleiben für simple Sachen,
eigene Punkte für alles, was Logik braucht. `channel.channel_points_custom_reward_redemption.add`
wird ohnehin schon ingested, also können beide Systeme dieselben Aktionen
auslösen.
## Vergabe
| Anlass | Punkte |
|---|---|
| pro 10 Minuten Anwesenheit | 10 |
| ...davon aktiv im Chat | +5 |
| erste Nachricht des Streams | 25 |
| Anwesenheit in Streamminute 1–10 | +50 (belohnt pünktliches Erscheinen) |
| Follow | 200 (einmalig) |
| Sub / Resub | 500 |
| Raid rein | 300 an den Raider |
| Clip, der übernommen wird | 250 an den Auslöser |
Anwesenheit kommt aus `user_streams.minutes` (Helix-Chatter-Liste alle 5 min),
nicht aus dem Chat allein — sonst bekommen stille Zuschauer nie etwas.
## Shop
Der Punkt der Sache ist, dass Einlösungen **wirklich etwas tun**:
| Artikel | Kosten | Aktion | Freigabe |
|---|---|---|---|
| Streamtitel für 10 min | 5.000 | `PATCH /helix/channels`, Timer setzt zurück | ja |
| Emote-Regen 30 s | 800 | Overlay-Effekt | nein |
| Sound aus dem Board | 300 | Overlay + Audio | nein |
| Challenge für Abdu | 3.000 | Karte im Overlay, Abdu bestätigt | ja |
| Nächstes Spiel bestimmen | 10.000 | Eintrag in die Warteschlange | ja |
| Facecam-Filter 60 s | 1.500 | obs-websocket Filter an | nein |
`requires_approval` ist bei allem gesetzt, was den Stream nach außen verändert
(Titel!) oder Abdu zu etwas verpflichtet. Titeländerung zusätzlich abgesichert:
Wortfilter, Längenbegrenzung, automatische Rückkehr zum Originaltitel per
Timer, und der Originaltitel wird vorher in `stream_segments` gesichert.
## Rückerstattung
Wenn eine Aktion fehlschlägt (OBS nicht erreichbar, Helix-Fehler, Abdu lehnt
ab), werden die Punkte automatisch zurückgebucht und der User im Chat
informiert. `redemptions.status = 'refunded'`.
## Bilanz-Integrität
Punkte werden nie direkt auf `users.points` geschrieben, sondern als Zeilen in
einer `points_ledger`-Tabelle geführt; `users.points` ist ein
Materialized-Summenfeld, das über einen Trigger aktualisiert wird. So ist jede
Punktzahl nachvollziehbar und ein Fehler in der Vergabelogik reparierbar, ohne
dass jemandem grundlos Punkte fehlen.
## Abnahmekriterien
- Ein Zuschauer, der 30 min anwesend ist und dreimal schreibt, hat exakt die
erwartete Punktzahl.
- Titel-Einlösung ändert den Titel und setzt ihn nach 10 min zurück, auch wenn
der Server dazwischen neu startet (Timer wird persistiert).
- Fehlgeschlagene Einlösung bucht zurück.
- `points_ledger`-Summe stimmt mit `users.points` überein.
-64
View File
@@ -1,64 +0,0 @@
# 3 — Raid- und Netzwerk-Tool (`apps/raid-scout`)
**Wirkrichtung:** Reichweite über Menschen statt über Algorithmen. In dieser
Kanalgröße die zuverlässigste Quelle für neue Stammzuschauer.
## Kandidaten finden
`GET /helix/streams?game_id=<aktuelle Kategorie>&first=100&language=de`,
seitenweise bis die Viewerzahl unter die Untergrenze fällt (Twitch sortiert
absteigend, also von hinten interessant). Filter:
- Viewer zwischen `min` und `max` (Default 5–30, konfigurierbar)
- Sprache `de`
- Uptime mindestens 30 min und höchstens 5 h — wer gerade erst angefangen hat,
verliert durch einen Raid nichts, wer gleich aufhört, kann nichts damit
anfangen
- nicht auf der Sperrliste (`config/raid-blocklist.yaml`)
## Bewertung
```
score = 0.35 * viewerNaehe (Ziel: aehnliche Groesse wie wir)
+ 0.25 * kategorieUeberlapp (gleiche Kategorie > gleiche Sparte)
+ 0.20 * beziehung (schon mal Kontakt gehabt? zurueckgeraidet?)
+ 0.10 * frische (lange nicht mehr geraidet)
+ 0.10 * uptimeFenster
```
`beziehung` kommt aus der `raids`-Tabelle: Wer uns geraidet hat und noch keinen
Raid zurückbekommen hat, steht ganz oben. **Das ist der eigentliche Kern des
Tools** — nicht das Finden von Fremden, sondern das Nicht-Vergessen von
Leuten, die schon Kontakt aufgenommen haben.
## Oberfläche
- **Chat-Command `!raidziel`** (nur Mods/Broadcaster): Bot postet die Top 3 mit
Login, Viewerzahl und Kategorie in den Chat.
- **Overlay-Panel**, das Abdu am Streamende einblenden kann: Top 5 als Karten,
Klick kopiert `/raid <login>` in die Zwischenablage. Bewusst **kein
automatisches Auslösen** — ein Raid ist eine soziale Geste, die einen
Menschen braucht.
- **Dashboard-Seite** mit der vollständigen Raid-Historie.
## Netzwerk-Gedächtnis
Für jeden Kanal, mit dem es je Kontakt gab:
| Feld | Quelle |
|---|---|
| Raids rein / raus, mit Datum und Viewerzahl | `channel.raid` EventSub |
| erwidert ja/nein | berechnet |
| Notizen | manuell im Dashboard |
| letzter Kontakt | berechnet |
Wöchentliche Erinnerung im Dashboard: "Diese 3 haben dich geraidet, du sie noch
nicht."
## Abnahmekriterien
- `!raidziel` liefert im laufenden Stream innerhalb von 3 s drei plausible
Kanäle in der aktuellen Kategorie.
- Ein eingehender Raid landet automatisch in `raids` mit `direction='in'`.
- Ein ausgehender Raid (manuell in Twitch ausgelöst) wird ebenfalls erfasst.
- Kanäle auf der Sperrliste tauchen nie auf.
-67
View File
@@ -1,67 +0,0 @@
# 9 — Stream-Recap (`apps/recap`)
**Wirkrichtung:** Bindung. Gibt Leuten einen Grund, im Discord zu sein, und
hält den Kanal zwischen zwei Streams präsent.
## Ablauf
`stream.offline` → 10 Minuten warten (VOD-Verarbeitung, letzte Clips rendern)
→ Zahlen aus der DB ziehen → Discord-Post → optional Bluesky-Kurzfassung.
## Inhalt
```
🔴 Stream vom 09.09. · 3 h 42 min · Elden Ring, Just Chatting
Peak 17 Zuschauer · Schnitt 11
23 Leute im Chat, davon 4 zum ersten Mal
+6 Follower
Top-Chatter
1. maxi_2k 84 Nachrichten
2. lenaaa 61
3. der_olli 47
Willkommen im Chat 👋
@neuer1 @neuer2 @neuer3 @neuer4
Beste Momente
▸ "Malenia Versuch 41" clips.twitch.tv/...
▸ "Der Hund im Hintergrund" clips.twitch.tv/...
VOD-Kapitel
00:00 Start
00:14 Elden Ring
01:32 Pause
...
Nächster Stream: Mittwoch 20 Uhr
```
## Regeln, damit es gelesen wird
- **Namen vor Zahlen.** Der Teil, der Leute in den Discord bringt, ist die
Liste mit ihren Namen darin — nicht die Peak-Viewer-Zahl. Erstchatter werden
namentlich begrüßt, Top-Chatter genannt.
- **Keine schlechten Streams schönreden, aber auch nicht ausstellen.** Wenn Peak
unter der Hälfte des Schnitts der letzten fünf Streams liegt, entfallen die
Viewer-Zahlen und der Post konzentriert sich auf Chat und Momente. Öffentlich
schwache Zahlen zu posten schadet mehr, als es nützt; im Dashboard stehen sie
trotzdem.
- **Kein Post ohne Inhalt.** Streams unter 20 Minuten oder ohne Chat-Aktivität
erzeugen keinen Recap.
- **Clips einbetten, nicht verlinken.** Discord bettet Twitch-Clips ab; die
besten zwei nach `clips.score`.
## Vergleich zur Vorwoche
Zweite Zeile, klein: "Chat +18 % ggü. letzten 4 Streams". Nur bei mindestens
vier Vergleichsstreams, sonst weggelassen — Prozentangaben auf Basis von zwei
Datenpunkten sind Unsinn.
## Abnahmekriterien
- Der Post erscheint innerhalb von 15 min nach Streamende.
- Alle Zahlen stimmen mit dem Dashboard überein.
- Ein Stream ohne Chat erzeugt keinen Post.
- Clip-Einbettungen funktionieren in Discord (nicht nur als nackte URL).
-54
View File
@@ -1,54 +0,0 @@
# 5 — VOD-Kapitel (`apps/vod-chapters`)
**Wirkrichtung:** Steuerung und ein wenig Reichweite (YouTube-Zweitverwertung).
## Was aufgezeichnet wird
Während des Streams sammelt der Ingest ohnehin schon:
| Signal | Quelle | wird Kapitel? |
|---|---|---|
| Kategoriewechsel | EventSub `channel.update` | immer |
| Titelwechsel | EventSub `channel.update` | wenn Kategorie gleich bleibt |
| OBS-Szenenwechsel | obs-websocket | nur bei Szenen aus der Whitelist |
| Stream-Marker | `POST /helix/streams/markers` | immer |
| erstellter Clip | `clips` | als Kapitel-Kandidat |
OBS-Szenen sind das rauschendste Signal — ein Wechsel auf "BRB" und zurück sind
zwei Wechsel, die kein Kapitel sind. Deshalb Whitelist in `config/obs-scenes.yaml`
plus Mindestabstand von 5 Minuten zwischen Kapiteln; kürzere Segmente werden mit
dem vorherigen verschmolzen.
## Ausgabe
Nach `stream.offline`: VOD über `GET /helix/videos?user_id=&type=archive&first=1`
holen, Zeitstempel relativ zu `streams.started_at` umrechnen, Text erzeugen:
```
00:00 Start & Begrüßung
00:14 Elden Ring — Malenia, Versuch 40
01:32 Pause
01:47 Elden Ring — sie ist tot
02:55 Just Chatting — Community-Fragen
```
Format wählbar: Twitch-VOD-Beschreibung, YouTube-Kapitel (identisch, muss aber
bei `00:00` beginnen und mindestens drei Einträge mit je mindestens 10 s haben,
sonst zeigt YouTube keine Kapitel an), Discord-Nachricht.
Twitch erlaubt kein Setzen der VOD-Beschreibung über die API — der Text landet
deshalb in `data/vods/<datum>/chapters.txt` und wird per Push-Nachricht
zugestellt, plus als Block im Recap-Post. Einfügen dauert zehn Sekunden.
## Kapitel-Titel
Der Rohtitel ist der Twitch-Titel bzw. der Kategoriename. Optional (Ausbaustufe 2)
eine Verdichtung aus dem Chat: die häufigsten Substantive im Segment als
Zusatz. Erst sinnvoll, wenn genug Chat-Volumen da ist.
## Abnahmekriterien
- Ein Testtream mit zwei Kategoriewechseln und drei Szenenwechseln erzeugt
genau die erwarteten Kapitel.
- Zeitstempel weichen vom VOD um weniger als 5 s ab.
- Kein Kapitel unter 5 Minuten Länge.
+1 -1
View File
@@ -22,7 +22,7 @@ function msg(over: Partial<ChatMessage> = {}): ChatMessage {
ts: '2026-09-09T20:00:00.000Z', ts: '2026-09-09T20:00:00.000Z',
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k', userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false, text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false,
isCommand: false, isMod: false, isSub: false, isBroadcaster: false, isCommand: false, isMod: false, isSub: false, isBroadcaster: false, badges: [],
bits: 0, color: null, ...over, bits: 0, color: null, ...over,
}; };
} }
+2
View File
@@ -41,6 +41,8 @@ export type ChatMessage = {
isMod: boolean; isMod: boolean;
isSub: boolean; isSub: boolean;
isBroadcaster: boolean; isBroadcaster: boolean;
/** Twitchs Abzeichen in Anzeigereihenfolge, z. B. moderator/1, subscriber/3012. */
badges: Array<{ set: string; version: string }>;
bits: number; bits: number;
color: string | null; color: string | null;
}; };
+23
View File
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { badgeKarte, badgesAufloesen } from './badges.ts';
const v = (id: string, url: string) =>
({ id, title: url, image_url_1x: '', image_url_2x: url, image_url_4x: '' });
test('Kanal-Abzeichen ueberdecken die globalen', () => {
const karte = badgeKarte(
[{ set_id: 'subscriber', versions: [v('0', 'global-sub')] }, { set_id: 'moderator', versions: [v('1', 'mod')] }],
[{ set_id: 'subscriber', versions: [v('0', 'kanal-sub'), v('3012', 'kanal-sub-t3')] }],
);
assert.deepEqual(
badgesAufloesen(karte, [{ set: 'moderator', version: '1' }, { set: 'subscriber', version: '0' }])
.map((b) => b.url),
['mod', 'kanal-sub'],
);
assert.equal(badgesAufloesen(karte, [{ set: 'subscriber', version: '3012' }])[0]?.url, 'kanal-sub-t3');
});
test('unbekannte Abzeichen fallen weg', () => {
assert.deepEqual(badgesAufloesen(new Map(), [{ set: 'vip', version: '1' }]), []);
});
+55
View File
@@ -0,0 +1,55 @@
// Abzeichen im Chat als Bilder. IRC liefert nur `subscriber/3012`, das Bild
// dazu kennt nur Helix. Die Kanal-Abzeichen (eigene Abo-Stufen) ueberdecken
// die globalen gleichen Namens - so zeigt Twitch sie auch an.
import { logger } from '#core/logger.ts';
import { helix, type HelixBadgeSet } from './helix.ts';
const log = logger('badges');
export type BadgeBild = { url: string; title: string };
export type BadgeKarte = Map<string, BadgeBild>;
export function badgeKarte(global: HelixBadgeSet[], kanal: HelixBadgeSet[]): BadgeKarte {
const karte: BadgeKarte = new Map();
for (const set of [...global, ...kanal]) {
for (const v of set.versions) {
// 2x ist 36 px: bei 19 px Schrift im Overlay scharf, auch skaliert.
karte.set(`${set.set_id}/${v.id}`, { url: v.image_url_2x, title: v.title });
}
}
return karte;
}
/** Unbekannte Abzeichen fallen weg, statt als kaputtes Bild zu erscheinen. */
export function badgesAufloesen(
karte: BadgeKarte, badges: Array<{ set: string; version: string }>,
): BadgeBild[] {
return badges.flatMap((b) => {
const bild = karte.get(`${b.set}/${b.version}`);
return bild ? [bild] : [];
});
}
export function createBadges(): {
laden: (broadcasterId: string | null) => Promise<void>;
aufloesen: (badges: Array<{ set: string; version: string }>) => BadgeBild[];
} {
let karte: BadgeKarte = new Map();
return {
async laden(broadcasterId) {
try {
const [global, kanal] = await Promise.all([
helix.chatBadges(),
broadcasterId ? helix.chatBadges(broadcasterId) : Promise.resolve([]),
]);
karte = badgeKarte(global, kanal);
log.info(`Abzeichen geladen: ${karte.size}`);
} catch (err) {
// Die alte Karte bleibt stehen. Ohne Karte zeigt der Chat eben keine.
log.warn(`Abzeichen nicht geladen: ${(err as Error).message}`);
}
},
aufloesen: (badges) => badgesAufloesen(karte, badges),
};
}
+17
View File
@@ -47,6 +47,11 @@ export type HelixStream = {
title: string; viewer_count: number; started_at: string; language: string; thumbnail_url: string; title: string; viewer_count: number; started_at: string; language: string; thumbnail_url: string;
}; };
export type HelixBadgeSet = {
set_id: string;
versions: Array<{ id: string; title: string; image_url_1x: string; image_url_2x: string; image_url_4x: string }>;
};
export type HelixUser = { id: string; login: string; display_name: string }; export type HelixUser = { id: string; login: string; display_name: string };
export type HelixReward = { export type HelixReward = {
@@ -220,6 +225,18 @@ export const helix = {
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope); return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
}, },
/**
* Abzeichen-Bilder, ohne `broadcasterId` die globalen (Moderator, VIP,
* Broadcaster ...), mit die des Kanals (Abo-Stufen, Bits). Ein App-Token
* reicht fuer beides.
*/
async chatBadges(broadcasterId?: string): Promise<HelixBadgeSet[]> {
const r = await call<{ data: HelixBadgeSet[] }>('broadcaster',
broadcasterId ? `/chat/badges?broadcaster_id=${broadcasterId}` : '/chat/badges/global',
{}, { appOk: true });
return r.data;
},
async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> { async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> {
const r = await call<{ data: Array<{ id: string; edit_url: string }> }>( const r = await call<{ data: Array<{ id: string; edit_url: string }> }>(
'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' }); 'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' });
+10 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseLine, toChatMessage, parseGifs } from './irc.ts'; import { parseLine, toChatMessage, parseGifs, parseBadges } from './irc.ts';
const LINE = const LINE =
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' + '@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' +
@@ -28,6 +28,9 @@ test('uebersetzt in eine ChatMessage', () => {
assert.ok(msg); assert.ok(msg);
assert.equal(msg.userId, '98765'); assert.equal(msg.userId, '98765');
assert.equal(msg.login, 'nic_ooo'); assert.equal(msg.login, 'nic_ooo');
assert.deepEqual(msg.badges, [
{ set: 'broadcaster', version: '1' }, { set: 'subscriber', version: '6' },
]);
assert.equal(msg.displayName, 'Nic_OOo'); assert.equal(msg.displayName, 'Nic_OOo');
assert.equal(msg.isBroadcaster, true); assert.equal(msg.isBroadcaster, true);
assert.equal(msg.isSub, true); assert.equal(msg.isSub, true);
@@ -104,3 +107,9 @@ test('unvollstaendige oder unsinnige Tags werden verworfen', () => {
assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`); assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`);
} }
}); });
test('Abzeichen: leer und kaputt ergeben nichts', () => {
assert.deepEqual(parseBadges(undefined), []);
assert.deepEqual(parseBadges(''), []);
assert.deepEqual(parseBadges('vip/1,kaputt'), [{ set: 'vip', version: '1' }]);
});
+10
View File
@@ -111,6 +111,15 @@ function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes
return out; return out;
} }
/** `broadcaster/1,subscriber/3012` -> [{ set, version }], Reihenfolge wie bei Twitch. */
export function parseBadges(tag: string | undefined): Array<{ set: string; version: string }> {
if (!tag) return [];
return tag.split(',').flatMap((teil) => {
const [set, version] = teil.split('/');
return set && version ? [{ set, version }] : [];
});
}
export function toChatMessage(m: IrcMessage): ChatMessage | null { export function toChatMessage(m: IrcMessage): ChatMessage | null {
if (m.command !== 'PRIVMSG') return null; if (m.command !== 'PRIVMSG') return null;
const text = m.params[1] ?? ''; const text = m.params[1] ?? '';
@@ -133,6 +142,7 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null {
isMod: m.tags['mod'] === '1' || badges.includes('broadcaster'), isMod: m.tags['mod'] === '1' || badges.includes('broadcaster'),
isSub: m.tags['subscriber'] === '1', isSub: m.tags['subscriber'] === '1',
isBroadcaster: badges.includes('broadcaster'), isBroadcaster: badges.includes('broadcaster'),
badges: parseBadges(badges),
bits: Number(m.tags['bits'] ?? 0), bits: Number(m.tags['bits'] ?? 0),
color: m.tags['color'] || null, color: m.tags['color'] || null,
}; };