Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00550aa37 | ||
|
|
227ecfdcfe | ||
|
|
9c64309c51 | ||
|
|
67ca472317 | ||
|
|
884e4517d8 | ||
|
|
54dbe1265b | ||
|
|
32416d1916 | ||
|
|
5eaae9129f |
No files matched your search
@@ -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/
|
||||||
@@ -11,23 +11,18 @@ Betrieb über Docker Compose.
|
|||||||
Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese
|
Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese
|
||||||
Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**.
|
Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**.
|
||||||
|
|
||||||
## Die neun Tools
|
## Was es gibt
|
||||||
|
|
||||||
| # | Tool | Ordner | Zweck | Status |
|
| Teil | Ordner | Zweck |
|
||||||
|---|------|--------|-------|--------|
|
|------|--------|-------|
|
||||||
| 1 | Clip-to-Shorts | `apps/clipper` | Clips → 9:16 mit Untertiteln, Upload-fertig | geplant |
|
| Hub | `apps/hub` | Läuft zentral im LXC: Chat, Overlay für OBS, Pult, Kanalpunkte, Discord, Go-Live-Ping |
|
||||||
| 2 | Go-Live-Broadcaster | `apps/broadcaster` | Stream-Start → Discord/Bluesky/Instagram | geplant |
|
| Overlay und Pult | `apps/overlay-server` + `apps/overlay-web` | Browser-Sources für OBS, Pult am Handy und Tablet |
|
||||||
| 3 | Raid- & Netzwerk-Tool | `apps/raid-scout` | Raid-Ziele finden, Raid-Historie führen | geplant |
|
| Go-Live-Ping | `apps/broadcaster` | Stream-Start → Discord |
|
||||||
| 4 | Analytics-Dashboard | `apps/dashboard` | Viewer vs. Chat, Titel-Performance, Retention | geplant |
|
| Ingest | `apps/ingest` | Läuft auf dem Stream-PC: OBS-Anbindung, `!clip`, 9:16-Render mit Untertiteln |
|
||||||
| 5 | VOD-Kapitel | `apps/vod-chapters` | Szenen-/Kategoriewechsel → Timestamps | geplant |
|
|
||||||
| 6 | Community-Gedächtnis | `apps/community` | Chatter-Profile, Begrüßungen, Overlay-Hinweise | geplant |
|
|
||||||
| 7 | Chat steuert Dinge | `apps/overlay-server` + `apps/overlay-web` | Overlay, Soundboard, Minigames, Umfragen | geplant |
|
|
||||||
| 8 | Punktesystem + Shop | `apps/loyalty` | Punkte mit echten Konsequenzen | geplant |
|
|
||||||
| 9 | Stream-Recap | `apps/recap` | Zusammenfassung nach Streamende in Discord | geplant |
|
|
||||||
|
|
||||||
Dazu der Dienst, der unter allen anderen liegt:
|
Die früher geplanten Werkzeuge (Raid-Scout, Auswertung, VOD-Kapitel,
|
||||||
|
Community-Gedächtnis, Punkteshop, Recap) sind gestrichen und mit ihren
|
||||||
| — | **Ingest-Daemon** | `apps/ingest` | IRC + Helix → DB, Einrichtungsseite | **läuft** |
|
Spezifikationen entfernt.
|
||||||
|
|
||||||
## Doku
|
## Doku
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
+20
-7
@@ -30,6 +30,7 @@ import { createBot } from '#discord/bot.ts';
|
|||||||
import { helix } from '#twitch/helix.ts';
|
import { helix } from '#twitch/helix.ts';
|
||||||
import { readSecrets } from '#twitch/secrets.ts';
|
import { readSecrets } from '#twitch/secrets.ts';
|
||||||
import { createEventSub } from '#twitch/eventsub.ts';
|
import { createEventSub } from '#twitch/eventsub.ts';
|
||||||
|
import { createBadges } from '#twitch/badges.ts';
|
||||||
import { createIrc, type UserNotice } from '#twitch/irc.ts';
|
import { createIrc, type UserNotice } from '#twitch/irc.ts';
|
||||||
import { streamWechsel } from '#twitch/stream-state.ts';
|
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||||
import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
|
import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
|
||||||
@@ -245,6 +246,21 @@ export async function runHub(): Promise<void> {
|
|||||||
const bot = createBot();
|
const bot = createBot();
|
||||||
if (bot) void bot.run(abort.signal);
|
if (bot) void bot.run(abort.signal);
|
||||||
|
|
||||||
|
// ---- Abzeichen-Bilder fuer den Chat. Twitch aendert sie selten, ein
|
||||||
|
// neues Abo-Abzeichen des Kanals ist nach spaetestens sechs Stunden da.
|
||||||
|
const badges = createBadges();
|
||||||
|
void (async () => {
|
||||||
|
while (!abort.signal.aborted) {
|
||||||
|
if (helix.canReadStream()) {
|
||||||
|
const id = state.broadcasterId || cfg.channel.userId ||
|
||||||
|
(await helix.userByLogin(cfg.channel.login).catch(() => null))?.id || null;
|
||||||
|
if (id) state.broadcasterId = id;
|
||||||
|
await badges.laden(id);
|
||||||
|
}
|
||||||
|
try { await sleep(6 * 60 * 60_000, abort.signal); } catch { return; }
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// ---- Chat
|
// ---- Chat
|
||||||
const irc = createIrc(cfg.channel.login, {
|
const irc = createIrc(cfg.channel.login, {
|
||||||
onConnected: (anonymous) => {
|
onConnected: (anonymous) => {
|
||||||
@@ -253,11 +269,9 @@ export async function runHub(): Promise<void> {
|
|||||||
},
|
},
|
||||||
onMessage: (msg) => {
|
onMessage: (msg) => {
|
||||||
try {
|
try {
|
||||||
repo.recordMessage(msg, state.streamId);
|
// Chatnachrichten landen nicht mehr in der Datenbank. Gelesen haben
|
||||||
|
// sie nur Recap und Auswertung, und die gibt es nicht mehr.
|
||||||
state.messages++;
|
state.messages++;
|
||||||
repo.event('chat.message', {
|
|
||||||
text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits,
|
|
||||||
}, state.streamId, msg.userId, msg.ts);
|
|
||||||
|
|
||||||
bus.publish({
|
bus.publish({
|
||||||
kind: 'chat',
|
kind: 'chat',
|
||||||
@@ -273,7 +287,9 @@ export async function runHub(): Promise<void> {
|
|||||||
mod: msg.isMod,
|
mod: msg.isMod,
|
||||||
sub: msg.isSub,
|
sub: msg.isSub,
|
||||||
broadcaster: msg.isBroadcaster,
|
broadcaster: msg.isBroadcaster,
|
||||||
|
badges: badges.aufloesen(msg.badges),
|
||||||
bits: msg.bits,
|
bits: msg.bits,
|
||||||
|
ts: msg.ts,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (msg.isFirst) {
|
if (msg.isFirst) {
|
||||||
@@ -549,9 +565,6 @@ async function pollStream(
|
|||||||
state.startedLiveAt = live.started_at;
|
state.startedLiveAt = live.started_at;
|
||||||
state.lastSeenLive = now;
|
state.lastSeenLive = now;
|
||||||
state.viewers = live.viewer_count;
|
state.viewers = live.viewer_count;
|
||||||
if (state.streamId) {
|
|
||||||
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
|
|
||||||
}
|
|
||||||
sendState(true);
|
sendState(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import type { ObsClient } from '#obs/client.ts';
|
import type { ObsClient } from '#obs/client.ts';
|
||||||
import { befehlAus, sseZerleger, standLesen } from './fern.ts';
|
import {
|
||||||
|
befehlAus, einrichtenAus, SOUND_QUELLE, soundQuelleEinrichten, sseZerleger, standLesen, TON_SZENE,
|
||||||
|
} from './fern.ts';
|
||||||
|
|
||||||
test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
|
test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
|
||||||
const z = sseZerleger();
|
const z = sseZerleger();
|
||||||
@@ -67,3 +69,83 @@ test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
|
|||||||
assert.equal(s.puffer, false);
|
assert.equal(s.puffer, false);
|
||||||
assert.deepEqual(s.szenen, ['A']);
|
assert.deepEqual(s.szenen, ['A']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('nur eine Adresse auf die Sound-Seite wird als Browser-Source angelegt', () => {
|
||||||
|
assert.deepEqual(einrichtenAus({ art: 'einrichten', soundsUrl: 'https://abdu.dimension47.de/sounds?key=4OB0-x_y' }),
|
||||||
|
{ art: 'einrichten', soundsUrl: 'https://abdu.dimension47.de/sounds?key=4OB0-x_y' });
|
||||||
|
assert.equal(einrichtenAus({ art: 'einrichten', soundsUrl: 'https://boese.example/irgendwas' }), null);
|
||||||
|
assert.equal(einrichtenAus({ art: 'einrichten', soundsUrl: 'javascript:alert(1)//sounds?key=a' }), null);
|
||||||
|
assert.equal(einrichtenAus({ art: 'szene', soundsUrl: 'https://a/sounds?key=a' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** OBS zum Mitschreiben: Szenen, Quellen und was darin steckt. */
|
||||||
|
function obsMitSzenen(szenen: string[]) {
|
||||||
|
const inhalt = new Map<string, string[]>(szenen.map((s) => [s, []]));
|
||||||
|
const quellen = new Map<string, Record<string, unknown>>();
|
||||||
|
const aufrufe: string[] = [];
|
||||||
|
const obs: ObsClient = {
|
||||||
|
run: async () => {},
|
||||||
|
isConnected: () => true,
|
||||||
|
request: async <T>(type: string, data?: unknown): Promise<T> => {
|
||||||
|
const d = (data ?? {}) as Record<string, unknown>;
|
||||||
|
aufrufe.push(type);
|
||||||
|
switch (type) {
|
||||||
|
case 'GetSceneList': return { scenes: [...inhalt.keys()].map((sceneName) => ({ sceneName })) } as T;
|
||||||
|
case 'CreateScene': inhalt.set(String(d['sceneName']), []); return {} as T;
|
||||||
|
case 'GetInputSettings': {
|
||||||
|
const q = quellen.get(String(d['inputName']));
|
||||||
|
if (!q) throw new Error('OBS lehnt ab (600): keine Quelle');
|
||||||
|
return { inputSettings: q } as T;
|
||||||
|
}
|
||||||
|
case 'CreateInput':
|
||||||
|
quellen.set(String(d['inputName']), d['inputSettings'] as Record<string, unknown>);
|
||||||
|
inhalt.get(String(d['sceneName']))!.push(String(d['inputName']));
|
||||||
|
return {} as T;
|
||||||
|
case 'SetInputSettings':
|
||||||
|
Object.assign(quellen.get(String(d['inputName']))!, d['inputSettings']);
|
||||||
|
return {} as T;
|
||||||
|
case 'GetSceneItemList':
|
||||||
|
return { sceneItems: inhalt.get(String(d['sceneName']))!.map((sourceName) => ({ sourceName })) } as T;
|
||||||
|
case 'CreateSceneItem':
|
||||||
|
inhalt.get(String(d['sceneName']))!.push(String(d['sourceName']));
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
throw new Error(`unerwartet: ${type}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { obs, inhalt, quellen, aufrufe };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Sound-Quelle: einmal angelegt, in jeder Szene, ein zweiter Lauf aendert nichts', async () => {
|
||||||
|
const o = obsMitSzenen(['Gameplay', 'Pause']);
|
||||||
|
const url = 'https://hub/sounds?key=a';
|
||||||
|
const getan = await soundQuelleEinrichten(o.obs, url);
|
||||||
|
assert.equal(getan.length, 4);
|
||||||
|
assert.deepEqual(o.inhalt.get(TON_SZENE), [SOUND_QUELLE]);
|
||||||
|
assert.deepEqual(o.inhalt.get('Gameplay'), [TON_SZENE]);
|
||||||
|
assert.deepEqual(o.inhalt.get('Pause'), [TON_SZENE]);
|
||||||
|
const q = o.quellen.get(SOUND_QUELLE)!;
|
||||||
|
assert.equal(q['reroute_audio'], true, 'sonst geht der Ton am Stream vorbei');
|
||||||
|
assert.equal(q['shutdown'], false);
|
||||||
|
|
||||||
|
assert.deepEqual(await soundQuelleEinrichten(o.obs, url), []);
|
||||||
|
assert.equal(o.aufrufe.filter((a) => a.startsWith('Create')).length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sound-Quelle: neue Szene und neuer Schluessel werden nachgezogen', async () => {
|
||||||
|
const o = obsMitSzenen(['Gameplay']);
|
||||||
|
await soundQuelleEinrichten(o.obs, 'https://hub/sounds?key=alt');
|
||||||
|
o.inhalt.set('Just Chatting', []);
|
||||||
|
const getan = await soundQuelleEinrichten(o.obs, 'https://hub/sounds?key=neu');
|
||||||
|
assert.deepEqual(getan, [`Adresse von "${SOUND_QUELLE}" erneuert`, 'in "Just Chatting" eingehaengt']);
|
||||||
|
assert.equal(o.quellen.get(SOUND_QUELLE)!['url'], 'https://hub/sounds?key=neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('die eigene Ton-Szene steht nicht in der Szenenauswahl', async () => {
|
||||||
|
const obs = falschesObs({
|
||||||
|
GetSceneList: () => ({ currentProgramSceneName: 'A', scenes: [
|
||||||
|
{ sceneName: 'A', sceneIndex: 1 }, { sceneName: TON_SZENE, sceneIndex: 0 }] }),
|
||||||
|
GetInputList: () => ({ inputs: [] }),
|
||||||
|
});
|
||||||
|
assert.deepEqual((await standLesen(obs)).szenen, ['A']);
|
||||||
|
});
|
||||||
+141
-8
@@ -8,9 +8,13 @@
|
|||||||
// POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub)
|
// POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub)
|
||||||
// POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub)
|
// POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub)
|
||||||
//
|
//
|
||||||
// Gegenstueck: apps/overlay-server/src/fern.ts. Eingerichtet wird mit
|
// Gegenstueck: apps/overlay-server/src/fern.ts.
|
||||||
// `abdulive fern:verbinden <adresse> <schluessel>`; beides steht fertig auf
|
//
|
||||||
// der Uebersichtsseite des Hubs.
|
// Einzurichten ist nichts. Beim ersten Start wuerfelt der PC einen Schluessel
|
||||||
|
// und klopft damit beim Hub an; der antwortet 202, bis im Pult jemand auf
|
||||||
|
// "Freigeben" tippt. Danach schickt der Hub die Adresse der Sound-Quelle, und
|
||||||
|
// der PC legt sie in OBS selbst an. `abdulive fern:verbinden` bleibt als Weg
|
||||||
|
// von Hand.
|
||||||
|
|
||||||
import { logger } from '#core/logger.ts';
|
import { logger } from '#core/logger.ts';
|
||||||
import { forever, sleep } from '#core/retry.ts';
|
import { forever, sleep } from '#core/retry.ts';
|
||||||
@@ -21,6 +25,18 @@ const log = logger('fern');
|
|||||||
/** Der Hub schickt alle 15 s einen Herzschlag. Drei verpasste heisst: tot. */
|
/** Der Hub schickt alle 15 s einen Herzschlag. Drei verpasste heisst: tot. */
|
||||||
const TOT_NACH_MS = 45_000;
|
const TOT_NACH_MS = 45_000;
|
||||||
|
|
||||||
|
/** So oft fragt ein noch nicht freigegebener PC nach. */
|
||||||
|
const WARTEN_MS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was der PC in OBS anlegt. Die Sound-Quelle sitzt in einer eigenen Szene,
|
||||||
|
* und diese Szene haengt in jeder anderen: OBS gibt den Ton einer Quelle nur
|
||||||
|
* aus, wenn sie in der gerade gezeigten Szene liegt. So gibt es die Quelle
|
||||||
|
* trotzdem nur einmal, und ein Szenenwechsel startet sie nicht neu.
|
||||||
|
*/
|
||||||
|
export const TON_SZENE = 'abdulive Ton';
|
||||||
|
export const SOUND_QUELLE = 'abdulive Sounds';
|
||||||
|
|
||||||
/** Nach diesen OBS-Ereignissen stimmt der gemeldete Stand nicht mehr. */
|
/** Nach diesen OBS-Ereignissen stimmt der gemeldete Stand nicht mehr. */
|
||||||
const STAND_EREIGNISSE = new Set([
|
const STAND_EREIGNISSE = new Set([
|
||||||
'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
|
'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
|
||||||
@@ -41,6 +57,21 @@ export type Befehl =
|
|||||||
| { id: string; art: 'clip' }
|
| { id: string; art: 'clip' }
|
||||||
| { id: string; art: 'stumm'; quelle: string; stumm: boolean };
|
| { id: string; art: 'stumm'; quelle: string; stumm: boolean };
|
||||||
|
|
||||||
|
/** Kommt gleich nach dem Verbinden, ohne ID: darauf antwortet niemand. */
|
||||||
|
export type Einrichten = { art: 'einrichten'; soundsUrl: string };
|
||||||
|
|
||||||
|
export function einrichtenAus(roh: unknown): Einrichten | null {
|
||||||
|
if (!roh || typeof roh !== 'object') return null;
|
||||||
|
const r = roh as Record<string, unknown>;
|
||||||
|
const url = r['soundsUrl'];
|
||||||
|
// Nur eine Adresse auf eine Sound-Seite. Was der Hub hier schickt, landet
|
||||||
|
// als Browser-Source in OBS - eine beliebige Seite haette dort nichts verloren.
|
||||||
|
if (r['art'] !== 'einrichten' || typeof url !== 'string' || !/^https?:\/\/[^\s]+\/sounds\?key=[\w-]+$/.test(url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { art: 'einrichten', soundsUrl: url };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zerlegt einen SSE-Strom in Nachrichten. Bekommt beliebige Stuecke - eine
|
* Zerlegt einen SSE-Strom in Nachrichten. Bekommt beliebige Stuecke - eine
|
||||||
* Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem
|
* Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem
|
||||||
@@ -90,7 +121,9 @@ export async function standLesen(obs: ObsClient): Promise<Stand> {
|
|||||||
// OBS zaehlt von unten. Im Fenster steht die mit dem hoechsten Index oben,
|
// OBS zaehlt von unten. Im Fenster steht die mit dem hoechsten Index oben,
|
||||||
// und in dieser Reihenfolge erwartet man sie auch am Handy.
|
// und in dieser Reihenfolge erwartet man sie auch am Handy.
|
||||||
const szenen = [...liste.scenes]
|
const szenen = [...liste.scenes]
|
||||||
.sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName);
|
.sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName)
|
||||||
|
// Die eigene Ton-Szene ist zum Einhaengen da, nicht zum Umschalten.
|
||||||
|
.filter((s) => s !== TON_SZENE);
|
||||||
|
|
||||||
const eingaenge = await obs.request<{ inputs: Array<{ inputName: string }> }>('GetInputList');
|
const eingaenge = await obs.request<{ inputs: Array<{ inputName: string }> }>('GetInputList');
|
||||||
// Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute
|
// Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute
|
||||||
@@ -110,9 +143,63 @@ export async function standLesen(obs: ObsClient): Promise<Stand> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt die Sound-Quelle in OBS an, wenn sie fehlt, und haengt die Ton-Szene
|
||||||
|
* in jede Szene, in der sie noch nicht steckt. Laeuft beim Verbinden und bei
|
||||||
|
* jeder neuen Szene; ein zweiter Lauf aendert nichts.
|
||||||
|
*
|
||||||
|
* Gibt zurueck, was getan wurde - fuers Protokoll.
|
||||||
|
*/
|
||||||
|
export async function soundQuelleEinrichten(obs: ObsClient, url: string): Promise<string[]> {
|
||||||
|
const getan: string[] = [];
|
||||||
|
const liste = await obs.request<{ scenes: Array<{ sceneName: string }> }>('GetSceneList');
|
||||||
|
const namen = liste.scenes.map((s) => s.sceneName);
|
||||||
|
|
||||||
|
if (!namen.includes(TON_SZENE)) {
|
||||||
|
await obs.request('CreateScene', { sceneName: TON_SZENE });
|
||||||
|
getan.push(`Szene "${TON_SZENE}" angelegt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await obs.request<{ inputSettings: Record<string, unknown> }>(
|
||||||
|
'GetInputSettings', { inputName: SOUND_QUELLE }).catch(() => null);
|
||||||
|
const einstellungen = {
|
||||||
|
url, width: 400, height: 120,
|
||||||
|
// "Audio ueber OBS steuern": ohne das geht der Ton am Mixer vorbei
|
||||||
|
// direkt auf die Lautsprecher und nie in den Stream.
|
||||||
|
reroute_audio: true,
|
||||||
|
// Weiterlaufen, auch wenn gerade nicht sichtbar, und beim Szenenwechsel
|
||||||
|
// nicht neu laden - sonst bricht jeder Wechsel den laufenden Sound ab.
|
||||||
|
shutdown: false,
|
||||||
|
restart_when_active: false,
|
||||||
|
};
|
||||||
|
if (!vorhanden) {
|
||||||
|
await obs.request('CreateInput', {
|
||||||
|
sceneName: TON_SZENE, inputName: SOUND_QUELLE, inputKind: 'browser_source',
|
||||||
|
inputSettings: einstellungen, sceneItemEnabled: true,
|
||||||
|
});
|
||||||
|
getan.push(`Quelle "${SOUND_QUELLE}" angelegt`);
|
||||||
|
} else if (vorhanden.inputSettings['url'] !== url) {
|
||||||
|
// Neuer Overlay-Schluessel auf dem Hub: die alte Adresse ist tot.
|
||||||
|
await obs.request('SetInputSettings', { inputName: SOUND_QUELLE, inputSettings: { url } });
|
||||||
|
getan.push(`Adresse von "${SOUND_QUELLE}" erneuert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const szene of namen) {
|
||||||
|
if (szene === TON_SZENE) continue;
|
||||||
|
const inhalt = await obs.request<{ sceneItems: Array<{ sourceName: string }> }>(
|
||||||
|
'GetSceneItemList', { sceneName: szene });
|
||||||
|
if (inhalt.sceneItems.some((i) => i.sourceName === TON_SZENE)) continue;
|
||||||
|
await obs.request('CreateSceneItem', { sceneName: szene, sourceName: TON_SZENE, sceneItemEnabled: true });
|
||||||
|
getan.push(`in "${szene}" eingehaengt`);
|
||||||
|
}
|
||||||
|
return getan;
|
||||||
|
}
|
||||||
|
|
||||||
export type FernDeps = {
|
export type FernDeps = {
|
||||||
url: string;
|
url: string;
|
||||||
key: string;
|
key: string;
|
||||||
|
/** So erscheint der PC bei der Freigabe im Pult. */
|
||||||
|
name: string;
|
||||||
obs: ObsClient;
|
obs: ObsClient;
|
||||||
/** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
|
/** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
|
||||||
clip: () => Promise<{ ok: boolean; reason?: string }>;
|
clip: () => Promise<{ ok: boolean; reason?: string }>;
|
||||||
@@ -127,11 +214,20 @@ export type FernAnschluss = {
|
|||||||
verbunden: () => boolean;
|
verbunden: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
||||||
const basis = deps.url.replace(/\/+$/, '');
|
const basis = deps.url.replace(/\/+$/, '');
|
||||||
const kopf = { authorization: `Bearer ${deps.key}` };
|
// Der Rechnername steht nur in der Freigabe im Pult. Nur ASCII: ein Umlaut
|
||||||
|
// im Namen machte den ganzen Kopf ungueltig, und fetch wirft.
|
||||||
|
const kopf = {
|
||||||
|
authorization: `Bearer ${deps.key}`,
|
||||||
|
'x-abdulive-rechner': deps.name.replace(/[^\x20-\x7e]/g, '?').slice(0, 40),
|
||||||
|
};
|
||||||
let verbunden = false;
|
let verbunden = false;
|
||||||
let sammler: NodeJS.Timeout | null = null;
|
let sammler: NodeJS.Timeout | null = null;
|
||||||
|
/** Kommt vom Hub nach dem Verbinden. Bis dahin wird in OBS nichts angelegt. */
|
||||||
|
let soundsUrl: string | null = null;
|
||||||
|
let richtetEin = false;
|
||||||
|
|
||||||
async function post(pfad: string, body: unknown): Promise<void> {
|
async function post(pfad: string, body: unknown): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -159,6 +255,21 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
|||||||
sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300);
|
sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Sound-Quelle in OBS anlegen bzw. pruefen. Nie zweimal gleichzeitig. */
|
||||||
|
const einrichten = async (): Promise<void> => {
|
||||||
|
if (!soundsUrl || !deps.obs.isConnected() || richtetEin) return;
|
||||||
|
richtetEin = true;
|
||||||
|
try {
|
||||||
|
const getan = await soundQuelleEinrichten(deps.obs, soundsUrl);
|
||||||
|
if (getan.length) log.info(`OBS eingerichtet: ${getan.join(', ')}`);
|
||||||
|
if (getan.length) spaeterSenden();
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`Sound-Quelle in OBS nicht eingerichtet: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
richtetEin = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function ausfuehren(b: Befehl): Promise<{ ok: boolean; text: string }> {
|
async function ausfuehren(b: Befehl): Promise<{ ok: boolean; text: string }> {
|
||||||
if (b.art === 'clip') {
|
if (b.art === 'clip') {
|
||||||
const r = await deps.clip();
|
const r = await deps.clip();
|
||||||
@@ -190,9 +301,24 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
|||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${basis}/fern/befehle`, {
|
let res: Response;
|
||||||
|
let gemeldet = false;
|
||||||
|
// Solange der Hub 202 sagt, ist der PC noch nicht freigegeben. Dann im
|
||||||
|
// festen Takt nachfragen - die Freigabe soll ein paar Sekunden nach dem
|
||||||
|
// Tipp im Pult greifen, nicht erst nach einer Minute Rueckzug.
|
||||||
|
for (;;) {
|
||||||
|
res = await fetch(`${basis}/fern/befehle`, {
|
||||||
headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
|
headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
|
||||||
});
|
});
|
||||||
|
if (res.status !== 202) break;
|
||||||
|
await res.body?.cancel().catch(() => {});
|
||||||
|
if (!gemeldet) {
|
||||||
|
log.info(`Wartet auf Freigabe: im Pult (${basis}/pult) unter Szenen auf „Freigeben“ tippen`);
|
||||||
|
gemeldet = true;
|
||||||
|
}
|
||||||
|
zuletzt = Date.now();
|
||||||
|
await sleep(WARTEN_MS, beides);
|
||||||
|
}
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
|
log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
|
||||||
'von der Uebersichtsseite: abdulive fern:verbinden ...');
|
'von der Uebersichtsseite: abdulive fern:verbinden ...');
|
||||||
@@ -213,6 +339,8 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
|||||||
for (const daten of zerleger(decoder.decode(stueck, { stream: true }))) {
|
for (const daten of zerleger(decoder.decode(stueck, { stream: true }))) {
|
||||||
let roh: unknown;
|
let roh: unknown;
|
||||||
try { roh = JSON.parse(daten); } catch { continue; }
|
try { roh = JSON.parse(daten); } catch { continue; }
|
||||||
|
const e = einrichtenAus(roh);
|
||||||
|
if (e) { soundsUrl = e.soundsUrl; void einrichten(); continue; }
|
||||||
const b = befehlAus(roh);
|
const b = befehlAus(roh);
|
||||||
if (!b) continue;
|
if (!b) continue;
|
||||||
void ausfuehren(b).then((a) => {
|
void ausfuehren(b).then((a) => {
|
||||||
@@ -240,8 +368,13 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
|||||||
if (n >= 3) log.warn(zeile); else log.debug(zeile);
|
if (n >= 3) log.warn(zeile); else log.debug(zeile);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
obsEreignis: (type) => { if (STAND_EREIGNISSE.has(type)) spaeterSenden(); },
|
obsEreignis: (type) => {
|
||||||
obsWechsel: spaeterSenden,
|
if (STAND_EREIGNISSE.has(type)) spaeterSenden();
|
||||||
|
// Eine neue Szene bekommt die Ton-Szene gleich mit, sonst ist dort
|
||||||
|
// das Soundboard stumm.
|
||||||
|
if (type === 'SceneCreated') void einrichten();
|
||||||
|
},
|
||||||
|
obsWechsel: () => { spaeterSenden(); void einrichten(); },
|
||||||
verbunden: () => verbunden,
|
verbunden: () => verbunden,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,9 @@ import { createObs } from '#obs/client.ts';
|
|||||||
import { isWindows } from '#core/platform.ts';
|
import { isWindows } from '#core/platform.ts';
|
||||||
import { helix } from '#twitch/helix.ts';
|
import { helix } from '#twitch/helix.ts';
|
||||||
import { createIrc } from '#twitch/irc.ts';
|
import { createIrc } from '#twitch/irc.ts';
|
||||||
import { readSecrets } from '#twitch/secrets.ts';
|
import { readSecrets, updateSecrets } from '#twitch/secrets.ts';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import os from 'node:os';
|
||||||
import { streamWechsel } from '#twitch/stream-state.ts';
|
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||||
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||||
import { createClipper } from './clips.ts';
|
import { createClipper } from './clips.ts';
|
||||||
@@ -116,19 +118,21 @@ export async function runIngest(): Promise<void> {
|
|||||||
onRawReady: (job) => renderer.enqueue(job),
|
onRawReady: (job) => renderer.enqueue(job),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Nur, wenn mit
|
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Einzurichten ist
|
||||||
// `abdulive fern:verbinden` eingerichtet - sonst laeuft alles wie vorher.
|
// nichts: beim ersten Start wird ein Schluessel gewuerfelt, der PC meldet
|
||||||
const fernZugang = readSecrets().fern;
|
// sich beim Hub, und im Pult erscheint "Freigeben".
|
||||||
const fern = fernZugang?.url && fernZugang.key
|
const fernZugang = fernZugangHolen(cfg.fern.url);
|
||||||
|
const fern = fernZugang
|
||||||
? createFernanschluss({
|
? createFernanschluss({
|
||||||
url: fernZugang.url,
|
url: fernZugang.url,
|
||||||
key: fernZugang.key,
|
key: fernZugang.key,
|
||||||
|
name: os.hostname(),
|
||||||
obs,
|
obs,
|
||||||
clip: () => clipper.trigger('manual', { login: 'Pult' }),
|
clip: () => clipper.trigger('manual', { login: 'Pult' }),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (fern) void fern.run(abort.signal);
|
if (fern) void fern.run(abort.signal);
|
||||||
else log.info('Pult-Fernsteuerung nicht eingerichtet (abdulive fern:verbinden)');
|
else log.info('Pult-Fernsteuerung aus (fern.url ist leer)');
|
||||||
|
|
||||||
// ---- Go-Live-Ankuendigung auf Discord
|
// ---- Go-Live-Ankuendigung auf Discord
|
||||||
const announcer = createAnnouncer({
|
const announcer = createAnnouncer({
|
||||||
@@ -221,6 +225,22 @@ export async function runIngest(): Promise<void> {
|
|||||||
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
|
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adresse und Schluessel fuer die Leitung zum Hub. Was mit
|
||||||
|
* `abdulive fern:verbinden` von Hand eingetragen ist, gewinnt. Sonst wird
|
||||||
|
* einmal gewuerfelt und gemerkt - der Hub kennt den Schluessel erst nach der
|
||||||
|
* Freigabe im Pult, also darf er sich nicht bei jedem Start aendern.
|
||||||
|
*/
|
||||||
|
function fernZugangHolen(vorgabeUrl: string): { url: string; key: string } | null {
|
||||||
|
const da = readSecrets().fern;
|
||||||
|
if (da?.url && da.key) return da;
|
||||||
|
if (!vorgabeUrl) return null;
|
||||||
|
const neu = { url: vorgabeUrl, key: crypto.randomBytes(24).toString('base64url') };
|
||||||
|
updateSecrets((s) => { s.fern = neu; });
|
||||||
|
log.info(`Neuer Schluessel fuer das Pult gewuerfelt - meldet sich bei ${vorgabeUrl}`);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
streamId: string | null; broadcasterId: string | null;
|
streamId: string | null; broadcasterId: string | null;
|
||||||
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
|
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
|
||||||
|
|||||||
@@ -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,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)
|
|
||||||
@@ -39,6 +39,8 @@ export type Zugang = {
|
|||||||
darfFern: (req: http.IncomingMessage) => boolean;
|
darfFern: (req: http.IncomingMessage) => boolean;
|
||||||
fernKey: () => string;
|
fernKey: () => string;
|
||||||
neuerFernKey: () => string;
|
neuerFernKey: () => string;
|
||||||
|
/** Den Schluessel eines im Pult freigegebenen PCs eintragen. */
|
||||||
|
fernKeySetzen: (key: string) => void;
|
||||||
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
|
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
|
||||||
hatPasswort: () => boolean;
|
hatPasswort: () => boolean;
|
||||||
};
|
};
|
||||||
@@ -184,6 +186,9 @@ export function createZugang(): Zugang {
|
|||||||
return Boolean(gegeben && gleich(gegeben, fernKey()));
|
return Boolean(gegeben && gleich(gegeben, fernKey()));
|
||||||
},
|
},
|
||||||
fernKey,
|
fernKey,
|
||||||
|
fernKeySetzen(k) {
|
||||||
|
updateSecrets((s) => { s.web = { ...s.web, fernKey: k }; });
|
||||||
|
},
|
||||||
neuerFernKey() {
|
neuerFernKey() {
|
||||||
const neu = crypto.randomBytes(24).toString('base64url');
|
const neu = crypto.randomBytes(24).toString('base64url');
|
||||||
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
|
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ export type ChatEvent = {
|
|||||||
mod: boolean;
|
mod: boolean;
|
||||||
sub: boolean;
|
sub: boolean;
|
||||||
broadcaster: boolean;
|
broadcaster: boolean;
|
||||||
|
/** Abzeichen als fertige Bilder, aufgeloest im Hub (#twitch/badges.ts). */
|
||||||
|
badges: Array<{ url: string; title: string }>;
|
||||||
bits: number;
|
bits: number;
|
||||||
|
/**
|
||||||
|
* Sendezeit laut Twitch. Fuer die Uhrzeit im Pult: ohne sie stuende nach
|
||||||
|
* dem Neuverbinden an allen Nachrichten aus dem Schnappschuss dieselbe.
|
||||||
|
*/
|
||||||
|
ts?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlertKind =
|
export type AlertKind =
|
||||||
@@ -132,6 +139,12 @@ export type OverlayEvent =
|
|||||||
| { kind: 'sound-stopp' }
|
| { kind: 'sound-stopp' }
|
||||||
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
|
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
|
||||||
| { kind: 'sounds-geaendert' }
|
| { kind: 'sounds-geaendert' }
|
||||||
|
/**
|
||||||
|
* Ein Stream-PC wartet auf Freigabe, oder die Liste hat sich geaendert.
|
||||||
|
* Ohne Inhalt: wer und von wo, holt sich das Pult mit Anmeldung - der
|
||||||
|
* Ereignisstrom geht auch an die Browser-Sources.
|
||||||
|
*/
|
||||||
|
| { kind: 'fern-anfrage' }
|
||||||
| ObsStandEvent
|
| ObsStandEvent
|
||||||
| { kind: 'clear'; userId?: string; messageId?: string }
|
| { kind: 'clear'; userId?: string; messageId?: string }
|
||||||
| { kind: 'beat'; t: number }
|
| { kind: 'beat'; t: number }
|
||||||
|
|||||||
@@ -114,3 +114,39 @@ test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
|
|||||||
assert.equal(sichererPfad('//boese.example'), '/');
|
assert.equal(sichererPfad('//boese.example'), '/');
|
||||||
assert.equal(sichererPfad(null), '/');
|
assert.equal(sichererPfad(null), '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ein unbekannter PC wartet auf Freigabe und bekommt dann seinen Schluessel eingetragen', () => {
|
||||||
|
const bus = createBus();
|
||||||
|
const ereignisse: string[] = [];
|
||||||
|
bus.subscribe((e) => ereignisse.push(e.kind));
|
||||||
|
const fern = createFernsteuerung(bus);
|
||||||
|
const key = 'k'.repeat(32);
|
||||||
|
|
||||||
|
fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
|
||||||
|
fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
|
||||||
|
const [a, ...rest] = fern.offeneAnfragen();
|
||||||
|
assert.equal(rest.length, 0, 'derselbe PC fragt alle paar Sekunden, das ist eine Anfrage');
|
||||||
|
assert.equal(a?.name, 'ABDU-PC');
|
||||||
|
assert.equal('key' in (a as object), false, 'der Schluessel geht nie ans Pult');
|
||||||
|
assert.ok(ereignisse.includes('fern-anfrage'));
|
||||||
|
|
||||||
|
assert.equal(fern.freigeben(a!.id), key);
|
||||||
|
assert.deepEqual(fern.offeneAnfragen(), []);
|
||||||
|
assert.equal(fern.freigeben(a!.id), null, 'zweimal freigeben geht nicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mehr als drei fremde Anfragen verdraengen die aeltesten', () => {
|
||||||
|
const fern = createFernsteuerung(createBus());
|
||||||
|
for (let i = 0; i < 5; i++) fern.anfragen(`schluessel-${i}`.padEnd(30, 'x'), `PC${i}`, '');
|
||||||
|
assert.deepEqual(fern.offeneAnfragen().map((a) => a.name).sort(), ['PC2', 'PC3', 'PC4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('beim Verbinden erfaehrt der PC die Adresse der Sound-Quelle', () => {
|
||||||
|
const fern = createFernsteuerung(createBus(), {
|
||||||
|
willkommen: () => ({ soundsUrl: 'https://hub.example/sounds?key=abc' }),
|
||||||
|
});
|
||||||
|
const l = leitung();
|
||||||
|
fern.annehmen(l.req, l.res);
|
||||||
|
const [hallo] = l.befehle();
|
||||||
|
assert.deepEqual(hallo, { art: 'einrichten', soundsUrl: 'https://hub.example/sounds?key=abc' });
|
||||||
|
});
|
||||||
@@ -32,9 +32,28 @@ export type FernBefehl =
|
|||||||
|
|
||||||
export type FernAntwort = { ok: boolean; text: string };
|
export type FernAntwort = { ok: boolean; text: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Stream-PC, der sich mit einem noch unbekannten Schluessel meldet. Er
|
||||||
|
* wartet, bis jemand im Pult auf "Freigeben" tippt - damit Abdu keinen Befehl
|
||||||
|
* abtippen muss. Der Schluessel selbst geht nie ans Pult zurueck.
|
||||||
|
*/
|
||||||
|
export type FernAnfrage = { id: string; name: string; adresse: string; seit: string };
|
||||||
|
|
||||||
|
/** So viele Anfragen gleichzeitig, so lange gueltig. Mehr ist Laerm oder ein Angriff. */
|
||||||
|
const MAX_ANFRAGEN = 3;
|
||||||
|
const ANFRAGE_MS = 10 * 60_000;
|
||||||
|
|
||||||
export type Fernsteuerung = {
|
export type Fernsteuerung = {
|
||||||
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
|
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
|
||||||
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
||||||
|
/** Ein unbekannter Schluessel klopft an. */
|
||||||
|
anfragen: (key: string, name: string, adresse: string) => void;
|
||||||
|
offeneAnfragen: () => FernAnfrage[];
|
||||||
|
/** Gibt den Schluessel der Anfrage zurueck, damit er eingetragen werden kann. */
|
||||||
|
freigeben: (id: string) => string | null;
|
||||||
|
ablehnen: (id: string) => boolean;
|
||||||
|
/** Leitung schliessen, z. B. nachdem der Schluessel neu gewuerfelt wurde. */
|
||||||
|
trennen: () => void;
|
||||||
/** Stand von OBS, wie ihn der PC meldet. */
|
/** Stand von OBS, wie ihn der PC meldet. */
|
||||||
melden: (roh: unknown) => void;
|
melden: (roh: unknown) => void;
|
||||||
antwort: (roh: unknown) => void;
|
antwort: (roh: unknown) => void;
|
||||||
@@ -66,15 +85,77 @@ export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFernsteuerung(bus: Bus): Fernsteuerung {
|
export type FernOptionen = {
|
||||||
|
/**
|
||||||
|
* Was der PC gleich nach dem Verbinden erfaehrt, z. B. die Adresse der
|
||||||
|
* Sound-Quelle mit Schluessel. Damit richtet er OBS selbst ein.
|
||||||
|
*/
|
||||||
|
willkommen?: () => Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createFernsteuerung(bus: Bus, fo: FernOptionen = {}): Fernsteuerung {
|
||||||
let leitung: http.ServerResponse | null = null;
|
let leitung: http.ServerResponse | null = null;
|
||||||
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
|
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
|
||||||
|
const anfragen = new Map<string, FernAnfrage & { key: string; zuletzt: number }>();
|
||||||
|
|
||||||
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
|
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
|
||||||
bus.publish({ kind: 'obs', ...stand });
|
bus.publish({ kind: 'obs', ...stand });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const aufraeumen = (): void => {
|
||||||
|
const jetzt = Date.now();
|
||||||
|
for (const [id, a] of anfragen) if (jetzt - a.zuletzt > ANFRAGE_MS) anfragen.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
anfragen(key, name, adresse) {
|
||||||
|
aufraeumen();
|
||||||
|
// Derselbe PC klopft alle paar Sekunden erneut an. Das ist dieselbe
|
||||||
|
// Anfrage, keine neue - sonst stuende er nach einer Minute zehnmal da.
|
||||||
|
for (const a of anfragen.values()) {
|
||||||
|
if (a.key === key) { a.zuletzt = Date.now(); return; }
|
||||||
|
}
|
||||||
|
if (anfragen.size >= MAX_ANFRAGEN) {
|
||||||
|
const aelteste = [...anfragen.values()].sort((x, y) => x.zuletzt - y.zuletzt)[0];
|
||||||
|
if (aelteste) anfragen.delete(aelteste.id);
|
||||||
|
}
|
||||||
|
const id = crypto.randomBytes(6).toString('hex');
|
||||||
|
const sauber = (s: string, n: number): string =>
|
||||||
|
s.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, n) || 'unbekannt';
|
||||||
|
anfragen.set(id, {
|
||||||
|
id, key, name: sauber(name, 40), adresse: sauber(adresse, 60),
|
||||||
|
seit: new Date().toISOString(), zuletzt: Date.now(),
|
||||||
|
});
|
||||||
|
log.info(`Stream-PC "${sauber(name, 40)}" (${sauber(adresse, 60)}) wartet auf Freigabe im Pult`);
|
||||||
|
bus.publish({ kind: 'fern-anfrage' });
|
||||||
|
},
|
||||||
|
|
||||||
|
offeneAnfragen() {
|
||||||
|
aufraeumen();
|
||||||
|
return [...anfragen.values()].map(({ id, name, adresse, seit }) => ({ id, name, adresse, seit }));
|
||||||
|
},
|
||||||
|
|
||||||
|
freigeben(id) {
|
||||||
|
aufraeumen();
|
||||||
|
const a = anfragen.get(id);
|
||||||
|
if (!a) return null;
|
||||||
|
// Es gibt nur einen Stream-PC. Wer freigibt, verwirft die anderen.
|
||||||
|
anfragen.clear();
|
||||||
|
log.info(`Stream-PC "${a.name}" freigegeben`);
|
||||||
|
bus.publish({ kind: 'fern-anfrage' });
|
||||||
|
return a.key;
|
||||||
|
},
|
||||||
|
|
||||||
|
ablehnen(id) {
|
||||||
|
const weg = anfragen.delete(id);
|
||||||
|
if (weg) bus.publish({ kind: 'fern-anfrage' });
|
||||||
|
return weg;
|
||||||
|
},
|
||||||
|
|
||||||
|
trennen() {
|
||||||
|
leitung?.end();
|
||||||
|
},
|
||||||
|
|
||||||
annehmen(req, res) {
|
annehmen(req, res) {
|
||||||
if (leitung) {
|
if (leitung) {
|
||||||
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
|
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
|
||||||
@@ -92,6 +173,8 @@ export function createFernsteuerung(bus: Bus): Fernsteuerung {
|
|||||||
leitung = res;
|
leitung = res;
|
||||||
log.info('Stream-PC verbunden');
|
log.info('Stream-PC verbunden');
|
||||||
veroeffentlichen({ ...LEER, pc: true });
|
veroeffentlichen({ ...LEER, pc: true });
|
||||||
|
const hallo = fo.willkommen?.();
|
||||||
|
if (hallo) res.write(`data: ${JSON.stringify({ art: 'einrichten', ...hallo })}\n\n`);
|
||||||
|
|
||||||
const beat = setInterval(
|
const beat = setInterval(
|
||||||
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
|
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { paths } from '#core/config.ts';
|
import { paths } from '#core/config.ts';
|
||||||
import { logger } from '#core/logger.ts';
|
import { logger } from '#core/logger.ts';
|
||||||
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
|
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
|
||||||
import { createZugang, type Zugang } from './auth.ts';
|
import { bearer, createZugang, type Zugang } from './auth.ts';
|
||||||
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
|
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
|
||||||
import { startDemo } from './demo.ts';
|
import { startDemo } from './demo.ts';
|
||||||
import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
|
import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
|
||||||
@@ -130,7 +130,14 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
|||||||
const lz: Laufzeit = {
|
const lz: Laufzeit = {
|
||||||
vorfuehrung: { abbruch: null },
|
vorfuehrung: { abbruch: null },
|
||||||
sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')),
|
sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')),
|
||||||
fern: createFernsteuerung(bus),
|
// Der PC bekommt beim Verbinden die Adresse der Sound-Quelle und legt sie
|
||||||
|
// damit selbst in OBS an. Der Overlay-Schluessel darf nur lesen, und der
|
||||||
|
// PC haette ihn ohnehin - er steht in jeder Browser-Source in OBS.
|
||||||
|
fern: createFernsteuerung(bus, {
|
||||||
|
willkommen: () => ({
|
||||||
|
soundsUrl: `${opts.publicUrl || `http://127.0.0.1:${opts.port}`}/sounds?key=${zugang.overlayKey()}`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
quellen: new Map(),
|
quellen: new Map(),
|
||||||
};
|
};
|
||||||
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
|
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
|
||||||
@@ -210,7 +217,18 @@ async function handle(
|
|||||||
// ---- Die Leitung zum Stream-PC. Eigener Schluessel im Kopf, nie in der
|
// ---- Die Leitung zum Stream-PC. Eigener Schluessel im Kopf, nie in der
|
||||||
// Adresse: wer ihn hat, schaltet Szenen um.
|
// Adresse: wer ihn hat, schaltet Szenen um.
|
||||||
if (p.startsWith('/fern/')) {
|
if (p.startsWith('/fern/')) {
|
||||||
if (!zugang.darfFern(req)) return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
|
if (!zugang.darfFern(req)) {
|
||||||
|
// Ein unbekannter Schluessel an der Leitung ist ein PC, der sich
|
||||||
|
// anmelden will. Er wartet auf "Freigeben" im Pult und fragt so lange
|
||||||
|
// nach. Alles andere unter /fern/ bleibt ohne gueltigen Schluessel zu.
|
||||||
|
const key = bearer(req.headers.authorization);
|
||||||
|
if (p === '/fern/befehle' && req.method === 'GET' && key && key.length >= 24 && key.length <= 128) {
|
||||||
|
fern.anfragen(key, String(req.headers['x-abdulive-rechner'] ?? ''), absender(req));
|
||||||
|
return send(res, 202, 'application/json; charset=utf-8',
|
||||||
|
JSON.stringify({ text: 'Wartet auf Freigabe im Pult.' }));
|
||||||
|
}
|
||||||
|
return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
|
||||||
|
}
|
||||||
if (p === '/fern/befehle' && req.method === 'GET') return fern.annehmen(req, res);
|
if (p === '/fern/befehle' && req.method === 'GET') return fern.annehmen(req, res);
|
||||||
if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') {
|
if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') {
|
||||||
let roh: unknown;
|
let roh: unknown;
|
||||||
@@ -285,6 +303,32 @@ async function handle(
|
|||||||
tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null,
|
tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (p === '/pult/fern') {
|
||||||
|
const liste = (code: number, text: string): void =>
|
||||||
|
send(res, code, 'application/json; charset=utf-8', JSON.stringify({
|
||||||
|
text, anfragen: fern.offeneAnfragen(), verbunden: fern.verbunden(),
|
||||||
|
}));
|
||||||
|
if (req.method === 'GET') return liste(200, '');
|
||||||
|
const form = await readForm(req);
|
||||||
|
const was = form.get('was');
|
||||||
|
if (was === 'freigeben') {
|
||||||
|
const key = fern.freigeben(form.get('id') ?? '');
|
||||||
|
if (!key) return liste(404, 'Die Anfrage ist abgelaufen. Der PC fragt gleich neu an.');
|
||||||
|
zugang.fernKeySetzen(key);
|
||||||
|
// Die alte Leitung (falls es eine gab) gehoert zu einem anderen Schluessel.
|
||||||
|
fern.trennen();
|
||||||
|
return liste(200, 'Freigegeben. Der Stream-PC verbindet sich in ein paar Sekunden.');
|
||||||
|
}
|
||||||
|
if (was === 'ablehnen') {
|
||||||
|
return liste(200, fern.ablehnen(form.get('id') ?? '') ? 'Abgelehnt.' : 'Schon weg.');
|
||||||
|
}
|
||||||
|
if (was === 'trennen') {
|
||||||
|
zugang.neuerFernKey();
|
||||||
|
fern.trennen();
|
||||||
|
return liste(200, 'Getrennt. Der PC muss neu freigegeben werden.');
|
||||||
|
}
|
||||||
|
return liste(400, 'Unbekannt.');
|
||||||
|
}
|
||||||
if (p === '/pult/obs' && req.method === 'POST') {
|
if (p === '/pult/obs' && req.method === 'POST') {
|
||||||
const form = await readForm(req);
|
const form = await readForm(req);
|
||||||
const befehl = befehlAus(form);
|
const befehl = befehlAus(form);
|
||||||
@@ -410,10 +454,15 @@ async function handle(
|
|||||||
|
|
||||||
if (p === '/schluessel/neu' && req.method === 'POST') {
|
if (p === '/schluessel/neu' && req.method === 'POST') {
|
||||||
zugang.neuerOverlayKey();
|
zugang.neuerOverlayKey();
|
||||||
|
// Der PC verbindet sich neu, bekommt die neue Adresse der Sound-Quelle
|
||||||
|
// und traegt sie in OBS ein. Die uebrigen Browser-Sources nicht - die hat
|
||||||
|
// jemand von Hand angelegt, und nur der weiss, wo sie liegen.
|
||||||
|
fern.trennen();
|
||||||
return redirect(res, '/');
|
return redirect(res, '/');
|
||||||
}
|
}
|
||||||
if (p === '/fernschluessel/neu' && req.method === 'POST') {
|
if (p === '/fernschluessel/neu' && req.method === 'POST') {
|
||||||
zugang.neuerFernKey();
|
zugang.neuerFernKey();
|
||||||
|
fern.trennen();
|
||||||
return redirect(res, '/#stream-pc');
|
return redirect(res, '/#stream-pc');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,6 +637,16 @@ export function sichererPfad(roh: string | null | undefined): string {
|
|||||||
return roh === '/pult' ? '/pult' : '/';
|
return roh === '/pult' ? '/pult' : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Woher eine Anfrage kommt. Hinter dem Proxy steht die echte Adresse im Kopf,
|
||||||
|
* die eigene Verbindung kommt immer vom Proxy. Nur zur Anzeige bei der
|
||||||
|
* Freigabe - nichts haengt daran, ob sie stimmt.
|
||||||
|
*/
|
||||||
|
function absender(req: http.IncomingMessage): string {
|
||||||
|
const kopf = req.headers['x-real-ip'] ?? String(req.headers['x-forwarded-for'] ?? '').split(',')[0];
|
||||||
|
return String(kopf || req.socket.remoteAddress || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function readBody(req: http.IncomingMessage, max: number): Promise<Buffer> {
|
function readBody(req: http.IncomingMessage, max: number): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -856,12 +915,16 @@ ${lzFeld}
|
|||||||
|
|
||||||
<h2 id="stream-pc">Stream-PC verbinden</h2>
|
<h2 id="stream-pc">Stream-PC verbinden</h2>
|
||||||
<p class="lead">Damit das Pult Szenen umschalten, Clips speichern und Ton stummschalten
|
<p class="lead">Damit das Pult Szenen umschalten, Clips speichern und Ton stummschalten
|
||||||
kann, wählt sich der Stream-PC hier ein. Einmal auf dem Stream-PC in PowerShell
|
kann, wählt sich der Stream-PC hier ein. Das macht er von selbst: beim ersten
|
||||||
ausführen, danach <code>abdulive ingest</code> neu starten:</p>
|
Start meldet er sich, und im Pult unter <a href="/pult#szenen">Szenen</a> erscheint
|
||||||
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
|
„Freigeben“. Danach legt er in OBS auch die Sound-Quelle selbst an.</p>
|
||||||
<button data-copy="fernbefehl">kopieren</button></p>
|
|
||||||
<p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${
|
<p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${
|
||||||
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p>
|
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p>
|
||||||
|
<details><summary class="hint">Von Hand verbinden, falls die Freigabe nicht klappt</summary>
|
||||||
|
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
|
||||||
|
<button data-copy="fernbefehl">kopieren</button></p>
|
||||||
|
<p class="hint">Auf dem Stream-PC in PowerShell, danach <code>abdulive ingest</code> neu starten.</p>
|
||||||
|
</details>
|
||||||
<form method="post" action="/fernschluessel/neu">
|
<form method="post" action="/fernschluessel/neu">
|
||||||
<button type="submit" style="margin-left:0">Schlüssel neu würfeln</button>
|
<button type="submit" style="margin-left:0">Schlüssel neu würfeln</button>
|
||||||
<span class="hint">Dieser Schlüssel schaltet OBS. Nie in eine Browser-Source eintragen,
|
<span class="hint">Dieser Schlüssel schaltet OBS. Nie in eine Browser-Source eintragen,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -100,8 +100,20 @@ einen Knopfdruck aus dem Speicher.
|
|||||||
| Sounds | Tasten fürs Soundboard. „+ Sound“ lädt MP3, WAV, OGG, WebM oder M4A vom Handy hoch (bis 8 MB). „Bearbeiten“: Name, Emoji, Farbe, Lautstärke, Reihenfolge, Löschen, am Handy anhören. „Stopp“ bricht alle Sounds ab. |
|
| Sounds | Tasten fürs Soundboard. „+ Sound“ lädt MP3, WAV, OGG, WebM oder M4A vom Handy hoch (bis 8 MB). „Bearbeiten“: Name, Emoji, Farbe, Lautstärke, Reihenfolge, Löschen, am Handy anhören. „Stopp“ bricht alle Sounds ab. |
|
||||||
| Szenen | OBS-Szenen umschalten, Clip aus dem Wiedergabepuffer speichern, Toneingänge stumm schalten. Braucht den Stream-PC (siehe unten). |
|
| Szenen | OBS-Szenen umschalten, Clip aus dem Wiedergabepuffer speichern, Toneingänge stumm schalten. Braucht den Stream-PC (siehe unten). |
|
||||||
| Stream | Liegestütze zählen, Vorlesen freigeben, „Sofort still“. |
|
| Stream | Liegestütze zählen, Vorlesen freigeben, „Sofort still“. |
|
||||||
|
| Chat | Der Twitch-Chat, **nur lesend**: Abzeichen, Emotes, GIFs, Erstchatter hervorgehoben, Abos, Raids und Belohnungen als eigene Zeile, von Mods Gelöschtes durchgestrichen. |
|
||||||
| Test | Dieselben Testknöpfe wie auf der Übersicht. |
|
| Test | Dieselben Testknöpfe wie auf der Übersicht. |
|
||||||
|
|
||||||
|
**Am Tablet** (ab 760 px Breite und 500 px Höhe) steht der Chat fest rechts
|
||||||
|
und links der gewählte Reiter, der Chat-Reiter entfällt. Ohne gemerkten
|
||||||
|
Reiter geht das Pult dort auf Szenen auf. Quer gehaltene Handys sind zu
|
||||||
|
niedrig dafür und behalten die Reiter.
|
||||||
|
|
||||||
|
Der Chat hängt am selben Ereignisstrom wie die Browser-Sources und schreibt
|
||||||
|
nie. Er braucht kein Bot-Konto und keine weiteren Rechte bei Twitch. Nach
|
||||||
|
dem Neuverbinden kommen die letzten 30 Nachrichten aus dem Schnappschuss,
|
||||||
|
im Pult stehen höchstens 250. Wer hochscrollt, bleibt dort; „Neue
|
||||||
|
Nachrichten“ springt zurück ans Ende.
|
||||||
|
|
||||||
Die Sonne oben rechts hält den Bildschirm an (Wake Lock), solange das Pult
|
Die Sonne oben rechts hält den Bildschirm an (Wake Lock), solange das Pult
|
||||||
offen ist.
|
offen ist.
|
||||||
|
|
||||||
@@ -114,15 +126,32 @@ Sounds gleichzeitig (`?max=`), `?laut=` dämpft alle.
|
|||||||
|
|
||||||
**Szenen** gehen über den Stream-PC, weil der Hub OBS nicht erreicht. Der PC
|
**Szenen** gehen über den Stream-PC, weil der Hub OBS nicht erreicht. Der PC
|
||||||
wählt sich mit eigenem Schlüssel beim Hub ein (`apps/ingest/src/fern.ts` und
|
wählt sich mit eigenem Schlüssel beim Hub ein (`apps/ingest/src/fern.ts` und
|
||||||
`apps/overlay-server/src/fern.ts`). Einmal auf dem Stream-PC:
|
`apps/overlay-server/src/fern.ts`). In OBS muss nur obs-websocket an sein -
|
||||||
|
dasselbe, was `!clip` braucht.
|
||||||
|
|
||||||
```
|
Einzurichten ist nichts. Beim ersten Start würfelt `ingest` einen Schlüssel
|
||||||
abdulive fern:verbinden https://abdu.dimension47.de <schlüssel>
|
und meldet sich bei `fern.url` (Vorgabe `https://abdu.dimension47.de`,
|
||||||
```
|
`FERN_URL=` schaltet ab). Im Pult unter Szenen steht dann „Stream-PC … möchte
|
||||||
|
sich verbinden“ mit **Freigeben**. Ein Tipp, nach ein paar Sekunden ist er da.
|
||||||
|
„Stream-PC trennen“ unten im Reiter würfelt den Schlüssel neu; danach muss er
|
||||||
|
neu freigegeben werden. `abdulive fern:verbinden <adresse> <schlüssel>` bleibt
|
||||||
|
als Weg von Hand (Befehl auf der Übersicht).
|
||||||
|
|
||||||
Der fertige Befehl steht auf der Übersicht unter „Stream-PC verbinden“.
|
Jeder Knopf wartet auf die Antwort von OBS und sagt ehrlich, wenn es nicht ging.
|
||||||
Danach `abdulive ingest` neu starten. Jeder Knopf wartet auf die Antwort von
|
|
||||||
OBS und sagt ehrlich, wenn es nicht ging.
|
**Die Sound-Quelle legt der PC selbst an.** Nach dem Verbinden schickt der Hub
|
||||||
|
die Adresse von `/sounds` mit Schlüssel, und `ingest` legt in OBS an:
|
||||||
|
|
||||||
|
- die Szene `abdulive Ton` mit der Browser-Source `abdulive Sounds` darin
|
||||||
|
(„Audio über OBS steuern“ an, beim Unsichtbarwerden nicht abschalten);
|
||||||
|
- in **jeder** Szene ein Verweis auf `abdulive Ton`. OBS gibt Ton nur aus
|
||||||
|
Quellen der gerade gezeigten Szene aus; so gibt es die Quelle einmal, sie
|
||||||
|
läuft beim Szenenwechsel durch und ist überall zu hören.
|
||||||
|
|
||||||
|
Neue Szenen bekommen den Verweis sofort, ein neuer Overlay-Schlüssel wird in
|
||||||
|
die Quelle nachgetragen. Die Ton-Szene taucht im Pult nicht als Szene auf, die
|
||||||
|
Quelle aber unter Ton - damit lässt sich das Soundboard stumm schalten. Wer den
|
||||||
|
Verweis aus einer Szene löscht, bekommt ihn beim nächsten Start zurück.
|
||||||
|
|
||||||
Die App-Symbole zeichnet `node scripts/make-icons.ts`.
|
Die App-Symbole zeichnet `node scripts/make-icons.ts`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 +
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "abdulive Pult",
|
"name": "abdulive Pult",
|
||||||
"short_name": "Pult",
|
"short_name": "Pult",
|
||||||
"description": "Soundboard, Szenen, Liegestütze und Vorlesen für den Stream von abdulive_.",
|
"description": "Soundboard, Szenen, Chat, Liegestütze und Vorlesen für den Stream von abdulive_.",
|
||||||
"lang": "de",
|
"lang": "de",
|
||||||
"id": "/pult",
|
"id": "/pult",
|
||||||
"start_url": "/pult",
|
"start_url": "/pult",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
],
|
],
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
|
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] },
|
||||||
|
{ "name": "Chat", "url": "/pult#chat", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -278,6 +278,17 @@ main {
|
|||||||
.ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; }
|
.ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; }
|
||||||
.ton-zeile.stumm svg { color: #ffb3b3; }
|
.ton-zeile.stumm svg { color: #ffb3b3; }
|
||||||
|
|
||||||
|
.anfragen { display: grid; gap: 12px; margin-bottom: 4px; }
|
||||||
|
.anfrage {
|
||||||
|
border-radius: var(--rund); padding: 16px; margin-bottom: 12px;
|
||||||
|
background: rgba(169, 112, 255, .1); border: 1px solid var(--lila);
|
||||||
|
}
|
||||||
|
.anfrage h2 { font-size: 18px; margin: 0 0 4px; }
|
||||||
|
.anfrage p { margin: 0 0 14px; color: var(--leise); font-size: 14px; }
|
||||||
|
.anfrage .reihe { display: flex; gap: 8px; }
|
||||||
|
.anfrage .reihe .primaer { flex: 1; min-height: 52px; font-size: 16px; }
|
||||||
|
.knopf.leise { margin-top: 28px; background: transparent; color: var(--leise); border-color: var(--linie); font-weight: 500; }
|
||||||
|
|
||||||
.leer-karte { text-align: center; padding: 28px 18px; }
|
.leer-karte { text-align: center; padding: 28px 18px; }
|
||||||
.leer-karte h2 { font-size: 18px; margin: 0 0 8px; }
|
.leer-karte h2 { font-size: 18px; margin: 0 0 8px; }
|
||||||
.leer-karte p { color: var(--leise); margin: 0; font-size: 14px; }
|
.leer-karte p { color: var(--leise); margin: 0; font-size: 14px; }
|
||||||
@@ -316,11 +327,85 @@ main {
|
|||||||
.gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
.gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
||||||
.gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; }
|
.gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Chat */
|
||||||
|
|
||||||
|
/* Ein fester Kasten zwischen Kopf und Reitern, damit nur die Liste
|
||||||
|
* scrollt und nicht die Seite: wer im Chat hochscrollt, soll den Kopf mit
|
||||||
|
* LIVE-Anzeige und Verbindungspunkt weiter sehen. */
|
||||||
|
.tab.chat.an { display: flex; }
|
||||||
|
.chat {
|
||||||
|
position: fixed; z-index: 10; left: 0; right: 0;
|
||||||
|
top: calc(var(--kopf-h) + var(--oben));
|
||||||
|
bottom: calc(var(--reiter-h) + var(--unten));
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--grund);
|
||||||
|
}
|
||||||
|
.chat-kopf {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 14px 16px 10px; border-bottom: 1px solid var(--linie);
|
||||||
|
}
|
||||||
|
.chat-kopf .abschnitt, .chat-kopf .klein { margin: 0; }
|
||||||
|
/* Wenige Nachrichten stehen unten wie in jedem Chat, nicht oben unter dem
|
||||||
|
* Kopf. margin-top: auto statt justify-content: flex-end - mit letzterem
|
||||||
|
* laesst sich in manchen Browsern nicht mehr nach oben scrollen. */
|
||||||
|
.chat-liste {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
||||||
|
padding: 8px 10px 14px; font-size: 15px; line-height: 1.42;
|
||||||
|
}
|
||||||
|
.chat-leer { position: absolute; top: 50%; left: 0; right: 0; text-align: center;
|
||||||
|
color: var(--leise); font-size: 14px; margin: 0; }
|
||||||
|
|
||||||
|
.cz { flex: none; padding: 5px 8px; border-radius: 8px; overflow-wrap: anywhere; }
|
||||||
|
.cz:first-child { margin-top: auto; }
|
||||||
|
.cz .zeit { color: var(--leise); font-size: 12px; font-variant-numeric: tabular-nums; margin-right: 6px; }
|
||||||
|
.cz .abz { height: 18px; width: auto; vertical-align: -3px; margin-right: 3px; }
|
||||||
|
.cz .wer { font-weight: 700; }
|
||||||
|
.cz .emote { height: 28px; width: auto; vertical-align: -8px; }
|
||||||
|
.cz .gif { display: block; max-width: min(100%, 220px); max-height: 140px; border-radius: 6px; margin: 4px 0 2px; }
|
||||||
|
.cz.erstmals { background: rgba(74, 222, 128, .1); box-shadow: inset 3px 0 0 var(--gruen); }
|
||||||
|
.cz.erstmals::before {
|
||||||
|
content: "Zum ersten Mal hier"; display: block; margin-bottom: 1px;
|
||||||
|
font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--gruen);
|
||||||
|
}
|
||||||
|
.cz.bits { background: rgba(255, 197, 61, .1); }
|
||||||
|
.cz.weg { opacity: .45; }
|
||||||
|
.cz.weg .text { text-decoration: line-through; }
|
||||||
|
.cz.system {
|
||||||
|
margin: 6px 0; font-size: 13px; color: var(--schrift);
|
||||||
|
background: rgba(169, 112, 255, .1); border: 1px solid rgba(169, 112, 255, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-runter {
|
||||||
|
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
|
||||||
|
padding: 9px 16px; border-radius: 999px; cursor: pointer;
|
||||||
|
font: 600 14px/1 var(--font); color: #fff;
|
||||||
|
background: var(--lila-tief); border: 1px solid var(--lila);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ab Tablet-Groesse: Chat fest rechts, links der gewaehlte Reiter. So
|
||||||
|
* stehen Szenen und Chat auf einem Bildschirm. Die Hoehe haelt quer
|
||||||
|
* gehaltene Handys heraus, dort bliebe fuer beides zu wenig. Muss in
|
||||||
|
* pult.js (`geteilt`) gleich lauten. */
|
||||||
|
@media (min-width: 760px) and (min-height: 500px) {
|
||||||
|
:root { --chat-b: clamp(300px, 36vw, 440px); }
|
||||||
|
main { max-width: none; margin: 0; padding-right: calc(var(--chat-b) + 16px); }
|
||||||
|
.tab.chat {
|
||||||
|
display: flex; left: auto; width: var(--chat-b); bottom: 0;
|
||||||
|
padding-bottom: var(--unten); border-left: 1px solid var(--linie); animation: none;
|
||||||
|
}
|
||||||
|
.chat-runter { bottom: calc(14px + var(--unten)); }
|
||||||
|
.reiter { right: var(--chat-b); }
|
||||||
|
#nav-chat { display: none; }
|
||||||
|
.meldung { left: calc((100% - var(--chat-b)) / 2); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ Reiter */
|
/* ------------------------------------------------------------ Reiter */
|
||||||
|
|
||||||
.reiter {
|
.reiter {
|
||||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||||
height: calc(var(--reiter-h) + var(--unten));
|
height: calc(var(--reiter-h) + var(--unten));
|
||||||
padding-bottom: var(--unten);
|
padding-bottom: var(--unten);
|
||||||
background: rgba(18, 14, 29, .94);
|
background: rgba(18, 14, 29, .94);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
|
|
||||||
<!-- ======================================================== Szenen -->
|
<!-- ======================================================== Szenen -->
|
||||||
<section id="tab-szenen" class="tab" aria-labelledby="nav-szenen">
|
<section id="tab-szenen" class="tab" aria-labelledby="nav-szenen">
|
||||||
|
<div id="anfragen" class="anfragen"></div>
|
||||||
<div id="obs-fehlt" class="karte leer-karte" hidden>
|
<div id="obs-fehlt" class="karte leer-karte" hidden>
|
||||||
<h2 id="obs-fehlt-titel">Stream-PC nicht verbunden</h2>
|
<h2 id="obs-fehlt-titel">Stream-PC nicht verbunden</h2>
|
||||||
<p id="obs-fehlt-text"></p>
|
<p id="obs-fehlt-text"></p>
|
||||||
@@ -83,6 +84,8 @@
|
|||||||
|
|
||||||
<h3 class="abschnitt">Ton</h3>
|
<h3 class="abschnitt">Ton</h3>
|
||||||
<div id="ton" class="ton"></div>
|
<div id="ton" class="ton"></div>
|
||||||
|
|
||||||
|
<button id="trennen" class="knopf breit leise" type="button">Stream-PC trennen</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -128,6 +131,18 @@
|
|||||||
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
|
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
|
||||||
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
|
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================================== Chat
|
||||||
|
Am Handy ein Reiter, ab Tablet-Breite steht er rechts daneben. -->
|
||||||
|
<section id="tab-chat" class="tab chat" aria-labelledby="nav-chat">
|
||||||
|
<div class="chat-kopf">
|
||||||
|
<h3 class="abschnitt">Chat</h3>
|
||||||
|
<span class="klein">nur lesen</span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-liste" class="chat-liste" role="log" aria-live="off"></div>
|
||||||
|
<p id="chat-leer" class="chat-leer">Noch keine Nachrichten.</p>
|
||||||
|
<button id="chat-runter" class="chat-runter" type="button" hidden>Neue Nachrichten ↓</button>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="meldung" class="meldung" role="status" aria-live="polite"></div>
|
<div id="meldung" class="meldung" role="status" aria-live="polite"></div>
|
||||||
@@ -140,12 +155,18 @@
|
|||||||
<button id="nav-szenen" data-tab="szenen" type="button">
|
<button id="nav-szenen" data-tab="szenen" type="button">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="13" rx="2"/><path d="M8 21h8"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="13" rx="2"/><path d="M8 21h8"/></svg>
|
||||||
Szenen
|
Szenen
|
||||||
|
<i id="nav-fern" class="punkt" hidden></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="nav-stream" data-tab="stream" type="button">
|
<button id="nav-stream" data-tab="stream" type="button">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
|
||||||
Stream
|
Stream
|
||||||
<i id="nav-tts" class="punkt" hidden></i>
|
<i id="nav-tts" class="punkt" hidden></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="nav-chat" data-tab="chat" type="button">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/></svg>
|
||||||
|
Chat
|
||||||
|
<i id="nav-chat-punkt" class="punkt" hidden></i>
|
||||||
|
</button>
|
||||||
<button id="nav-test" data-tab="test" type="button">
|
<button id="nav-test" data-tab="test" type="button">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg>
|
||||||
Test
|
Test
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* Das Pult: Soundboard, Szenen, Liegestuetze, Vorlesen - am Handy.
|
/* Das Pult: Soundboard, Szenen, Chat, Liegestuetze, Vorlesen - am Handy,
|
||||||
|
* ab Tablet-Groesse mit dem Chat daneben.
|
||||||
*
|
*
|
||||||
* Zwei Wege zum Hub:
|
* Zwei Wege zum Hub:
|
||||||
* - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei
|
* - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei
|
||||||
@@ -12,6 +13,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var $ = function (id) { return document.getElementById(id); };
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
/** Gleiche Grenze wie die Media Query in pult.css. */
|
||||||
|
var geteilt = window.matchMedia('(min-width: 760px) and (min-height: 500px)');
|
||||||
var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'];
|
var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'];
|
||||||
var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
|
var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
|
||||||
|
|
||||||
@@ -86,6 +89,9 @@
|
|||||||
// ------------------------------------------------------------ Reiter
|
// ------------------------------------------------------------ Reiter
|
||||||
|
|
||||||
function zeigeReiter(name) {
|
function zeigeReiter(name) {
|
||||||
|
// Ab Tablet-Breite steht der Chat ohnehin daneben, links bleibt dann
|
||||||
|
// Platz fuer das, womit man ihn zusammen braucht.
|
||||||
|
if (name === 'chat' && geteilt.matches) name = 'szenen';
|
||||||
var reiter = document.querySelectorAll('.reiter button');
|
var reiter = document.querySelectorAll('.reiter button');
|
||||||
for (var i = 0; i < reiter.length; i++) {
|
for (var i = 0; i < reiter.length; i++) {
|
||||||
reiter[i].classList.toggle('an', reiter[i].dataset.tab === name);
|
reiter[i].classList.toggle('an', reiter[i].dataset.tab === name);
|
||||||
@@ -94,6 +100,7 @@
|
|||||||
for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name);
|
for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name);
|
||||||
try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ }
|
try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ }
|
||||||
if (name === 'stream') ladeWartend();
|
if (name === 'stream') ladeWartend();
|
||||||
|
if (name === 'chat') chatGesehen();
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
document.querySelector('.reiter').addEventListener('click', function (e) {
|
document.querySelector('.reiter').addEventListener('click', function (e) {
|
||||||
@@ -369,8 +376,8 @@
|
|||||||
$('obs-da').hidden = fehlt;
|
$('obs-da').hidden = fehlt;
|
||||||
if (!o.pc) {
|
if (!o.pc) {
|
||||||
$('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden';
|
$('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden';
|
||||||
$('obs-fehlt-text').textContent = 'Auf dem Stream-PC muss abdulive ingest laufen und einmal mit ' +
|
$('obs-fehlt-text').textContent = 'Sobald abdulive auf dem Stream-PC läuft, meldet er sich hier ' +
|
||||||
'„abdulive fern:verbinden …“ eingerichtet sein. Der Befehl steht auf der Übersicht unter „Stream-PC verbinden“.';
|
'und du tippst einmal auf „Freigeben“. Läuft er schon, ist er vielleicht aus oder offline.';
|
||||||
} else if (!o.obs) {
|
} else if (!o.obs) {
|
||||||
$('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar';
|
$('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar';
|
||||||
$('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' +
|
$('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' +
|
||||||
@@ -436,6 +443,60 @@
|
|||||||
obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' });
|
obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Freigabe
|
||||||
|
|
||||||
|
// Ein Stream-PC, der sich zum ersten Mal meldet, wartet hier auf einen
|
||||||
|
// Tipp. Name und Adresse kommen vom PC selbst - eine Anfrage, die man nicht
|
||||||
|
// erwartet, lehnt man ab.
|
||||||
|
function zeigeAnfragen(liste) {
|
||||||
|
var box = $('anfragen');
|
||||||
|
box.textContent = '';
|
||||||
|
$('nav-fern').hidden = !liste.length;
|
||||||
|
liste.forEach(function (a) {
|
||||||
|
var k = el('div', 'anfrage');
|
||||||
|
k.appendChild(el('h2', '', 'Stream-PC „' + a.name + '“ möchte sich verbinden'));
|
||||||
|
var seit = new Date(a.seit);
|
||||||
|
k.appendChild(el('p', '', 'Von ' + a.adresse + ', seit ' +
|
||||||
|
seit.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) +
|
||||||
|
' Uhr. Danach steuert das Pult OBS auf diesem Rechner.'));
|
||||||
|
var reihe = el('div', 'reihe');
|
||||||
|
var ja = el('button', 'knopf primaer', 'Freigeben');
|
||||||
|
ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = a.id;
|
||||||
|
var nein = el('button', 'knopf', 'Ablehnen');
|
||||||
|
nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = a.id;
|
||||||
|
reihe.appendChild(ja); reihe.appendChild(nein);
|
||||||
|
k.appendChild(reihe);
|
||||||
|
box.appendChild(k);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ladeAnfragen() {
|
||||||
|
holen('/pult/fern').then(function (b) { zeigeAnfragen(b.anfragen || []); }, function () { /* still */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
$('anfragen').addEventListener('click', function (e) {
|
||||||
|
var b = e.target.closest('button[data-was]');
|
||||||
|
if (!b) return;
|
||||||
|
tippen();
|
||||||
|
mitWarten(b, rufen('/pult/fern', { was: b.dataset.was, id: b.dataset.id })).then(function (r) {
|
||||||
|
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||||
|
if (r.anfragen) zeigeAnfragen(r.anfragen);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('trennen').addEventListener('click', function () {
|
||||||
|
var b = $('trennen');
|
||||||
|
if (!b.dataset.sicher) {
|
||||||
|
b.dataset.sicher = '1';
|
||||||
|
b.textContent = 'Wirklich trennen? Danach neu freigeben.';
|
||||||
|
setTimeout(function () { b.dataset.sicher = ''; b.textContent = 'Stream-PC trennen'; }, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
b.dataset.sicher = '';
|
||||||
|
b.textContent = 'Stream-PC trennen';
|
||||||
|
rufen('/pult/fern', { was: 'trennen' }).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); });
|
||||||
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------ Liegestuetze
|
// ------------------------------------------------------------ Liegestuetze
|
||||||
|
|
||||||
function zeigeLz(st) {
|
function zeigeLz(st) {
|
||||||
@@ -504,6 +565,173 @@
|
|||||||
rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); });
|
rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Chat
|
||||||
|
|
||||||
|
// Nur lesend. Die Nachrichten kommen ueber denselben Ereignisstrom wie bei
|
||||||
|
// der Chat-Browser-Source; das Pult schreibt nie in den Chat und braucht
|
||||||
|
// dafuer weder Bot-Konto noch weitere Rechte bei Twitch.
|
||||||
|
var CHAT_MAX = 250;
|
||||||
|
var chatListe = $('chat-liste');
|
||||||
|
/** Steht die Liste am Ende? Dann wandert sie mit, sonst nicht. */
|
||||||
|
var chatKlebt = true;
|
||||||
|
|
||||||
|
/* Wie in chat.html: dunkle Twitch-Farben aufhellen statt verwerfen. */
|
||||||
|
function lesbar(hex) {
|
||||||
|
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) return '#c9a7ff';
|
||||||
|
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16),
|
||||||
|
b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
var hell = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
if (hell >= 0.5) return hex;
|
||||||
|
var f = 0.5 / Math.max(hell, 0.08);
|
||||||
|
return '#' + [r, g, b].map(function (v) {
|
||||||
|
return Math.min(255, Math.round(v * f)).toString(16).padStart(2, '0');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text mit Emotes als Knoten. Ersetzt wird der Name als ganzes Wort,
|
||||||
|
* wie in client.js - Twitch liefert keine Positionen. */
|
||||||
|
function emoteKnoten(ziel, text, emotes) {
|
||||||
|
var ids = {};
|
||||||
|
(emotes || []).forEach(function (e) { if (e && e.id && e.name) ids[e.name] = e.id; });
|
||||||
|
text.split(/(\s+)/).forEach(function (wort) {
|
||||||
|
if (!wort) return;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(ids, wort)) {
|
||||||
|
ziel.appendChild(document.createTextNode(wort));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var img = el('img', 'emote');
|
||||||
|
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||||
|
encodeURIComponent(ids[wort]) + '/default/dark/2.0';
|
||||||
|
img.alt = wort;
|
||||||
|
img.title = wort;
|
||||||
|
ziel.appendChild(img);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GIFs kommen mit Zeichenbereich und ersetzen ihren Platzhalter im Text. */
|
||||||
|
function textKnoten(ziel, text, gifs, emotes) {
|
||||||
|
var zeichen = Array.from(text);
|
||||||
|
var pos = 0;
|
||||||
|
(gifs || []).slice().sort(function (a, b) { return a.from - b.from; }).forEach(function (g) {
|
||||||
|
if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') return;
|
||||||
|
if (g.from > pos) emoteKnoten(ziel, zeichen.slice(pos, g.from).join(''), emotes);
|
||||||
|
var img = el('img', 'gif');
|
||||||
|
img.src = g.url;
|
||||||
|
img.alt = 'GIF';
|
||||||
|
img.onerror = function () { img.replaceWith(document.createTextNode('[GIF]')); };
|
||||||
|
ziel.appendChild(img);
|
||||||
|
pos = Math.max(pos, g.to + 1);
|
||||||
|
});
|
||||||
|
if (pos < zeichen.length) emoteKnoten(ziel, zeichen.slice(pos).join(''), emotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uhrzeit(ts) {
|
||||||
|
var d = ts ? new Date(ts) : new Date();
|
||||||
|
if (isNaN(d.getTime())) d = new Date();
|
||||||
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatSichtbar() {
|
||||||
|
return geteilt.matches || $('tab-chat').classList.contains('an');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nachUnten() {
|
||||||
|
chatListe.scrollTop = chatListe.scrollHeight;
|
||||||
|
chatKlebt = true;
|
||||||
|
$('chat-runter').hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatGesehen() {
|
||||||
|
$('nav-chat-punkt').hidden = true;
|
||||||
|
nachUnten();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatAnhaengen(zeile, still) {
|
||||||
|
chatListe.appendChild(zeile);
|
||||||
|
while (chatListe.children.length > CHAT_MAX) chatListe.firstChild.remove();
|
||||||
|
$('chat-leer').hidden = true;
|
||||||
|
if (chatKlebt) nachUnten();
|
||||||
|
else $('chat-runter').hidden = false;
|
||||||
|
if (!still && !chatSichtbar()) $('nav-chat-punkt').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatNachricht(ev, still) {
|
||||||
|
if (!ev || !ev.id) return;
|
||||||
|
if (chatListe.querySelector('[data-id="' + CSS.escape(ev.id) + '"]')) return;
|
||||||
|
var z = el('div', 'cz' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : ''));
|
||||||
|
z.dataset.id = ev.id;
|
||||||
|
z.dataset.user = ev.userId || '';
|
||||||
|
z.appendChild(el('span', 'zeit', uhrzeit(ev.ts)));
|
||||||
|
(ev.badges || []).forEach(function (b) {
|
||||||
|
// Nur Twitchs eigene Bildadressen, wie in chat.html.
|
||||||
|
if (!b || !/^https:\/\/static-cdn\.jtvnw\.net\//.test(b.url)) return;
|
||||||
|
var img = el('img', 'abz');
|
||||||
|
img.src = b.url;
|
||||||
|
img.alt = b.title || '';
|
||||||
|
img.title = b.title || '';
|
||||||
|
z.appendChild(img);
|
||||||
|
});
|
||||||
|
var wer = el('span', 'wer', ev.name || ev.login || '?');
|
||||||
|
wer.style.color = lesbar(ev.color);
|
||||||
|
z.appendChild(wer);
|
||||||
|
z.appendChild(document.createTextNode(': '));
|
||||||
|
var text = el('span', 'text');
|
||||||
|
textKnoten(text, String(ev.text || ''), ev.gifs, ev.emotes);
|
||||||
|
z.appendChild(text);
|
||||||
|
chatAnhaengen(z, still);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was im Stream passiert, als eigene Zeile zwischen den Nachrichten.
|
||||||
|
* Erstchatter und Bits fehlen bewusst: die zeigt schon die Nachricht. */
|
||||||
|
function alarmText(a) {
|
||||||
|
var n = a.name || 'Jemand';
|
||||||
|
switch (a.type) {
|
||||||
|
case 'sub': return n + ' hat abonniert' + (a.tier ? ' (Stufe ' + String(a.tier).charAt(0) + ')' : '');
|
||||||
|
case 'resub': return n + ' abonniert weiter' + (a.months ? ', seit ' + a.months + ' Monaten' : '');
|
||||||
|
case 'subgift': return n + ' verschenkt ein Abo' + (a.recipient ? ' an ' + a.recipient : '');
|
||||||
|
case 'massgift': return n + ' verschenkt ' + (a.amount || 'mehrere') + ' Abos';
|
||||||
|
case 'raid': return 'Raid von ' + n + (a.amount ? ' mit ' + a.amount + ' Zuschauern' : '');
|
||||||
|
case 'follow': return n + ' folgt jetzt';
|
||||||
|
case 'reward': return n + ' löst „' + (a.reward || 'Belohnung') + '“ ein';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatAlarm(a) {
|
||||||
|
var text = alarmText(a);
|
||||||
|
if (!text) return;
|
||||||
|
var z = el('div', 'cz system');
|
||||||
|
z.appendChild(el('span', 'zeit', uhrzeit()));
|
||||||
|
z.appendChild(document.createTextNode(text + (a.message ? ': ' + a.message : '')));
|
||||||
|
chatAnhaengen(z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Von einem Mod geloescht: durchstreichen statt verschwinden lassen,
|
||||||
|
* damit man am Pult sieht, dass da etwas war. */
|
||||||
|
function chatLoeschen(ev) {
|
||||||
|
var zeilen = chatListe.querySelectorAll('.cz:not(.system)');
|
||||||
|
for (var i = 0; i < zeilen.length; i++) {
|
||||||
|
var z = zeilen[i];
|
||||||
|
if ((!ev.messageId && !ev.userId) ||
|
||||||
|
(ev.messageId && z.dataset.id === ev.messageId) ||
|
||||||
|
(ev.userId && z.dataset.user === ev.userId)) z.classList.add('weg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatListe.addEventListener('scroll', function () {
|
||||||
|
chatKlebt = chatListe.scrollHeight - chatListe.scrollTop - chatListe.clientHeight < 40;
|
||||||
|
if (chatKlebt) $('chat-runter').hidden = true;
|
||||||
|
}, { passive: true });
|
||||||
|
// Emotes und GIFs laden nach und machen die Zeile hoeher. Ohne das
|
||||||
|
// rutscht die neueste Nachricht unter den Rand.
|
||||||
|
chatListe.addEventListener('load', function () { if (chatKlebt) nachUnten(); }, true);
|
||||||
|
$('chat-runter').addEventListener('click', function () { tippen(); nachUnten(); });
|
||||||
|
|
||||||
|
geteilt.addEventListener('change', function () {
|
||||||
|
if (geteilt.matches && $('tab-chat').classList.contains('an')) zeigeReiter('szenen');
|
||||||
|
if (geteilt.matches) chatGesehen();
|
||||||
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------ Test
|
// ------------------------------------------------------------ Test
|
||||||
|
|
||||||
function baueTest(knoepfe, demoLaeuft) {
|
function baueTest(knoepfe, demoLaeuft) {
|
||||||
@@ -574,13 +802,20 @@
|
|||||||
zeigeStream(ev.stream);
|
zeigeStream(ev.stream);
|
||||||
if (ev.obs) zeigeObs(ev.obs);
|
if (ev.obs) zeigeObs(ev.obs);
|
||||||
if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze);
|
if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze);
|
||||||
|
// Nach dem Neuverbinden: was fehlt, nachtragen. Schon Gezeigtes
|
||||||
|
// bleibt stehen, sonst waere der Rueckblick bei jedem Abriss weg.
|
||||||
|
(ev.chat || []).forEach(function (m) { chatNachricht(m, true); });
|
||||||
break;
|
break;
|
||||||
|
case 'chat': chatNachricht(ev, false); break;
|
||||||
|
case 'alert': chatAlarm(ev); break;
|
||||||
|
case 'clear': chatLoeschen(ev); break;
|
||||||
case 'stream': zeigeStream(ev); break;
|
case 'stream': zeigeStream(ev); break;
|
||||||
case 'obs': zeigeObs(ev); break;
|
case 'obs': zeigeObs(ev); break;
|
||||||
case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break;
|
case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break;
|
||||||
case 'sound': knall(ev.id); break;
|
case 'sound': knall(ev.id); break;
|
||||||
case 'sounds-geaendert': ladeSounds(); break;
|
case 'sounds-geaendert': ladeSounds(); break;
|
||||||
case 'tts': ladeWartend(); break;
|
case 'tts': ladeWartend(); break;
|
||||||
|
case 'fern-anfrage': ladeAnfragen(); break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -611,6 +846,7 @@
|
|||||||
ladeWartend();
|
ladeWartend();
|
||||||
}, function () { /* abgemeldet oder weg - zeigt sich oben */ });
|
}, function () { /* abgemeldet oder weg - zeigt sich oben */ });
|
||||||
ladeSounds();
|
ladeSounds();
|
||||||
|
ladeAnfragen();
|
||||||
verbinde();
|
verbinde();
|
||||||
wachHolen();
|
wachHolen();
|
||||||
|
|
||||||
@@ -628,6 +864,8 @@
|
|||||||
// dem zuletzt benutzten.
|
// dem zuletzt benutzten.
|
||||||
var gemerkt = location.hash.slice(1) || null;
|
var gemerkt = location.hash.slice(1) || null;
|
||||||
if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } }
|
if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } }
|
||||||
|
// Am Tablet ist der naheliegende Anfang Szenen neben dem Chat.
|
||||||
|
if (!gemerkt && geteilt.matches) gemerkt = 'szenen';
|
||||||
if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt);
|
if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt);
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
|
|||||||
@@ -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
|
mastodon: false
|
||||||
instagramStoryImage: false
|
instagramStoryImage: false
|
||||||
|
|
||||||
community:
|
|
||||||
greetings:
|
|
||||||
enabled: true
|
|
||||||
minSecondsBetween: 20
|
|
||||||
maxPerStream: 10
|
|
||||||
returningAfterDays: 21
|
|
||||||
milestones: [5, 10, 25, 50, 100]
|
|
||||||
suppressWhileStreamerSpeaking: true
|
|
||||||
overlayHint: true
|
|
||||||
|
|
||||||
raidScout:
|
|
||||||
minViewers: 5
|
|
||||||
maxViewers: 30
|
|
||||||
minUptimeMinutes: 30
|
|
||||||
maxUptimeMinutes: 300
|
|
||||||
languages: [de]
|
|
||||||
topN: 5
|
|
||||||
|
|
||||||
recap:
|
|
||||||
enabled: true
|
|
||||||
delayMinutesAfterOffline: 10
|
|
||||||
minStreamMinutes: 20
|
|
||||||
hideViewerNumbersIfWeak: true
|
|
||||||
topChatters: 3
|
|
||||||
topClips: 2
|
|
||||||
|
|
||||||
loyalty:
|
|
||||||
enabled: false # spaetere Ausbaustufe
|
|
||||||
# Kanalpunkt-Belohnungen und was sie ausloesen.
|
# Kanalpunkt-Belohnungen und was sie ausloesen.
|
||||||
#
|
#
|
||||||
# Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein-
|
# Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein-
|
||||||
|
|||||||
+11
-12
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
## Bild
|
## Bild
|
||||||
|
|
||||||
|
Stand vor dem Umzug in den Hub (ADR-0008). Raid-Scout, Auswertung,
|
||||||
|
VOD-Kapitel, Community-Gedaechtnis und Recap sind seit 2026-10-27 gestrichen.
|
||||||
|
|
||||||
```
|
```
|
||||||
Twitch OBS
|
Twitch OBS
|
||||||
┌──────────────────┐ ┌───────────┐
|
┌──────────────────┐ ┌───────────┐
|
||||||
@@ -20,19 +23,15 @@
|
|||||||
┌─────────────────┐ ┌──────────────────────┐
|
┌─────────────────┐ ┌──────────────────────┐
|
||||||
│ SQLite │ │ Event-Bus │
|
│ SQLite │ │ Event-Bus │
|
||||||
│ data/db/ │ │ (in-proc + WS 4790) │
|
│ data/db/ │ │ (in-proc + WS 4790) │
|
||||||
└────────┬────────┘ └──────────┬───────────┘
|
└─────────────────┘ └──────────┬───────────┘
|
||||||
│ │
|
|
||||||
┌───────────┬───────┼───────────┬───────────┼───────────┬──────────┐
|
|
||||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
|
||||||
dashboard recap raid-scout vod-chapters community overlay- clipper
|
|
||||||
server │
|
|
||||||
│ │
|
|
||||||
overlay-web ffmpeg
|
|
||||||
(Browser- whisper
|
|
||||||
Source) │
|
|
||||||
data/clips
|
|
||||||
│
|
│
|
||||||
broadcaster
|
┌─────────┴─────────┐
|
||||||
|
▼ ▼
|
||||||
|
overlay-server clipper-Kette
|
||||||
|
(Pult, Sounds) ffmpeg, whisper
|
||||||
|
│ │
|
||||||
|
overlay-web data/clips
|
||||||
|
(Browser-Source)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prozesse
|
## Prozesse
|
||||||
|
|||||||
+4
-17
@@ -29,24 +29,11 @@ und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
|
|||||||
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
|
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
|
||||||
automatisch angekündigt.
|
automatisch angekündigt.
|
||||||
|
|
||||||
## M4 — Auswertung (Woche 5–6)
|
## M4 bis M6 — gestrichen
|
||||||
|
|
||||||
- [ ] `apps/dashboard`: Stream-Zeitachse, Abgangs-Analyse
|
Auswertung (`apps/dashboard`), Netzwerk (`apps/raid-scout`) und Recap
|
||||||
- [ ] Titel-/Kategorie-Performance mit Stichprobenhinweis
|
(`apps/recap`) sind seit 2026-10-27 gestrichen, samt Spezifikationen. Der Hub
|
||||||
- [ ] Kohortenansicht neu vs. wiederkehrend
|
bleibt bei OBS-Anbindung, Overlay, Pult, Kanalpunkten und Discord.
|
||||||
- [ ] Betriebsstatus-Seite
|
|
||||||
- [ ] Spike-Detektor an echten Daten kalibrieren und scharf schalten
|
|
||||||
|
|
||||||
## M5 — Netzwerk (Woche 7)
|
|
||||||
|
|
||||||
- [ ] `apps/raid-scout`: Kandidatensuche, Scoring, `!raidziel`
|
|
||||||
- [ ] Raid-Historie und Erwiderungs-Erinnerung
|
|
||||||
- [ ] Overlay-Panel für das Streamende
|
|
||||||
|
|
||||||
## M6 — Recap (Woche 8)
|
|
||||||
|
|
||||||
- [ ] `apps/recap`: Discord-Post nach Streamende
|
|
||||||
- [ ] Clip-Einbettung, Erstchatter-Begrüßung, Wochenvergleich
|
|
||||||
|
|
||||||
## Später
|
## Später
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -260,8 +260,8 @@ in unter zwei Minuten entscheiden, welche Clips hochgeladen werden.
|
|||||||
|
|
||||||
Der IRC-Client aus S1 liegt ohnehin da, das `first-msg`-Tag ebenso. Eine
|
Der IRC-Client aus S1 liegt ohnehin da, das `first-msg`-Tag ebenso. Eine
|
||||||
Begrüßung für Erstchatter, eine für Rückkehrer nach mehr als 21 Tagen, mit
|
Begrüßung für Erstchatter, eine für Rückkehrer nach mehr als 21 Tagen, mit
|
||||||
Cooldown und Obergrenze pro Stream. Details in
|
Cooldown und Obergrenze pro Stream. (Die ausführliche Spezifikation lag in
|
||||||
[`projects/community-memory.md`](projects/community-memory.md).
|
`projects/community-memory.md` und ist mit dem Community-Gedächtnis gestrichen.)
|
||||||
|
|
||||||
Diese Scheibe steht am Ende, weil sie unabhängig ist und notfalls entfällt.
|
Diese Scheibe steht am Ende, weil sie unabhängig ist und notfalls entfällt.
|
||||||
Sie kostet fast nichts und ist das Einzige im MVP, das die Zuschauer direkt
|
Sie kostet fast nichts und ist das Einzige im MVP, das die Zuschauer direkt
|
||||||
|
|||||||
+80
-27
@@ -1,6 +1,6 @@
|
|||||||
# Übergabe
|
# Übergabe
|
||||||
|
|
||||||
Stand **2026-10-18**, letztes gebautes Paket 0.5.4. 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,25 +130,49 @@ 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 gebaut, **noch nicht ausgerollt und nicht an echtem OBS
|
Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.7. **Seit 2026-10-27
|
||||||
geprüft**. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit
|
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,
|
Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen,
|
||||||
Vorlese-Freigabe und den Testknöpfen. Beschreibung in
|
Vorlese-Freigabe und den Testknöpfen. Beschreibung in
|
||||||
[`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult).
|
[`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult).
|
||||||
|
|
||||||
|
**Seit 2026-10-27 mit Chat, nur lesend.** Eigener Reiter am Handy; ab
|
||||||
|
Tablet-Größe (760×500) steht der Chat fest rechts neben dem gewählten
|
||||||
|
Reiter, also etwa Szenen und Chat auf einem Bildschirm. Reiner Client-Code in
|
||||||
|
`pult.js`: der Chat kam schon immer über den Ereignisstrom des Pults, es
|
||||||
|
braucht weder neue Routen noch Rechte. Neu am Bus ist nur `ts` an der
|
||||||
|
Chatzeile, für die Uhrzeit. Geprüft im Browser mit `overlay --demo` in vier
|
||||||
|
Größen (Handy hoch und quer, iPad hoch und quer); auf einem echten Tablet
|
||||||
|
und im Stream noch nicht.
|
||||||
|
|
||||||
- **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf
|
- **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf
|
||||||
dem Hub unter `<db>/sounds/`, gespielt wird über die neue Browser-Source
|
dem Hub unter `<db>/sounds/`, gespielt wird über die Browser-Source
|
||||||
`/sounds` (muss in OBS eingetragen werden, mit Ton).
|
`abdulive Sounds`, die `ingest` selbst in OBS anlegt.
|
||||||
- **Szenen** brauchen die neue Leitung Stream-PC → Hub: einmal
|
- **Szenen** laufen über die Leitung Stream-PC → Hub. Der PC meldet sich
|
||||||
`abdulive fern:verbinden <adresse> <schlüssel>` auf dem Stream-PC (Befehl
|
beim ersten Start von selbst, die Freigabe ist ein Tipp im Pult. In OBS
|
||||||
steht auf der Übersicht), dann läuft sie in `abdulive ingest` mit. Das
|
muss nur obs-websocket an sein.
|
||||||
heißt auch: **ein neues Paket für den Stream-PC** ist nötig, nicht nur
|
- **Restrisiko der Freigabe:** Anfragen kann jeder, der die Adresse kennt.
|
||||||
ein Ausrollen des Hubs.
|
Freigeben kann nur, wer im Pult angemeldet ist, und das Pult zeigt Name und
|
||||||
- Geprüft: 311 Tests, Typprüfung, ein Durchlauf im Browser gegen einen
|
Adresse des Rechners. Wer etwas freigibt, das er nicht erwartet, gibt einem
|
||||||
nachgebauten Stream-PC mit falschem OBS (Szenenwechsel, Stummschalten,
|
Fremden Szenen, Stummschalter und Clip-Knopf - nicht mehr.
|
||||||
abgelehnte Szene, Hochladen, Abspielen in der Sound-Quelle). Ungeprüft:
|
- Geprüft: 319 Tests, Typprüfung, Durchläufe im Browser gegen einen
|
||||||
echtes OBS, iPhone/Android-Installation, der Weg durch Nginx Proxy Manager
|
nachgebauten Stream-PC mit falschem OBS (Freigabe, Einrichten der
|
||||||
für die lange offene `/fern/befehle`-Leitung und Uploads bis 8 MB.
|
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
|
## Kanalpunkte: „Nachricht vorlesen" läuft
|
||||||
|
|
||||||
@@ -233,7 +266,7 @@ aufgetragen wird er mit `abdulive discord:setup`. Wiederholbar: gefunden wird
|
|||||||
nichts, gelöscht wird nie.
|
nichts, gelöscht wird nie.
|
||||||
|
|
||||||
Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für
|
Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für
|
||||||
[Go-Live](projects/go-live-broadcaster.md), [Recap](projects/stream-recap.md)
|
[Go-Live](projects/go-live-broadcaster.md), Recap (inzwischen gestrichen)
|
||||||
und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher
|
und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher
|
||||||
unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge
|
unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge
|
||||||
baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten.
|
baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten.
|
||||||
@@ -347,15 +380,22 @@ 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.
|
||||||
|
|
||||||
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. **Die Pipeline
|
`.gitea/workflows/release.yml` macht das beim Tag automatisch. **Seit
|
||||||
ist noch nie gelaufen**, bisher wurde von Hand veröffentlicht. Der Runner
|
2026-10-26 läuft sie** (0.5.6, danach 0.5.7). Der erste Lauf (`v0.5.5`) scheiterte daran,
|
||||||
(`ubuntu-latest`) steht.
|
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.
|
||||||
|
|
||||||
## Windows-Fallen, die schon zugeschlagen haben
|
## Windows-Fallen, die schon zugeschlagen haben
|
||||||
|
|
||||||
@@ -450,7 +490,7 @@ Umlaute und das Trennzeichen `│` (U+2502) bleiben unverändert stehen.
|
|||||||
dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von
|
dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von
|
||||||
oben.
|
oben.
|
||||||
|
|
||||||
## Die drei Dinge, die Abdu tun muss
|
## Was Abdu tun muss
|
||||||
|
|
||||||
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
|
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
|
||||||
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
|
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
|
||||||
@@ -475,6 +515,9 @@ oben.
|
|||||||
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
|
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
|
||||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
||||||
`abdulive calibrate` zur Kontrolle.
|
`abdulive calibrate` zur Kontrolle.
|
||||||
|
4. ~~**Stream-PC im Pult freigeben.**~~ Erledigt am 2026-10-27, ebenso
|
||||||
|
obs-websocket und das Passwort aus Punkt 2. Offen aus Punkt 2 bleiben
|
||||||
|
Wiedergabepuffer, Hotkey und Audiospuren - nicht nachgeprüft.
|
||||||
|
|
||||||
Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
|
Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
|
||||||
Chat anonym mit und kann nichts senden.
|
Chat anonym mit und kann nichts senden.
|
||||||
@@ -503,6 +546,12 @@ in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
|
|||||||
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
|
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
|
||||||
malt. Das war vorher nicht zu erraten.
|
malt. Das war vorher nicht zu erraten.
|
||||||
|
|
||||||
|
**Das Pult an Abdus OBS abnehmen.** Die Freigabe ist erteilt. Jetzt nachsehen, ob die
|
||||||
|
Szene `abdulive Ton` mit der Quelle `abdulive Sounds` angelegt und in jede
|
||||||
|
Szene eingehängt ist, ob ein Sound im Stream zu hören ist und ob Szenenwechsel
|
||||||
|
und Stummschalter greifen. Das fällt mit der Abnahme von S2 zusammen, beides
|
||||||
|
braucht nur ein laufendes OBS.
|
||||||
|
|
||||||
**Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
|
**Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
|
||||||
`abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
|
`abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
|
||||||
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
|
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
|
||||||
@@ -512,11 +561,15 @@ Wirkung.
|
|||||||
als Entwurf geht darin auf, die Statusleiste soll ersetzt werden. Danach die
|
als Entwurf geht darin auf, die Statusleiste soll ersetzt werden. Danach die
|
||||||
Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten.
|
Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten.
|
||||||
|
|
||||||
**`apps/recap` bauen.** Angefangen, aber nicht committet: `apps/recap/src/recap.ts`
|
**Recap und die übrigen geplanten Werkzeuge sind gestrichen** (2026-10-27).
|
||||||
und Ergänzungen in `packages/db/src/repos.ts` liegen nur im Arbeitsstand
|
Raid-Scout, Auswertung, VOD-Kapitel, Community-Gedächtnis, Punkteshop und
|
||||||
(die Typprüfung meckert dort noch über `url: undefined`). Der Webhook liegt
|
Recap sind samt Platzhaltern, Spezifikationen und Konfigurationsabschnitten
|
||||||
bereit, der Hub hat die Daten. Seit 2026-10-04 wird aus einem sauberen
|
entfernt. Der Hub schreibt keine Chatnachrichten und Zuschauerproben mehr in
|
||||||
Checkout ausgerollt, der halbe Recap liegt also nicht mehr im LXC.
|
die Datenbank, gelesen haben die nur diese Werkzeuge; die Tabellen bleiben
|
||||||
|
stehen. Der angefangene Recap liegt nur im Git-Stash
|
||||||
|
`recap WIP (vor dem Rueckbau 2026-10-27)` des Rechners, auf dem er
|
||||||
|
angefangen wurde. Der Webhook `recapWebhook` steht weiter im Discord-Bauplan,
|
||||||
|
weil das Setup-Skript nie löscht.
|
||||||
|
|
||||||
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
|
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
|
||||||
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
||||||
@@ -536,7 +589,7 @@ hörbar („Danke" wurde „Dank es"), DejaVu Sans ist unauffällig, und das
|
|||||||
fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs
|
fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs
|
||||||
konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
|
konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
|
||||||
|
|
||||||
**Die CI ist geschrieben, aber ungetestet.** Beim ersten Tag zusehen.
|
**Die CI läuft, aber nur einmal erfolgreich.** Beim nächsten Tag kurz hinsehen.
|
||||||
|
|
||||||
**Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation.
|
**Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation.
|
||||||
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
|
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
|
||||||
|
|||||||
@@ -6,13 +6,7 @@ Entscheidungen, Fallstricke, Abnahmekriterien.
|
|||||||
| # | Dokument | App |
|
| # | Dokument | App |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| — | [Ingest-Daemon](00-ingest.md) | `apps/ingest` |
|
| — | [Ingest-Daemon](00-ingest.md) | `apps/ingest` |
|
||||||
| 1 | [Clip-to-Shorts](clip-shorts.md) | `apps/clipper` |
|
| 1 | [Clip-to-Shorts](clip-shorts.md) | `apps/ingest` |
|
||||||
| 2 | [Go-Live-Broadcaster](go-live-broadcaster.md) | `apps/broadcaster` |
|
| 2 | [Go-Live-Broadcaster](go-live-broadcaster.md) | `apps/broadcaster` |
|
||||||
| 3 | [Raid- und Netzwerk-Tool](raid-network.md) | `apps/raid-scout` |
|
|
||||||
| 4 | [Analytics-Dashboard](analytics.md) | `apps/dashboard` |
|
|
||||||
| 5 | [VOD-Kapitel](vod-chapters.md) | `apps/vod-chapters` |
|
|
||||||
| 6 | [Community-Gedaechtnis](community-memory.md) | `apps/community` |
|
|
||||||
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
|
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
|
||||||
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
|
|
||||||
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
|
|
||||||
| — | [Discord-Community](discord-community.md) | `packages/discord` |
|
| — | [Discord-Community](discord-community.md) | `packages/discord` |
|
||||||
@@ -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.
|
faengt jeder Stream bei null an.
|
||||||
|
|
||||||
Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin:
|
Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin:
|
||||||
[Go-Live-Broadcaster](go-live-broadcaster.md), [Stream-Recap](stream-recap.md)
|
[Go-Live-Broadcaster](go-live-broadcaster.md), Stream-Recap (gestrichen)
|
||||||
und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo
|
und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo
|
||||||
und nicht nur in einer Oberflaeche.
|
und nicht nur in einer Oberflaeche.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -80,6 +80,12 @@ export type Config = {
|
|||||||
* Redirect-URI passen muss.
|
* Redirect-URI passen muss.
|
||||||
*/
|
*/
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
|
/**
|
||||||
|
* Wo der Hub steht, bei dem sich der Stream-PC fuers Pult anmeldet. Als
|
||||||
|
* Vorgabe fest eingetragen, damit auf Abdus Rechner nichts einzurichten
|
||||||
|
* ist. Leer heisst: keine Fernsteuerung (auch per FERN_URL= abschaltbar).
|
||||||
|
*/
|
||||||
|
fern: { url: string };
|
||||||
ingestServer: { port: number };
|
ingestServer: { port: number };
|
||||||
dashboard: { port: number };
|
dashboard: { port: number };
|
||||||
raw: YamlMap;
|
raw: YamlMap;
|
||||||
@@ -151,6 +157,7 @@ const DEFAULTS = {
|
|||||||
},
|
},
|
||||||
overlay: { port: 4791, enabled: true },
|
overlay: { port: 4791, enabled: true },
|
||||||
publicUrl: '',
|
publicUrl: '',
|
||||||
|
fern: { url: 'https://abdu.dimension47.de' },
|
||||||
ingestServer: { port: 4790 },
|
ingestServer: { port: 4790 },
|
||||||
dashboard: { port: 4792 },
|
dashboard: { port: 4792 },
|
||||||
};
|
};
|
||||||
@@ -180,6 +187,12 @@ export function loadConfig(): Config {
|
|||||||
// Unit-Datei, nicht in einer YAML, die mitversioniert wird.
|
// Unit-Datei, nicht in einer YAML, die mitversioniert wird.
|
||||||
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
|
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
|
||||||
merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
|
merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
|
||||||
|
// Auch leer gesetzt gewinnt die Umgebung: so meldet sich ein Rechner zum
|
||||||
|
// Entwickeln nicht beim echten Hub an.
|
||||||
|
// Neues Objekt statt Zuweisung: ohne Eintrag in der YAML ist merged.fern
|
||||||
|
// dasselbe Objekt wie in DEFAULTS.
|
||||||
|
const fernUrl = process.env.FERN_URL ?? merged.fern.url;
|
||||||
|
merged.fern = { ...merged.fern, url: fernUrl.replace(/\/+$/, '') };
|
||||||
cached = { ...merged, raw } as Config;
|
cached = { ...merged, raw } as Config;
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' }]), []);
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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' }]);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,15 +72,31 @@ const COMPONENTS: Component[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das neueste Archiv, das zum Muster passt. Bewusst nicht nur `releases/latest`:
|
||||||
|
* whisper.cpp legt seit 2026-09 die Programme in eine eigene Veroeffentlichung
|
||||||
|
* `b<nummer>` am selben Tag wie die Version, und die Version selbst (z. B.
|
||||||
|
* v1.9.5, "latest") hat gar keine Archive. Die `b`-Staende sind mal als
|
||||||
|
* Vorabversion markiert, mal nicht - wer Vorabversionen ueberspringt, landet
|
||||||
|
* bei einem Stand von vor zwei Versionen. Der erste Lauf der Pipeline ist
|
||||||
|
* genau daran gescheitert.
|
||||||
|
*/
|
||||||
async function githubAsset(repoName: string, pattern: RegExp): Promise<string> {
|
async function githubAsset(repoName: string, pattern: RegExp): Promise<string> {
|
||||||
const res = await fetch(`https://api.github.com/repos/${repoName}/releases/latest`, {
|
const res = await fetch(`https://api.github.com/repos/${repoName}/releases?per_page=30`, {
|
||||||
headers: { 'user-agent': 'abdulive-build' },
|
headers: { 'user-agent': 'abdulive-build' },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`);
|
if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`);
|
||||||
const rel = await res.json() as { assets: Array<{ name: string; browser_download_url: string }> };
|
const rels = await res.json() as Array<{
|
||||||
|
tag_name: string; draft: boolean;
|
||||||
|
assets: Array<{ name: string; browser_download_url: string }>;
|
||||||
|
}>;
|
||||||
|
// Die API liefert die neuesten zuerst.
|
||||||
|
for (const rel of rels) {
|
||||||
|
if (rel.draft) continue;
|
||||||
const asset = rel.assets.find((a) => pattern.test(a.name));
|
const asset = rel.assets.find((a) => pattern.test(a.name));
|
||||||
if (!asset) throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`);
|
if (asset) return asset.browser_download_url;
|
||||||
return asset.browser_download_url;
|
}
|
||||||
|
throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Werkzeug
|
// ---------------------------------------------------------------- Werkzeug
|
||||||
@@ -159,7 +175,9 @@ async function main(): Promise<void> {
|
|||||||
// html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in
|
// html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in
|
||||||
// apps/overlay-web/public und werden zur Laufzeit von der Platte
|
// apps/overlay-web/public und werden zur Laufzeit von der Platte
|
||||||
// gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay.
|
// gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay.
|
||||||
if (!/\.(ts|sql|json|html|css|js|svg|png|woff2)$/.test(rel)) continue;
|
// Dieselben Endungen, die der Overlay-Server ausliefert (TYPES in
|
||||||
|
// apps/overlay-server/src/server.ts) - sonst fehlt z. B. das Manifest des Pults.
|
||||||
|
if (!/\.(ts|sql|json|html|css|js|svg|png|jpg|woff2|webmanifest)$/.test(rel)) continue;
|
||||||
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
|
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user