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

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

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

No files matched your search

+3
View File
@@ -32,3 +32,6 @@ docs/handbuch/*.log
docs/handbuch/*.out
docs/handbuch/*.toc
docs/handbuch/*.png
# Mitschriften der Browser-Automatisierung
.playwright-mcp/
@@ -1,93 +0,0 @@
[ 54050ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_RESET @ http://127.0.0.1:4791/events?quelle=pult:0
[ 54054ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 55062ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 55068ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 56069ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 56071ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 57087ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 57089ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 58102ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 58104ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 59105ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 59107ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 60048ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 60109ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 60111ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 61142ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 61160ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 62276ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 62524ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 63583ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 63620ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 64985ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 65532ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 66017ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 66032ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 67490ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 67498ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 68503ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 68506ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 69510ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 69512ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 70526ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 70529ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 71536ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 71538ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 72551ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 72554ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 73562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 73565ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 74571ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 74574ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 75587ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 75590ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 76603ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 76606ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 77607ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 77608ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 78614ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 78619ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 79627ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 79629ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 80112ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 80649ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 80663ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 82183ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 82313ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 84634ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 84970ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 85652ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 85687ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 87032ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 87063ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 88257ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 88277ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 89301ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 89323ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 90312ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 90315ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 91330ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 91333ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 93470ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 94174ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 94469ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 94472ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 95485ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 95487ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 96489ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 96491ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 97495ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 97500ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 98504ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 98507ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 99514ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 99519ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 100093ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 100096ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 100104ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 103134ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 103167ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 106151ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 106162ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
[ 109164ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/events?quelle=pult:0
[ 109166ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:4791/soundboard/liste:0
+17
View File
@@ -30,6 +30,7 @@ import { createBot } from '#discord/bot.ts';
import { helix } from '#twitch/helix.ts';
import { readSecrets } from '#twitch/secrets.ts';
import { createEventSub } from '#twitch/eventsub.ts';
import { createBadges } from '#twitch/badges.ts';
import { createIrc, type UserNotice } from '#twitch/irc.ts';
import { streamWechsel } from '#twitch/stream-state.ts';
import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
@@ -245,6 +246,21 @@ export async function runHub(): Promise<void> {
const bot = createBot();
if (bot) void bot.run(abort.signal);
// ---- Abzeichen-Bilder fuer den Chat. Twitch aendert sie selten, ein
// neues Abo-Abzeichen des Kanals ist nach spaetestens sechs Stunden da.
const badges = createBadges();
void (async () => {
while (!abort.signal.aborted) {
if (helix.canReadStream()) {
const id = state.broadcasterId || cfg.channel.userId ||
(await helix.userByLogin(cfg.channel.login).catch(() => null))?.id || null;
if (id) state.broadcasterId = id;
await badges.laden(id);
}
try { await sleep(6 * 60 * 60_000, abort.signal); } catch { return; }
}
})();
// ---- Chat
const irc = createIrc(cfg.channel.login, {
onConnected: (anonymous) => {
@@ -273,6 +289,7 @@ export async function runHub(): Promise<void> {
mod: msg.isMod,
sub: msg.isSub,
broadcaster: msg.isBroadcaster,
badges: badges.aufloesen(msg.badges),
bits: msg.bits,
});
+1 -1
View File
@@ -21,7 +21,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage {
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId,
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [],
isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null,
isMod: false, isSub: false, isBroadcaster: false, badges: [], bits: 0, color: null,
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ function nachricht(over: Partial<ChatEvent> = {}): ChatEvent {
return {
kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin',
text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false,
sub: false, broadcaster: false, bits: 0,
sub: false, broadcaster: false, badges: [], bits: 0,
...over,
};
}
+2
View File
@@ -29,6 +29,8 @@ export type ChatEvent = {
mod: boolean;
sub: boolean;
broadcaster: boolean;
/** Abzeichen als fertige Bilder, aufgeloest im Hub (#twitch/badges.ts). */
badges: Array<{ url: string; title: string }>;
bits: number;
};
+18 -2
View File
@@ -96,6 +96,23 @@ export const TEST_KNOEPFE: TestKnopf[] = [
{ art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' },
];
// Twitchs globale Abzeichen. Die Adressen sind fest, so zeigt die Vorfuehrung
// echte Bilder, ohne Helix fragen zu muessen.
const ABZEICHEN = 'https://static-cdn.jtvnw.net/badges/v1/';
const MOD = { url: `${ABZEICHEN}3267646d-33f0-4b17-b3df-f923a41db1d0/2`, title: 'Moderator' };
const VIP = { url: `${ABZEICHEN}b817aba4-fad8-49e2-b88a-7cc744dfa6ec/2`, title: 'VIP' };
const ABO = { url: `${ABZEICHEN}5d9f2208-5dd8-11e7-8513-2ff4adfae661/2`, title: 'Subscriber' };
function zufallsAbzeichen(): Pick<ChatEvent, 'mod' | 'sub' | 'badges'> {
const mod = Math.random() < 0.15;
const vip = !mod && Math.random() < 0.1;
const sub = Math.random() < 0.3;
return {
mod, sub,
badges: [...(mod ? [MOD] : []), ...(vip ? [VIP] : []), ...(sub ? [ABO] : [])],
};
}
function chat(over: Partial<ChatEvent> = {}): ChatEvent {
const name = zufall(NAMEN);
return {
@@ -109,8 +126,7 @@ function chat(over: Partial<ChatEvent> = {}): ChatEvent {
emotes: [],
gifs: [],
first: false,
mod: Math.random() < 0.15,
sub: Math.random() < 0.3,
...zufallsAbzeichen(),
broadcaster: false,
bits: 0,
...over,
+14 -13
View File
@@ -53,18 +53,15 @@
background: rgba(0, 0, 0, .35);
}
/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten
* ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */
/* Twitchs eigene Abzeichen. Die Bilder loest der Hub ueber Helix auf und
* schickt sie mit jeder Zeile mit (#twitch/badges.ts). */
.abzeichen {
display: inline-block;
width: .5em; height: .5em;
border-radius: 50%;
margin-right: .4em;
vertical-align: .08em;
height: 1.05em;
width: auto;
margin-right: .3em;
vertical-align: -.17em;
}
.abzeichen.mod { background: var(--gruen); }
.abzeichen.abo { background: var(--lila); }
.abzeichen.host { background: var(--rot); }
.abzeichen:last-of-type { margin-right: .45em; }
/* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in
* dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante
@@ -152,9 +149,13 @@
el.dataset.user = ev.userId;
var abzeichen = '';
if (ev.broadcaster) abzeichen += '<span class="abzeichen host"></span>';
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>';
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>';
var bilder = ev.badges || [];
for (var i = 0; i < bilder.length; i++) {
// Nur Twitchs eigene Bildadressen: was hier steht, laedt OBS.
if (!/^https:\/\/static-cdn\.jtvnw\.net\//.test(bilder[i].url)) continue;
abzeichen += '<img class="abzeichen" src="' + Overlay.esc(bilder[i].url) +
'" alt="' + Overlay.esc(bilder[i].title) + '">';
}
el.innerHTML =
abzeichen +
+40 -6
View File
@@ -1,6 +1,6 @@
# Übergabe
Stand **2026-10-26**, letztes gebautes Paket 0.5.7. Wer hier weitermacht,
Stand **2026-10-27**, letztes gebautes Paket 0.5.7. Wer hier weitermacht,
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
@@ -20,7 +20,7 @@ Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
| S5 Onboarding + Sichtung | offen | |
| S6 Erstchatter-Begrüßung | offen | |
288 Tests, alle grün. Was noch fehlt, steht vollständig in
322 Tests, alle grün. Was noch fehlt, steht vollständig in
[`offene-punkte.md`](offene-punkte.md).
## Neu: der Dienst läuft zentral
@@ -96,6 +96,15 @@ aus `theme.css`, `?probe=1` zum Ansehen im Browser. Die Ereignisse auf dem Bus
(`apps/overlay-server/src/bus.ts`) sind die Schnittstelle, an der sich die
neuen Seiten festhalten können.
**Chat-Abzeichen sind seit 2026-10-27 Twitchs echte Bilder** statt farbiger
Punkte. IRC liefert nur `moderator/1`, `subscriber/3012` usw.; der Hub löst das
über Helix (global und Kanal, Kanal gewinnt) zu Bildadressen auf und hängt sie
an jede Chatzeile (`packages/twitch/src/badges.ts`, Feld `badges` im
`ChatEvent`). Geladen beim Start und alle sechs Stunden, beim Ausrollen
kamen 552. Unbekanntes fällt weg, die Seite nimmt nur Adressen von
`static-cdn.jtvnw.net`. Im echten Stream noch nicht angesehen; zeigt OBS
weiter Punkte, die Browser-Source einmal aktualisieren.
Die Ereignisse kommen über Server-Sent Events statt WebSocket. Warum, und was
gegen die tote Browser-Source nach einem Neustart getan ist, steht in
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
@@ -121,8 +130,12 @@ Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
## Das Pult: Handy als Stream Deck
Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.7, **nicht an echtem
OBS geprüft**. Auf Abdus Rechner ist nichts auszuführen: das Update kommt über
Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.7. **Seit 2026-10-27
hängt Abdus Stream-PC dran:** obs-websocket an, Passwort auf der
Einrichtungsseite hinterlegt, der PC (`DESKTOP-DGJ4HC6`) hat sich beim Hub
gemeldet und ist freigegeben, im Hub-Log steht `Stream-PC verbunden`. Ob
Szenenwechsel, Stummschalter, Sounds und das Anlegen der Quelle in OBS
greifen, ist damit noch nicht beobachtet. Auf Abdus Rechner ist nichts auszuführen: das Update kommt über
die tägliche Aufgabe, danach meldet sich der PC von selbst, Abdu tippt im Pult
einmal „Freigeben“, und die Sound-Quelle legt `ingest` selbst in OBS an. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit
Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen,
@@ -144,6 +157,13 @@ Vorlese-Freigabe und den Testknöpfen. Beschreibung in
Sound-Quelle, Szenenwechsel, Stummschalten, Hochladen, Abspielen); 8 MB
Upload durch den Proxy. Ungeprüft: echtes OBS (vor allem das Anlegen der
Quelle), iPhone/Android-Installation.
- **Die Adresse in der Anfragekarte** stimmt: bei Abdus echter Anfrage stand
seine öffentliche Adresse da, nicht die Docker-Brücke (`172.18.0.1` kam nur
bei der Probe aus dem eigenen Netz). Sie ist reine Anzeige - die Freigabe
hängt am Schlüssel, eine wechselnde IP stört nicht, eine feste braucht er
nicht.
- Auf dem Hub stand am 2026-10-26 kurz eine Probe-Anfrage „PRUEFUNG-CLAUDE“.
Sie ist nach zehn Minuten von selbst verfallen und wurde nicht freigegeben.
## Kanalpunkte: „Nachricht vorlesen" läuft
@@ -351,6 +371,11 @@ npx tsc --noEmit
## Ausliefern
Zwei Wege, je nachdem was sich ändert: Hub-Teil (Overlay, Pult, Discord, Chat)
ist mit `deploy-hub.sh` sofort live, Abdu muss nichts tun. Der PC-Teil
(`ingest`) kommt nur mit einem neuen Paket an, also nach einem Versions-Tag;
ein Push auf `main` allein erreicht seinen Rechner nicht.
`build-package.ts` lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern,
packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
`abdulive update` auf Abdus Rechner ab.
@@ -358,7 +383,7 @@ packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau
ersetzt, damit die Adresse gleich bleibt.
`.gitea/workflows/release.yml` macht das beim Tag automatisch. **Seit
2026-10-26 läuft sie** (0.5.6). Der erste Lauf (`v0.5.5`) scheiterte daran,
2026-10-26 läuft sie** (0.5.6, danach 0.5.7). Der erste Lauf (`v0.5.5`) scheiterte daran,
dass whisper.cpp seine Windows-Programme nicht mehr an `latest` hängt, sondern
an eigene `b…`-Veröffentlichungen; `githubAsset()` sucht jetzt die neueste
mit passendem Archiv. Unter `v0.5.5` liegt kein Paket.
@@ -456,7 +481,7 @@ Umlaute und das Trennzeichen `│` (U+2502) bleiben unverändert stehen.
dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von
oben.
## Die drei Dinge, die Abdu tun muss
## Was Abdu tun muss
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
@@ -481,6 +506,9 @@ oben.
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
`abdulive calibrate` zur Kontrolle.
4. ~~**Stream-PC im Pult freigeben.**~~ Erledigt am 2026-10-27, ebenso
obs-websocket und das Passwort aus Punkt 2. Offen aus Punkt 2 bleiben
Wiedergabepuffer, Hotkey und Audiospuren - nicht nachgeprüft.
Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
Chat anonym mit und kann nichts senden.
@@ -509,6 +537,12 @@ in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
malt. Das war vorher nicht zu erraten.
**Das Pult an Abdus OBS abnehmen.** Die Freigabe ist erteilt. Jetzt nachsehen, ob die
Szene `abdulive Ton` mit der Quelle `abdulive Sounds` angelegt und in jede
Szene eingehängt ist, ob ein Sound im Stream zu hören ist und ob Szenenwechsel
und Stummschalter greifen. Das fällt mit der Abnahme von S2 zusammen, beides
braucht nur ein laufendes OBS.
**Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
`abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
+1 -1
View File
@@ -22,7 +22,7 @@ function msg(over: Partial<ChatMessage> = {}): ChatMessage {
ts: '2026-09-09T20:00:00.000Z',
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false,
isCommand: false, isMod: false, isSub: false, isBroadcaster: false,
isCommand: false, isMod: false, isSub: false, isBroadcaster: false, badges: [],
bits: 0, color: null, ...over,
};
}
+2
View File
@@ -41,6 +41,8 @@ export type ChatMessage = {
isMod: boolean;
isSub: boolean;
isBroadcaster: boolean;
/** Twitchs Abzeichen in Anzeigereihenfolge, z. B. moderator/1, subscriber/3012. */
badges: Array<{ set: string; version: string }>;
bits: number;
color: string | null;
};
+23
View File
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { badgeKarte, badgesAufloesen } from './badges.ts';
const v = (id: string, url: string) =>
({ id, title: url, image_url_1x: '', image_url_2x: url, image_url_4x: '' });
test('Kanal-Abzeichen ueberdecken die globalen', () => {
const karte = badgeKarte(
[{ set_id: 'subscriber', versions: [v('0', 'global-sub')] }, { set_id: 'moderator', versions: [v('1', 'mod')] }],
[{ set_id: 'subscriber', versions: [v('0', 'kanal-sub'), v('3012', 'kanal-sub-t3')] }],
);
assert.deepEqual(
badgesAufloesen(karte, [{ set: 'moderator', version: '1' }, { set: 'subscriber', version: '0' }])
.map((b) => b.url),
['mod', 'kanal-sub'],
);
assert.equal(badgesAufloesen(karte, [{ set: 'subscriber', version: '3012' }])[0]?.url, 'kanal-sub-t3');
});
test('unbekannte Abzeichen fallen weg', () => {
assert.deepEqual(badgesAufloesen(new Map(), [{ set: 'vip', version: '1' }]), []);
});
+55
View File
@@ -0,0 +1,55 @@
// Abzeichen im Chat als Bilder. IRC liefert nur `subscriber/3012`, das Bild
// dazu kennt nur Helix. Die Kanal-Abzeichen (eigene Abo-Stufen) ueberdecken
// die globalen gleichen Namens - so zeigt Twitch sie auch an.
import { logger } from '#core/logger.ts';
import { helix, type HelixBadgeSet } from './helix.ts';
const log = logger('badges');
export type BadgeBild = { url: string; title: string };
export type BadgeKarte = Map<string, BadgeBild>;
export function badgeKarte(global: HelixBadgeSet[], kanal: HelixBadgeSet[]): BadgeKarte {
const karte: BadgeKarte = new Map();
for (const set of [...global, ...kanal]) {
for (const v of set.versions) {
// 2x ist 36 px: bei 19 px Schrift im Overlay scharf, auch skaliert.
karte.set(`${set.set_id}/${v.id}`, { url: v.image_url_2x, title: v.title });
}
}
return karte;
}
/** Unbekannte Abzeichen fallen weg, statt als kaputtes Bild zu erscheinen. */
export function badgesAufloesen(
karte: BadgeKarte, badges: Array<{ set: string; version: string }>,
): BadgeBild[] {
return badges.flatMap((b) => {
const bild = karte.get(`${b.set}/${b.version}`);
return bild ? [bild] : [];
});
}
export function createBadges(): {
laden: (broadcasterId: string | null) => Promise<void>;
aufloesen: (badges: Array<{ set: string; version: string }>) => BadgeBild[];
} {
let karte: BadgeKarte = new Map();
return {
async laden(broadcasterId) {
try {
const [global, kanal] = await Promise.all([
helix.chatBadges(),
broadcasterId ? helix.chatBadges(broadcasterId) : Promise.resolve([]),
]);
karte = badgeKarte(global, kanal);
log.info(`Abzeichen geladen: ${karte.size}`);
} catch (err) {
// Die alte Karte bleibt stehen. Ohne Karte zeigt der Chat eben keine.
log.warn(`Abzeichen nicht geladen: ${(err as Error).message}`);
}
},
aufloesen: (badges) => badgesAufloesen(karte, badges),
};
}
+17
View File
@@ -47,6 +47,11 @@ export type HelixStream = {
title: string; viewer_count: number; started_at: string; language: string; thumbnail_url: string;
};
export type HelixBadgeSet = {
set_id: string;
versions: Array<{ id: string; title: string; image_url_1x: string; image_url_2x: string; image_url_4x: string }>;
};
export type HelixUser = { id: string; login: string; display_name: string };
export type HelixReward = {
@@ -220,6 +225,18 @@ export const helix = {
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
},
/**
* Abzeichen-Bilder, ohne `broadcasterId` die globalen (Moderator, VIP,
* Broadcaster ...), mit die des Kanals (Abo-Stufen, Bits). Ein App-Token
* reicht fuer beides.
*/
async chatBadges(broadcasterId?: string): Promise<HelixBadgeSet[]> {
const r = await call<{ data: HelixBadgeSet[] }>('broadcaster',
broadcasterId ? `/chat/badges?broadcaster_id=${broadcasterId}` : '/chat/badges/global',
{}, { appOk: true });
return r.data;
},
async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> {
const r = await call<{ data: Array<{ id: string; edit_url: string }> }>(
'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' });
+10 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseLine, toChatMessage, parseGifs } from './irc.ts';
import { parseLine, toChatMessage, parseGifs, parseBadges } from './irc.ts';
const LINE =
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' +
@@ -28,6 +28,9 @@ test('uebersetzt in eine ChatMessage', () => {
assert.ok(msg);
assert.equal(msg.userId, '98765');
assert.equal(msg.login, 'nic_ooo');
assert.deepEqual(msg.badges, [
{ set: 'broadcaster', version: '1' }, { set: 'subscriber', version: '6' },
]);
assert.equal(msg.displayName, 'Nic_OOo');
assert.equal(msg.isBroadcaster, true);
assert.equal(msg.isSub, true);
@@ -104,3 +107,9 @@ test('unvollstaendige oder unsinnige Tags werden verworfen', () => {
assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`);
}
});
test('Abzeichen: leer und kaputt ergeben nichts', () => {
assert.deepEqual(parseBadges(undefined), []);
assert.deepEqual(parseBadges(''), []);
assert.deepEqual(parseBadges('vip/1,kaputt'), [{ set: 'vip', version: '1' }]);
});
+10
View File
@@ -111,6 +111,15 @@ function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes
return out;
}
/** `broadcaster/1,subscriber/3012` -> [{ set, version }], Reihenfolge wie bei Twitch. */
export function parseBadges(tag: string | undefined): Array<{ set: string; version: string }> {
if (!tag) return [];
return tag.split(',').flatMap((teil) => {
const [set, version] = teil.split('/');
return set && version ? [{ set, version }] : [];
});
}
export function toChatMessage(m: IrcMessage): ChatMessage | null {
if (m.command !== 'PRIVMSG') return null;
const text = m.params[1] ?? '';
@@ -133,6 +142,7 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null {
isMod: m.tags['mod'] === '1' || badges.includes('broadcaster'),
isSub: m.tags['subscriber'] === '1',
isBroadcaster: badges.includes('broadcaster'),
badges: parseBadges(badges),
bits: Number(m.tags['bits'] ?? 0),
color: m.tags['color'] || null,
};