S2: obs-websocket, Wiedergabepuffer und der !clip-Ausloeser
Eigener obs-websocket-v5-Client (das Protokoll ist klein genug, und das Projekt bleibt ohne Abhaengigkeiten). Anmeldung, Wiederverbindung mit wachsender Pause, Ereignisse fuer ReplayBufferSaved und Szenenwechsel. Der Clipper loest zwei Wege gleichzeitig aus: OBS-Wiedergabepuffer fuer die Rohdatei in Aufnahmequalitaet, Twitch-Clip fuer die teilbare URL. Faellt einer aus, ist der andere trotzdem etwas wert. Ein direkt in OBS gedrueckter Hotkey wird ohne unseren Umweg erkannt und als vollwertiger Ausloeser behandelt. Die Pfaduebersetzung Windows -> Container hat eine eigene Datei und eigene Tests - das ist die Stelle, an der die Pipeline am ehesten scheitert. Dazu Warten auf den abgeschlossenen Schreibvorgang, bevor die Datei uebernommen wird. Zum Testvektor der OBS-Anmeldung: die Spezifikation nennt Salt und Challenge, aber kein erwartetes Ergebnis. Der Erwartungswert wurde unabhaengig mit Pythons hashlib aus derselben Beschreibung gerechnet; der Test sagt das im Kommentar, statt einen amtlichen Vektor vorzutaeuschen. 29 Tests gruen. Die Abnahme von S2 braucht ein laufendes OBS und steht aus. Ausserdem: ADR-0006 haelt fest, dass alles unter einem Dach laeuft - eine lokale Weboberflaeche plus Kindprozesse fuer Rechenarbeit. Die Prozessliste in der Architekturdoku ist entsprechend ersetzt. Der Onboarding-Assistent ist als Teil von S5 aufgenommen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
1 parent
d07c18441b
commit
684f10eb02
13 files changed
+796
-44
No files matched your search
@@ -1,17 +1,20 @@
|
||||
// Ingest-Daemon. Liest Chat und Zuschauerzahlen, schreibt beides in die
|
||||
// Datenbank und haelt die Einrichtungsseite bereit.
|
||||
//
|
||||
// Stand S1: IRC + Helix-Polling. EventSub und OBS kommen in S2.
|
||||
// Stand S2: IRC, Helix-Polling, obs-websocket und der !clip-Ausloeser.
|
||||
// EventSub kommt spaeter - fuer den Clip-Weg reicht IRC plus Polling.
|
||||
// Siehe docs/projects/00-ingest.md.
|
||||
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { loadConfig, paths } from '#core/config.ts';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { sleep } from '#core/retry.ts';
|
||||
import { openDb, closeDb } from '#db/index.ts';
|
||||
import { repos } from '#db/repos.ts';
|
||||
import { createObs } from '#obs/client.ts';
|
||||
import { helix } from '#twitch/helix.ts';
|
||||
import { createIrc } from '#twitch/irc.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
import { createClipper } from './clips.ts';
|
||||
import { createSpikeDetector } from './spike.ts';
|
||||
import { startServer } from './server.ts';
|
||||
|
||||
@@ -26,12 +29,16 @@ export async function runIngest(): Promise<void> {
|
||||
const state = {
|
||||
startedAt: new Date().toISOString(),
|
||||
streamId: null as string | null,
|
||||
broadcasterId: null as string | null,
|
||||
lastSeenLive: 0,
|
||||
viewers: 0,
|
||||
chatConnected: false,
|
||||
chatAnonymous: true,
|
||||
obsConnected: false,
|
||||
obsScene: null as string | null,
|
||||
messages: 0,
|
||||
spikeCandidates: 0,
|
||||
clipsTriggered: 0,
|
||||
lastError: null as string | null,
|
||||
};
|
||||
|
||||
@@ -44,6 +51,39 @@ export async function runIngest(): Promise<void> {
|
||||
process.on('SIGINT', () => stop('SIGINT'));
|
||||
process.on('SIGTERM', () => stop('SIGTERM'));
|
||||
|
||||
// ---- OBS (Wiedergabepuffer, Szenenwechsel)
|
||||
const obs = createObs(
|
||||
cfg.obs.url,
|
||||
() => readSecrets().obs?.password,
|
||||
{
|
||||
onIdentified: () => { state.obsConnected = true; },
|
||||
onClosed: () => { state.obsConnected = false; },
|
||||
onSceneChanged: (scene) => {
|
||||
state.obsScene = scene;
|
||||
repo.event('obs.scene_changed', { scene }, state.streamId);
|
||||
},
|
||||
onReplaySaved: (hostPath) => { void clipper.onReplaySaved(hostPath); },
|
||||
}
|
||||
);
|
||||
|
||||
const clipper = createClipper({
|
||||
cfg: {
|
||||
enabled: cfg.clipper.enabled,
|
||||
alsoCreateTwitchClip: cfg.clipper.alsoCreateTwitchClip,
|
||||
cooldownGlobalSeconds: cfg.clipper.cooldownGlobalSeconds,
|
||||
cooldownUserSeconds: cfg.clipper.cooldownUserSeconds,
|
||||
followersOnly: cfg.clipper.followersOnly,
|
||||
...(process.env.OBS_REPLAY_DIR ? { replayDirHost: process.env.OBS_REPLAY_DIR } : {}),
|
||||
replayDirContainer: paths.replay,
|
||||
clipsDir: paths.clips,
|
||||
},
|
||||
repo,
|
||||
obs,
|
||||
say: (text) => irc.say(text),
|
||||
currentStreamId: () => state.streamId,
|
||||
broadcasterId: () => state.broadcasterId,
|
||||
});
|
||||
|
||||
startServer({
|
||||
channel: cfg.channel.login,
|
||||
port: cfg.dashboard.port,
|
||||
@@ -51,6 +91,7 @@ export async function runIngest(): Promise<void> {
|
||||
...state,
|
||||
db: repo.stats(),
|
||||
spikeArmed: spikes.isArmed(),
|
||||
clipsWaitingForFile: clipper.pendingCount(),
|
||||
note: state.chatAnonymous
|
||||
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
||||
: undefined,
|
||||
@@ -78,6 +119,12 @@ export async function runIngest(): Promise<void> {
|
||||
log.debug(`Erster Stream fuer ${msg.displayName}`);
|
||||
}
|
||||
|
||||
if (msg.isCommand && /^!clip\b/i.test(msg.text)) {
|
||||
state.clipsTriggered++;
|
||||
void clipper.handleCommand(msg.userId, msg.displayName, msg.isMod || msg.isBroadcaster)
|
||||
.then((reply) => { if (reply) irc.say(`@${msg.displayName} ${reply}`); });
|
||||
}
|
||||
|
||||
const spike = spikes.push(msg);
|
||||
if (spike) {
|
||||
state.spikeCandidates++;
|
||||
@@ -93,6 +140,7 @@ export async function runIngest(): Promise<void> {
|
||||
},
|
||||
});
|
||||
void irc.run(abort.signal);
|
||||
void obs.run(abort.signal);
|
||||
|
||||
// ---- Zuschauerzahlen und Stream-Sessions
|
||||
void pollStream(cfg, repo, state, abort.signal);
|
||||
@@ -102,7 +150,8 @@ export async function runIngest(): Promise<void> {
|
||||
}
|
||||
|
||||
type State = {
|
||||
streamId: string | null; lastSeenLive: number; viewers: number; lastError: string | null;
|
||||
streamId: string | null; broadcasterId: string | null;
|
||||
lastSeenLive: number; viewers: number; lastError: string | null;
|
||||
};
|
||||
|
||||
async function pollStream(
|
||||
@@ -133,8 +182,10 @@ async function pollStream(
|
||||
}
|
||||
} else {
|
||||
warnedMissingAuth = false;
|
||||
const userId = cfg.channel.userId ||
|
||||
const userId = state.broadcasterId || cfg.channel.userId ||
|
||||
(await helix.userByLogin(cfg.channel.login))?.id || '';
|
||||
// Einmal aufgeloest, danach gemerkt - spart einen Aufruf je Minute
|
||||
if (userId) state.broadcasterId = userId;
|
||||
const live = userId ? await helix.streamByUserId(userId) : null;
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
Reference in new issue
Block a user