Stream-Erkennung: eine geprüfte Funktion statt zweimal ausgeschrieben

Wann ist ein Stream ein *neuer* Stream? Die Entscheidung stand doppelt in Hub
und Ingest und war ungetestet - dabei haengt genau an ihr, ob der Go-Live-Ping
zum richtigen Zeitpunkt und nur einmal rausgeht.

Jetzt rein in stream-state.ts, mit zehn Tests. Darunter die Faelle, die im
Betrieb selten sind und dann mitten im Stream zuschlagen:

- Neue Twitch-ID nach kurzem Aussetzer ist eine Fortsetzung, kein zweiter
  Ping. Twitch vergibt bei einem Verbindungsabbruch eine neue ID.
- Eine einzelne leere Antwort beendet den Stream nicht. Wer daraufhin die
  Sitzung schliesst, hat beim naechsten Abruf einen "neuen" Stream.
- Ein Durchlauf ueber 90 Minuten mit zwei Aussetzern, im Minutentakt
  abgefragt, ergibt genau einen Start und genau ein Ende.

Dazu App-Token fuer Helix: `GET /users` und `GET /streams` kommen ohne
Nutzer-Token aus. Das ist der Unterschied zwischen "laeuft" und "wartet auf
einen Klick" - ohne diese Unterscheidung stand der ganze Dienst still, bis
der Broadcaster sein Konto verbindet. Endpunkte, die etwas tun oder Privates
lesen, verlangen weiterhin ein Nutzer-Token, und `appOk` sagt je Aufruf, was
gilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-18 00:13:28 +02:00
1 parent bc2c8f6cbe
commit 70928235b6
4 files changed
+324 -33

No files matched your search

+68 -27
View File
@@ -16,6 +16,8 @@ import { isWindows } from '#core/platform.ts';
import { helix } from '#twitch/helix.ts';
import { createIrc } from '#twitch/irc.ts';
import { readSecrets } from '#twitch/secrets.ts';
import { streamWechsel } from '#twitch/stream-state.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createClipper } from './clips.ts';
import { createRenderer } from './render.ts';
import { createSpikeDetector } from './spike.ts';
@@ -42,6 +44,7 @@ export async function runIngest(): Promise<void> {
messages: 0,
spikeCandidates: 0,
clipsTriggered: 0,
goLive: 'noch nichts' as string,
lastError: null as string | null,
};
@@ -111,6 +114,20 @@ export async function runIngest(): Promise<void> {
onRawReady: (job) => renderer.enqueue(job),
});
// ---- Go-Live-Ankuendigung auf Discord
const announcer = createAnnouncer({
cfg: {
enabled: cfg.broadcaster.enabled,
mode: cfg.broadcaster.mode,
delaySeconds: cfg.broadcaster.delaySecondsAfterOnline,
cooldownHours: cfg.broadcaster.cooldownHours,
pingRole: cfg.broadcaster.pingRole,
obsScreenshot: cfg.broadcaster.obsScreenshot,
},
repo,
obs,
});
startServer({
channel: cfg.channel.login,
port: cfg.dashboard.port,
@@ -118,6 +135,7 @@ export async function runIngest(): Promise<void> {
...state,
db: repo.stats(),
discordConnected: bot ? bot.isConnected() : false,
goLive: announcer.lastResult() ?? state.goLive,
spikeArmed: spikes.isArmed(),
clipsWaitingForFile: clipper.pendingCount(),
renderQueue: renderer.pending(),
@@ -177,7 +195,10 @@ export async function runIngest(): Promise<void> {
if (bot) void bot.run(abort.signal);
// ---- Zuschauerzahlen und Stream-Sessions
void pollStream(cfg, repo, state, abort.signal);
void pollStream(cfg, repo, state, abort.signal, (stream) => {
state.goLive = 'laeuft';
announcer.announce(stream);
});
log.info(`Ingest laeuft fuer #${cfg.channel.login}`);
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
@@ -185,14 +206,16 @@ export async function runIngest(): Promise<void> {
type State = {
streamId: string | null; broadcasterId: string | null;
lastSeenLive: number; viewers: number; lastError: string | null;
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
};
async function pollStream(
cfg: ReturnType<typeof loadConfig>,
repo: ReturnType<typeof repos>,
state: State,
signal: AbortSignal
signal: AbortSignal,
/** Genau einmal je neu erkanntem Stream. Fortsetzungen zaehlen nicht. */
onOnline: (stream: StreamInfo) => void
): Promise<void> {
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
let warnedMissingAuth = false;
@@ -208,10 +231,12 @@ async function pollStream(
while (!signal.aborted) {
try {
if (!readSecrets().tokens.broadcaster) {
// Client-ID und Secret genuegen: `GET /users` und `GET /streams` laufen
// mit einem App-Token, auch ohne verbundenes Konto.
if (!helix.canReadStream()) {
if (!warnedMissingAuth) {
log.warn('Kein Broadcaster-Token - Zuschauerzahlen und Stream-Sitzungen bleiben aus. ' +
'Einrichtung unter /setup.');
log.warn('Keine Twitch-App hinterlegt - Zuschauerzahlen und Stream-Sitzungen ' +
'bleiben aus. Einrichtung unter /setup.');
warnedMissingAuth = true;
}
} else {
@@ -223,33 +248,49 @@ async function pollStream(
const live = userId ? await helix.streamByUserId(userId) : null;
const now = Date.now();
// Die Entscheidung "neuer Stream oder Fortsetzung" steht in
// #twitch/stream-state.ts und ist dort geprueft. Sie haengt daran,
// ob ein zweiter Rundruf rausgeht.
const wechsel = streamWechsel({
live: live ? { id: live.id, startedAt: live.started_at } : null,
laufendeId: state.streamId,
zuletztLive: state.lastSeenLive,
jetzt: now,
luckeMs: gapMs,
});
if (wechsel.art === 'gestartet') {
state.streamId = wechsel.id;
repo.openStream(wechsel.id, wechsel.startedAt);
repo.event('stream.online',
{ title: live!.title, category: live!.game_name }, wechsel.id);
log.info(`Stream online: "${live!.title}" (${live!.game_name}), ` +
`gestartet ${wechsel.startedAt}`);
onOnline({
id: wechsel.id,
title: live!.title,
category: live!.game_name,
login: cfg.channel.login,
startedAt: wechsel.startedAt,
thumbnailUrl: live!.thumbnail_url,
});
} else if (wechsel.art === 'fortsetzung') {
log.info(`Neue Twitch-Stream-ID ${wechsel.neueId}, behandle als Fortsetzung ` +
`von ${state.streamId}`);
} else if (wechsel.art === 'beendet') {
repo.closeStream(wechsel.id, new Date().toISOString());
repo.event('stream.offline', {}, wechsel.id);
log.info(`Stream offline: ${wechsel.id}`);
state.streamId = null;
state.viewers = 0;
}
if (live) {
if (state.streamId !== live.id) {
// Kurze Unterbrechungen gelten als dieselbe Sitzung, sonst
// zerfaellt ein Stream bei jedem Aussetzer in Fragmente.
const wasRecent = now - state.lastSeenLive < gapMs;
if (state.streamId && wasRecent) {
log.info(`Neue Twitch-Stream-ID ${live.id}, behandle als Fortsetzung von ${state.streamId}`);
} else {
state.streamId = live.id;
repo.openStream(live.id, live.started_at);
repo.event('stream.online',
{ title: live.title, category: live.game_name }, live.id);
log.info(`Stream online: "${live.title}" (${live.game_name})`);
}
}
state.lastSeenLive = now;
state.viewers = live.viewer_count;
if (state.streamId) {
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
}
} else if (state.streamId && now - state.lastSeenLive > gapMs) {
const ended = new Date().toISOString();
repo.closeStream(state.streamId, ended);
repo.event('stream.offline', {}, state.streamId);
log.info(`Stream offline: ${state.streamId}`);
state.streamId = null;
state.viewers = 0;
}
}
} catch (err) {