Pult: Stream-PC per Tipp freigeben, Sound-Quelle legt er selbst in OBS an
Paket bauen und veroeffentlichen / paket (push) Successful in 42s
Paket bauen und veroeffentlichen / paket (push) Successful in 42s
- ingest wuerfelt beim ersten Start einen Schluessel und meldet sich beim Hub; der antwortet 202, bis im Pult jemand auf Freigeben tippt - nach dem Verbinden schickt der Hub die Adresse von /sounds, ingest legt Szene "abdulive Ton" mit der Browser-Source an und haengt sie in jede Szene - fern:verbinden bleibt als Weg von Hand Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32416d1916
commit
54dbe1265b
15 files changed
+675
-50
No files matched your search
@@ -3,7 +3,9 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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', () => {
|
||||
const z = sseZerleger();
|
||||
@@ -67,3 +69,83 @@ test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
|
||||
assert.equal(s.puffer, false);
|
||||
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']);
|
||||
});
|
||||
+143
-10
@@ -8,9 +8,13 @@
|
||||
// POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub)
|
||||
// POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub)
|
||||
//
|
||||
// Gegenstueck: apps/overlay-server/src/fern.ts. Eingerichtet wird mit
|
||||
// `abdulive fern:verbinden <adresse> <schluessel>`; beides steht fertig auf
|
||||
// der Uebersichtsseite des Hubs.
|
||||
// Gegenstueck: apps/overlay-server/src/fern.ts.
|
||||
//
|
||||
// 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 { 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. */
|
||||
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. */
|
||||
const STAND_EREIGNISSE = new Set([
|
||||
'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
|
||||
@@ -41,6 +57,21 @@ export type Befehl =
|
||||
| { id: string; art: 'clip' }
|
||||
| { 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
|
||||
* 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,
|
||||
// und in dieser Reihenfolge erwartet man sie auch am Handy.
|
||||
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');
|
||||
// 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 = {
|
||||
url: string;
|
||||
key: string;
|
||||
/** So erscheint der PC bei der Freigabe im Pult. */
|
||||
name: string;
|
||||
obs: ObsClient;
|
||||
/** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
|
||||
clip: () => Promise<{ ok: boolean; reason?: string }>;
|
||||
@@ -127,11 +214,20 @@ export type FernAnschluss = {
|
||||
verbunden: () => boolean;
|
||||
};
|
||||
|
||||
|
||||
export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
||||
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 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> {
|
||||
try {
|
||||
@@ -159,6 +255,21 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
||||
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 }> {
|
||||
if (b.art === 'clip') {
|
||||
const r = await deps.clip();
|
||||
@@ -190,9 +301,24 @@ export function createFernanschluss(deps: FernDeps): FernAnschluss {
|
||||
}, 10_000);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${basis}/fern/befehle`, {
|
||||
headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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) {
|
||||
log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
|
||||
'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 }))) {
|
||||
let roh: unknown;
|
||||
try { roh = JSON.parse(daten); } catch { continue; }
|
||||
const e = einrichtenAus(roh);
|
||||
if (e) { soundsUrl = e.soundsUrl; void einrichten(); continue; }
|
||||
const b = befehlAus(roh);
|
||||
if (!b) continue;
|
||||
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);
|
||||
},
|
||||
}),
|
||||
obsEreignis: (type) => { if (STAND_EREIGNISSE.has(type)) spaeterSenden(); },
|
||||
obsWechsel: spaeterSenden,
|
||||
obsEreignis: (type) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import { createObs } from '#obs/client.ts';
|
||||
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 { readSecrets, updateSecrets } from '#twitch/secrets.ts';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||
import { createClipper } from './clips.ts';
|
||||
@@ -116,19 +118,21 @@ export async function runIngest(): Promise<void> {
|
||||
onRawReady: (job) => renderer.enqueue(job),
|
||||
});
|
||||
|
||||
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Nur, wenn mit
|
||||
// `abdulive fern:verbinden` eingerichtet - sonst laeuft alles wie vorher.
|
||||
const fernZugang = readSecrets().fern;
|
||||
const fern = fernZugang?.url && fernZugang.key
|
||||
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Einzurichten ist
|
||||
// nichts: beim ersten Start wird ein Schluessel gewuerfelt, der PC meldet
|
||||
// sich beim Hub, und im Pult erscheint "Freigeben".
|
||||
const fernZugang = fernZugangHolen(cfg.fern.url);
|
||||
const fern = fernZugang
|
||||
? createFernanschluss({
|
||||
url: fernZugang.url,
|
||||
key: fernZugang.key,
|
||||
name: os.hostname(),
|
||||
obs,
|
||||
clip: () => clipper.trigger('manual', { login: 'Pult' }),
|
||||
})
|
||||
: null;
|
||||
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
|
||||
const announcer = createAnnouncer({
|
||||
@@ -221,6 +225,22 @@ export async function runIngest(): Promise<void> {
|
||||
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 = {
|
||||
streamId: string | null; broadcasterId: string | null;
|
||||
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
|
||||
|
||||
Reference in new issue
Block a user