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