Compare commits

..
2 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
28 changed files with 346 additions and 534 deletions

No files matched your search

+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)
+3 -7
View File
@@ -269,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',
@@ -291,6 +289,7 @@ export async function runHub(): Promise<void> {
broadcaster: msg.isBroadcaster, broadcaster: msg.isBroadcaster,
badges: badges.aufloesen(msg.badges), badges: badges.aufloesen(msg.badges),
bits: msg.bits, bits: msg.bits,
ts: msg.ts,
}); });
if (msg.isFirst) { if (msg.isFirst) {
@@ -566,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);
} }
} }
-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)
+5
View File
@@ -32,6 +32,11 @@ export type ChatEvent = {
/** Abzeichen als fertige Bilder, aufgeloest im Hub (#twitch/badges.ts). */ /** Abzeichen als fertige Bilder, aufgeloest im Hub (#twitch/badges.ts). */
badges: Array<{ url: string; title: string }>; 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 =
+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.
+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-
+11 -12
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
server │
│ │
overlay-web ffmpeg
(Browser- whisper
Source) │
data/clips
│ │
broadcaster ┌─────────┴─────────┐
▼ ▼
overlay-server clipper-Kette
(Pult, Sounds) ffmpeg, whisper
│ │
overlay-web data/clips
(Browser-Source)
``` ```
## 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
+19 -6
View File
@@ -142,6 +142,15 @@ 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.
@@ -257,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.
@@ -552,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.