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:
1 parent
bc2c8f6cbe
commit
70928235b6
4 files changed
+324
-33
No files matched your search
+68
-27
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user