Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt TypeScript direkt aus - der Container schrieb nur noch drei Programme fest, und dafuer braucht es keine Virtualisierung, sondern einen Ordner. Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste whisper.cpp aus dem Quelltext uebersetzen. Plattformschicht (packages/core/src/platform.ts): - Programme werden neben der Anwendung gesucht, nicht im PATH - Daten unter %LOCALAPPDATA%, damit Installation und Update ohne Administratorrechte auskommen - Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert: - Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche, escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht - `nice` gibt es nicht, ersetzt durch os.setPriority Auslieferung: - scripts/build-package.ts baut das Paket, laeuft auch unter Linux - install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner - update legt neue Fassungen bereit; eingespielt wird beim naechsten Start, nie mitten im Stream - ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts) Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene Vorgabeadresse mehr. Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben. ADR-0007 ersetzt ADR-0004. 84 Tests, Typpruefung sauber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
256 lines
9.1 KiB
TypeScript
256 lines
9.1 KiB
TypeScript
// Ingest-Daemon. Liest Chat und Zuschauerzahlen, schreibt beides in die
|
|
// Datenbank und haelt die Einrichtungsseite bereit.
|
|
//
|
|
// 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, loadLayout, 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 { isWindows } from '#core/platform.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 { createRenderer } from './render.ts';
|
|
import { createSpikeDetector } from './spike.ts';
|
|
import { startServer } from './server.ts';
|
|
|
|
const log = logger('ingest');
|
|
|
|
export async function runIngest(): Promise<void> {
|
|
const cfg = loadConfig();
|
|
const db = openDb();
|
|
const repo = repos(db);
|
|
const spikes = createSpikeDetector(cfg.ingest.spike);
|
|
|
|
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,
|
|
};
|
|
|
|
const abort = new AbortController();
|
|
const stop = (signal: string) => {
|
|
log.info(`${signal} empfangen, fahre herunter`);
|
|
abort.abort();
|
|
setTimeout(() => { closeDb(); process.exit(0); }, 500);
|
|
};
|
|
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 layout = loadLayout();
|
|
const profile = layout.profiles['default'];
|
|
if (!profile) throw new Error('config/layout.yaml hat kein Profil "default"');
|
|
|
|
const renderer = createRenderer({
|
|
cfg: {
|
|
profile,
|
|
tailSeconds: cfg.clipper.tailSeconds,
|
|
outputTrack: cfg.clipper.audio.outputTrack,
|
|
encoder: cfg.clipper.ffmpeg.encoder,
|
|
preset: cfg.clipper.ffmpeg.preset,
|
|
crf: cfg.clipper.ffmpeg.crf,
|
|
niceness: cfg.clipper.ffmpeg.niceness,
|
|
renderWhileLive: cfg.clipper.renderWhileLive,
|
|
},
|
|
repo,
|
|
workDir: paths.work,
|
|
isLive: () => state.streamId !== null,
|
|
});
|
|
|
|
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 } : {}),
|
|
// Nativ sieht die Anwendung denselben Pfad wie OBS. Leer heisst:
|
|
// nicht uebersetzen (siehe #obs/replay-path.ts).
|
|
replayDirContainer: isWindows ? '' : paths.replay,
|
|
clipsDir: paths.clips,
|
|
workDir: paths.work,
|
|
},
|
|
repo,
|
|
obs,
|
|
say: (text) => irc.say(text),
|
|
currentStreamId: () => state.streamId,
|
|
broadcasterId: () => state.broadcasterId,
|
|
onRawReady: (job) => renderer.enqueue(job),
|
|
});
|
|
|
|
startServer({
|
|
channel: cfg.channel.login,
|
|
port: cfg.dashboard.port,
|
|
health: () => ({
|
|
...state,
|
|
db: repo.stats(),
|
|
spikeArmed: spikes.isArmed(),
|
|
clipsWaitingForFile: clipper.pendingCount(),
|
|
renderQueue: renderer.pending(),
|
|
rendering: renderer.currentJob(),
|
|
note: state.chatAnonymous
|
|
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
|
: undefined,
|
|
}),
|
|
});
|
|
|
|
// ---- Chat
|
|
const irc = createIrc(cfg.channel.login, {
|
|
onConnected: (anonymous) => {
|
|
state.chatConnected = true;
|
|
state.chatAnonymous = anonymous;
|
|
},
|
|
onMessage: (msg) => {
|
|
try {
|
|
const { streamsChatted } = repo.recordMessage(msg, state.streamId);
|
|
state.messages++;
|
|
repo.event('chat.message', {
|
|
text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits,
|
|
}, state.streamId, msg.userId, msg.ts);
|
|
|
|
if (msg.isFirst) {
|
|
repo.event('chat.first_message', { login: msg.login }, state.streamId, msg.userId, msg.ts);
|
|
log.info(`Erstchatter: ${msg.displayName}`);
|
|
} else if (streamsChatted === 1) {
|
|
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++;
|
|
repo.event('chat.spike', spike, state.streamId, null, msg.ts);
|
|
log.info(`Spitze im Chat: Punktzahl ${spike.score} ` +
|
|
`(${spike.rate} Nachrichten von ${spike.chatters} Leuten, Basislinie ${spike.baseline})` +
|
|
(spike.overThreshold ? '' : ' - nur protokolliert, Ausloesung ist aus'));
|
|
}
|
|
} catch (err) {
|
|
state.lastError = (err as Error).message;
|
|
log.error(`Nachricht nicht verarbeitet: ${(err as Error).message}`);
|
|
}
|
|
},
|
|
});
|
|
void irc.run(abort.signal);
|
|
void obs.run(abort.signal);
|
|
|
|
// ---- Zuschauerzahlen und Stream-Sessions
|
|
void pollStream(cfg, repo, state, abort.signal);
|
|
|
|
log.info(`Ingest laeuft fuer #${cfg.channel.login}`);
|
|
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
|
|
}
|
|
|
|
type State = {
|
|
streamId: string | null; broadcasterId: string | null;
|
|
lastSeenLive: number; viewers: number; lastError: string | null;
|
|
};
|
|
|
|
async function pollStream(
|
|
cfg: ReturnType<typeof loadConfig>,
|
|
repo: ReturnType<typeof repos>,
|
|
state: State,
|
|
signal: AbortSignal
|
|
): Promise<void> {
|
|
const gapMs = cfg.ingest.sessionGapMinutes * 60_000;
|
|
let warnedMissingAuth = false;
|
|
|
|
// Ein laufender Stream wird beim Start uebernommen, damit ein Neustart
|
|
// mitten im Stream keine zweite Sitzung anlegt.
|
|
const open = repo.latestOpenStream();
|
|
if (open) {
|
|
state.streamId = open.id;
|
|
state.lastSeenLive = Date.now();
|
|
log.info(`Laufende Sitzung uebernommen: ${open.id} seit ${open.started_at}`);
|
|
}
|
|
|
|
while (!signal.aborted) {
|
|
try {
|
|
if (!readSecrets().tokens.broadcaster) {
|
|
if (!warnedMissingAuth) {
|
|
log.warn('Kein Broadcaster-Token - Zuschauerzahlen und Stream-Sitzungen bleiben aus. ' +
|
|
'Einrichtung unter /setup.');
|
|
warnedMissingAuth = true;
|
|
}
|
|
} else {
|
|
warnedMissingAuth = false;
|
|
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();
|
|
|
|
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) {
|
|
state.lastError = (err as Error).message;
|
|
log.error(`Abfrage fehlgeschlagen: ${(err as Error).message}`);
|
|
}
|
|
try { await sleep(cfg.ingest.viewerPollSeconds * 1000, signal); } catch { return; }
|
|
}
|
|
}
|