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

+79 -6
View File
@@ -4,7 +4,7 @@
import { logger } from '#core/logger.ts';
import { retry, sleep } from '#core/retry.ts';
import { accessToken } from './oauth.ts';
import { accessToken, appToken } from './oauth.ts';
import { readSecrets, type Role } from './secrets.ts';
const log = logger('helix');
@@ -49,13 +49,33 @@ export type HelixStream = {
export type HelixUser = { id: string; login: string; display_name: string };
async function call<T>(role: Role, endpoint: string, init: RequestInit = {}): Promise<T> {
const app = readSecrets().apps[role];
/**
* `appOk` heisst: dieser Endpunkt kommt auch ohne Nutzer-Token aus.
*
* Das ist kein Detail, sondern der Unterschied zwischen "laeuft" und "wartet
* auf einen Klick": Stream-Erkennung und damit die Go-Live-Ankuendigung
* brauchen nur `GET /users` und `GET /streams`, und beide sind oeffentlich.
* Ohne diese Unterscheidung stuende der ganze Dienst still, bis der
* Broadcaster sein Konto verbindet.
*/
type CallOpts = { appOk?: boolean };
async function call<T>(
role: Role, endpoint: string, init: RequestInit = {}, opts: CallOpts = {}
): Promise<T> {
const secrets = readSecrets();
const app = secrets.apps[role];
if (!app?.clientId) throw new Error(`Fuer "${role}" ist keine Client-ID hinterlegt.`);
const hatNutzerToken = Boolean(secrets.tokens[role]);
if (!hatNutzerToken && !opts.appOk) {
throw new Error(`"${role}" ist nicht verbunden - Einrichtung unter /setup.`);
}
return await retry(async () => {
await bucket.take();
const token = await accessToken(role);
// Das Nutzer-Token gewinnt, wenn es da ist: es sieht mehr (Abos, Chatter)
// und zaehlt auf ein eigenes Kontingent.
const token = hatNutzerToken ? await accessToken(role) : await appToken(role);
const res = await fetch(`${BASE}${endpoint}`, {
...init,
headers: {
@@ -85,15 +105,26 @@ async function call<T>(role: Role, endpoint: string, init: RequestInit = {}): Pr
export const helix = {
async userByLogin(login: string): Promise<HelixUser | null> {
const r = await call<{ data: HelixUser[] }>('broadcaster', `/users?login=${encodeURIComponent(login)}`);
const r = await call<{ data: HelixUser[] }>(
'broadcaster', `/users?login=${encodeURIComponent(login)}`, {}, { appOk: true });
return r.data[0] ?? null;
},
async streamByUserId(userId: string): Promise<HelixStream | null> {
const r = await call<{ data: HelixStream[] }>('broadcaster', `/streams?user_id=${userId}`);
const r = await call<{ data: HelixStream[] }>(
'broadcaster', `/streams?user_id=${userId}`, {}, { appOk: true });
return r.data[0] ?? null;
},
/**
* Reicht das Hinterlegte, um den Stream-Zustand abzufragen? Client-ID und
* Secret genuegen dafuer - ein verbundenes Konto ist nicht noetig.
*/
canReadStream(): boolean {
const app = readSecrets().apps.broadcaster;
return Boolean(app?.clientId && app.clientSecret);
},
async chatters(broadcasterId: string, moderatorId: string): Promise<string[]> {
const out: string[] = [];
let cursor = '';
@@ -108,6 +139,48 @@ export const helix = {
return out;
},
/**
* Abonnement fuer die WebSocket-Sitzung anlegen.
*
* Zwingend mit dem Nutzer-Token: Twitch verlangt fuer den WebSocket-Weg
* dasselbe Token, mit dem die Sitzung geoeffnet wurde. Ein App-Token wird
* hier abgelehnt, und die Fehlermeldung sagt nicht, warum.
*/
async subscribe(
sub: { type: string; version: string; condition: Record<string, string> },
sessionId: string,
): Promise<{ id: string }> {
const r = await call<{ data: Array<{ id: string }> }>('broadcaster', '/eventsub/subscriptions', {
method: 'POST',
body: JSON.stringify({ ...sub, transport: { method: 'websocket', session_id: sessionId } }),
});
const angelegt = r.data[0];
if (!angelegt) throw new Error(`Abonnement ${sub.type} wurde nicht angelegt`);
return angelegt;
},
/**
* Eine Einloesung abschliessen oder die Punkte zurueckgeben.
*
* Braucht `channel:manage:redemptions`; `channel:read:redemptions` allein
* genuegt nicht. Fehlt der Scope, meldet Twitch 401 - der Aufrufer faengt
* das ab und macht weiter, statt die Aktion daran scheitern zu lassen.
*/
async setRedemptionStatus(
broadcasterId: string, rewardId: string, redemptionId: string,
status: 'FULFILLED' | 'CANCELED',
): Promise<void> {
await call('broadcaster',
`/channel_points/custom_rewards/redemptions?broadcaster_id=${broadcasterId}` +
`&reward_id=${encodeURIComponent(rewardId)}&id=${encodeURIComponent(redemptionId)}`,
{ method: 'PATCH', body: JSON.stringify({ status }) });
},
/** Hat das verbundene Konto diesen Scope freigegeben? */
hasScope(scope: string): boolean {
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
},
async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> {
const r = await call<{ data: Array<{ id: string; edit_url: string }> }>(
'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' });