Compare commits
2
Commits
9c64309c51
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00550aa37 | ||
|
|
227ecfdcfe |
No files matched your search
@@ -11,23 +11,18 @@ Betrieb über Docker Compose.
|
||||
Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese
|
||||
Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**.
|
||||
|
||||
## Die neun Tools
|
||||
## Was es gibt
|
||||
|
||||
| # | Tool | Ordner | Zweck | Status |
|
||||
|---|------|--------|-------|--------|
|
||||
| 1 | Clip-to-Shorts | `apps/clipper` | Clips → 9:16 mit Untertiteln, Upload-fertig | geplant |
|
||||
| 2 | Go-Live-Broadcaster | `apps/broadcaster` | Stream-Start → Discord/Bluesky/Instagram | geplant |
|
||||
| 3 | Raid- & Netzwerk-Tool | `apps/raid-scout` | Raid-Ziele finden, Raid-Historie führen | geplant |
|
||||
| 4 | Analytics-Dashboard | `apps/dashboard` | Viewer vs. Chat, Titel-Performance, Retention | geplant |
|
||||
| 5 | VOD-Kapitel | `apps/vod-chapters` | Szenen-/Kategoriewechsel → Timestamps | geplant |
|
||||
| 6 | Community-Gedächtnis | `apps/community` | Chatter-Profile, Begrüßungen, Overlay-Hinweise | geplant |
|
||||
| 7 | Chat steuert Dinge | `apps/overlay-server` + `apps/overlay-web` | Overlay, Soundboard, Minigames, Umfragen | geplant |
|
||||
| 8 | Punktesystem + Shop | `apps/loyalty` | Punkte mit echten Konsequenzen | geplant |
|
||||
| 9 | Stream-Recap | `apps/recap` | Zusammenfassung nach Streamende in Discord | geplant |
|
||||
| Teil | Ordner | Zweck |
|
||||
|------|--------|-------|
|
||||
| Hub | `apps/hub` | Läuft zentral im LXC: Chat, Overlay für OBS, Pult, Kanalpunkte, Discord, Go-Live-Ping |
|
||||
| Overlay und Pult | `apps/overlay-server` + `apps/overlay-web` | Browser-Sources für OBS, Pult am Handy und Tablet |
|
||||
| Go-Live-Ping | `apps/broadcaster` | Stream-Start → Discord |
|
||||
| Ingest | `apps/ingest` | Läuft auf dem Stream-PC: OBS-Anbindung, `!clip`, 9:16-Render mit Untertiteln |
|
||||
|
||||
Dazu der Dienst, der unter allen anderen liegt:
|
||||
|
||||
| — | **Ingest-Daemon** | `apps/ingest` | IRC + Helix → DB, Einrichtungsseite | **läuft** |
|
||||
Die früher geplanten Werkzeuge (Raid-Scout, Auswertung, VOD-Kapitel,
|
||||
Community-Gedächtnis, Punkteshop, Recap) sind gestrichen und mit ihren
|
||||
Spezifikationen entfernt.
|
||||
|
||||
## Doku
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,5 +0,0 @@
|
||||
# @abdulive/dashboard
|
||||
|
||||
Analytics-Dashboard und Freigabe-Ansicht fuer Clip-Kandidaten.
|
||||
|
||||
Spezifikation: [docs/projects/analytics.md](../../docs/projects/analytics.md)
|
||||
@@ -269,11 +269,9 @@ export async function runHub(): Promise<void> {
|
||||
},
|
||||
onMessage: (msg) => {
|
||||
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++;
|
||||
repo.event('chat.message', {
|
||||
text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits,
|
||||
}, state.streamId, msg.userId, msg.ts);
|
||||
|
||||
bus.publish({
|
||||
kind: 'chat',
|
||||
@@ -291,6 +289,7 @@ export async function runHub(): Promise<void> {
|
||||
broadcaster: msg.isBroadcaster,
|
||||
badges: badges.aufloesen(msg.badges),
|
||||
bits: msg.bits,
|
||||
ts: msg.ts,
|
||||
});
|
||||
|
||||
if (msg.isFirst) {
|
||||
@@ -566,9 +565,6 @@ async function pollStream(
|
||||
state.startedLiveAt = live.started_at;
|
||||
state.lastSeenLive = now;
|
||||
state.viewers = live.viewer_count;
|
||||
if (state.streamId) {
|
||||
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
|
||||
}
|
||||
sendState(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -32,6 +32,11 @@ export type ChatEvent = {
|
||||
/** Abzeichen als fertige Bilder, aufgeloest im Hub (#twitch/badges.ts). */
|
||||
badges: Array<{ url: string; title: string }>;
|
||||
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 =
|
||||
|
||||
@@ -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. |
|
||||
| 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“. |
|
||||
| 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. |
|
||||
|
||||
**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
|
||||
offen ist.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "abdulive 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",
|
||||
"id": "/pult",
|
||||
"start_url": "/pult",
|
||||
@@ -16,6 +16,7 @@
|
||||
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
|
||||
{ "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" }] }
|
||||
]
|
||||
}
|
||||
@@ -327,11 +327,85 @@ main {
|
||||
.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; }
|
||||
|
||||
/* ------------------------------------------------------------ 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 {
|
||||
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));
|
||||
padding-bottom: var(--unten);
|
||||
background: rgba(18, 14, 29, .94);
|
||||
|
||||
@@ -131,6 +131,18 @@
|
||||
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
|
||||
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
|
||||
</section>
|
||||
|
||||
<!-- ======================================================== 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>
|
||||
|
||||
<div id="meldung" class="meldung" role="status" aria-live="polite"></div>
|
||||
@@ -150,6 +162,11 @@
|
||||
Stream
|
||||
<i id="nav-tts" class="punkt" hidden></i>
|
||||
</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">
|
||||
<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
|
||||
|
||||
@@ -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:
|
||||
* - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei
|
||||
@@ -12,6 +13,8 @@
|
||||
'use strict';
|
||||
|
||||
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 SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
|
||||
|
||||
@@ -86,6 +89,9 @@
|
||||
// ------------------------------------------------------------ Reiter
|
||||
|
||||
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');
|
||||
for (var i = 0; i < reiter.length; i++) {
|
||||
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);
|
||||
try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ }
|
||||
if (name === 'stream') ladeWartend();
|
||||
if (name === 'chat') chatGesehen();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
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'); });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ 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
|
||||
|
||||
function baueTest(knoepfe, demoLaeuft) {
|
||||
@@ -628,7 +802,13 @@
|
||||
zeigeStream(ev.stream);
|
||||
if (ev.obs) zeigeObs(ev.obs);
|
||||
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;
|
||||
case 'chat': chatNachricht(ev, false); break;
|
||||
case 'alert': chatAlarm(ev); break;
|
||||
case 'clear': chatLoeschen(ev); break;
|
||||
case 'stream': zeigeStream(ev); break;
|
||||
case 'obs': zeigeObs(ev); break;
|
||||
case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break;
|
||||
@@ -684,6 +864,8 @@
|
||||
// dem zuletzt benutzten.
|
||||
var gemerkt = location.hash.slice(1) || null;
|
||||
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 ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -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)
|
||||
@@ -1,5 +0,0 @@
|
||||
# @abdulive/recap
|
||||
|
||||
Stream-Zusammenfassung nach Streamende als Discord-Post.
|
||||
|
||||
Spezifikation: [docs/projects/stream-recap.md](../../docs/projects/stream-recap.md)
|
||||
@@ -1,5 +0,0 @@
|
||||
# @abdulive/vod-chapters
|
||||
|
||||
Kapitel-Timestamps aus Kategorie- und Szenenwechseln.
|
||||
|
||||
Spezifikation: [docs/projects/vod-chapters.md](../../docs/projects/vod-chapters.md)
|
||||
@@ -95,34 +95,6 @@ broadcaster:
|
||||
mastodon: 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.
|
||||
#
|
||||
# Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein-
|
||||
|
||||
+11
-12
@@ -2,6 +2,9 @@
|
||||
|
||||
## 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
|
||||
┌──────────────────┐ ┌───────────┐
|
||||
@@ -20,19 +23,15 @@
|
||||
┌─────────────────┐ ┌──────────────────────┐
|
||||
│ SQLite │ │ Event-Bus │
|
||||
│ data/db/ │ │ (in-proc + WS 4790) │
|
||||
└────────┬────────┘ └──────────┬───────────┘
|
||||
│ │
|
||||
┌───────────┬───────┼───────────┬───────────┼───────────┬──────────┐
|
||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||
dashboard recap raid-scout vod-chapters community overlay- clipper
|
||||
server │
|
||||
│ │
|
||||
overlay-web ffmpeg
|
||||
(Browser- whisper
|
||||
Source) │
|
||||
data/clips
|
||||
└─────────────────┘ └──────────┬───────────┘
|
||||
│
|
||||
broadcaster
|
||||
┌─────────┴─────────┐
|
||||
▼ ▼
|
||||
overlay-server clipper-Kette
|
||||
(Pult, Sounds) ffmpeg, whisper
|
||||
│ │
|
||||
overlay-web data/clips
|
||||
(Browser-Source)
|
||||
```
|
||||
|
||||
## Prozesse
|
||||
|
||||
+4
-17
@@ -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
|
||||
automatisch angekündigt.
|
||||
|
||||
## M4 — Auswertung (Woche 5–6)
|
||||
## M4 bis M6 — gestrichen
|
||||
|
||||
- [ ] `apps/dashboard`: Stream-Zeitachse, Abgangs-Analyse
|
||||
- [ ] Titel-/Kategorie-Performance mit Stichprobenhinweis
|
||||
- [ ] Kohortenansicht neu vs. wiederkehrend
|
||||
- [ ] Betriebsstatus-Seite
|
||||
- [ ] Spike-Detektor an echten Daten kalibrieren und scharf schalten
|
||||
|
||||
## M5 — Netzwerk (Woche 7)
|
||||
|
||||
- [ ] `apps/raid-scout`: Kandidatensuche, Scoring, `!raidziel`
|
||||
- [ ] Raid-Historie und Erwiderungs-Erinnerung
|
||||
- [ ] Overlay-Panel für das Streamende
|
||||
|
||||
## M6 — Recap (Woche 8)
|
||||
|
||||
- [ ] `apps/recap`: Discord-Post nach Streamende
|
||||
- [ ] Clip-Einbettung, Erstchatter-Begrüßung, Wochenvergleich
|
||||
Auswertung (`apps/dashboard`), Netzwerk (`apps/raid-scout`) und Recap
|
||||
(`apps/recap`) sind seit 2026-10-27 gestrichen, samt Spezifikationen. Der Hub
|
||||
bleibt bei OBS-Anbindung, Overlay, Pult, Kanalpunkten und Discord.
|
||||
|
||||
## Später
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
Begrüßung für Erstchatter, eine für Rückkehrer nach mehr als 21 Tagen, mit
|
||||
Cooldown und Obergrenze pro Stream. Details in
|
||||
[`projects/community-memory.md`](projects/community-memory.md).
|
||||
Cooldown und Obergrenze pro Stream. (Die ausführliche Spezifikation lag in
|
||||
`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.
|
||||
Sie kostet fast nichts und ist das Einzige im MVP, das die Zuschauer direkt
|
||||
|
||||
+19
-6
@@ -142,6 +142,15 @@ Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen,
|
||||
Vorlese-Freigabe und den Testknöpfen. Beschreibung in
|
||||
[`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult).
|
||||
|
||||
**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
|
||||
dem Hub unter `<db>/sounds/`, gespielt wird über die Browser-Source
|
||||
`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.
|
||||
|
||||
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
|
||||
unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge
|
||||
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
|
||||
Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten.
|
||||
|
||||
**`apps/recap` bauen.** Angefangen, aber nicht committet: `apps/recap/src/recap.ts`
|
||||
und Ergänzungen in `packages/db/src/repos.ts` liegen nur im Arbeitsstand
|
||||
(die Typprüfung meckert dort noch über `url: undefined`). Der Webhook liegt
|
||||
bereit, der Hub hat die Daten. Seit 2026-10-04 wird aus einem sauberen
|
||||
Checkout ausgerollt, der halbe Recap liegt also nicht mehr im LXC.
|
||||
**Recap und die übrigen geplanten Werkzeuge sind gestrichen** (2026-10-27).
|
||||
Raid-Scout, Auswertung, VOD-Kapitel, Community-Gedächtnis, Punkteshop und
|
||||
Recap sind samt Platzhaltern, Spezifikationen und Konfigurationsabschnitten
|
||||
entfernt. Der Hub schreibt keine Chatnachrichten und Zuschauerproben mehr in
|
||||
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
|
||||
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
||||
|
||||
@@ -6,13 +6,7 @@ Entscheidungen, Fallstricke, Abnahmekriterien.
|
||||
| # | Dokument | App |
|
||||
|---|---|---|
|
||||
| — | [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` |
|
||||
| 3 | [Raid- und Netzwerk-Tool](raid-network.md) | `apps/raid-scout` |
|
||||
| 4 | [Analytics-Dashboard](analytics.md) | `apps/dashboard` |
|
||||
| 5 | [VOD-Kapitel](vod-chapters.md) | `apps/vod-chapters` |
|
||||
| 6 | [Community-Gedaechtnis](community-memory.md) | `apps/community` |
|
||||
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
|
||||
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
|
||||
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
|
||||
| — | [Discord-Community](discord-community.md) | `packages/discord` |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
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 nicht nur in einer Oberflaeche.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user