diff --git a/apps/ingest/src/clips.ts b/apps/ingest/src/clips.ts new file mode 100644 index 0000000..b3262ad --- /dev/null +++ b/apps/ingest/src/clips.ts @@ -0,0 +1,200 @@ +// Clip-Ausloesung. Scheibe S2 aus docs/07-mvp-plan.md. +// +// Zwei Wege gleichzeitig, mit unterschiedlichem Zweck: +// OBS-Wiedergabepuffer -> Rohdatei in Aufnahmequalitaet, daraus wird das Short +// Twitch-Clip ueber Helix -> teilbare URL, im Kanal auffindbar +// Faellt einer aus, ist der andere trotzdem etwas wert. Siehe +// docs/projects/clip-shorts.md. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { logger } from '#core/logger.ts'; +import { ulid } from '#shared/events.ts'; +import type { Repos } from '#db/repos.ts'; +import type { ObsClient } from '#obs/client.ts'; +import { mapReplayPath, waitForStableFile } from '#obs/replay-path.ts'; +import { helix } from '#twitch/helix.ts'; +import { readSecrets } from '#twitch/secrets.ts'; + +const log = logger('clipper'); + +export type ClipTrigger = 'command' | 'hotkey' | 'spike' | 'manual'; + +export type ClipperConfig = { + enabled: boolean; + alsoCreateTwitchClip: boolean; + cooldownGlobalSeconds: number; + cooldownUserSeconds: number; + followersOnly: boolean; + replayDirHost?: string; + replayDirContainer: string; + clipsDir: string; +}; + +export type ClipperDeps = { + cfg: ClipperConfig; + repo: Repos; + obs: ObsClient; + say: (text: string) => boolean; + currentStreamId: () => string | null; + broadcasterId: () => string | null; +}; + +export function createClipper(deps: ClipperDeps) { + const { cfg, repo, obs } = deps; + let lastGlobal = 0; + const lastByUser = new Map(); + + // Wer den Puffer ausgeloest hat, wissen wir erst, wenn OBS die Datei meldet. + // Solange steht der Auftrag hier. + const awaitingFile: Array<{ clipId: string; at: number }> = []; + + /** Gibt eine Absage zurueck, wenn gerade nicht ausgeloest werden darf. */ + function checkAllowed(trigger: ClipTrigger, userId: string | null, now: number): string | null { + if (!cfg.enabled) return 'Clips sind ausgeschaltet.'; + if (trigger === 'command' || trigger === 'spike') { + const sinceGlobal = (now - lastGlobal) / 1000; + if (sinceGlobal < cfg.cooldownGlobalSeconds) { + return `Gerade eben schon geclippt - noch ${Math.ceil(cfg.cooldownGlobalSeconds - sinceGlobal)} s.`; + } + } + if (trigger === 'command' && userId) { + const last = lastByUser.get(userId) ?? 0; + const sinceUser = (now - last) / 1000; + if (sinceUser < cfg.cooldownUserSeconds) { + return `Du hast gerade erst geclippt - noch ${Math.ceil(cfg.cooldownUserSeconds - sinceUser)} s.`; + } + } + return null; + } + + async function trigger( + kind: ClipTrigger, opts: { userId?: string; login?: string; score?: number } = {} + ): Promise<{ ok: boolean; reason?: string; clipId?: string }> { + const now = Date.now(); + const denied = checkAllowed(kind, opts.userId ?? null, now); + if (denied) { + log.debug(`Ausloesung abgelehnt (${kind}): ${denied}`); + return { ok: false, reason: denied }; + } + + const clipId = ulid(); + const streamId = deps.currentStreamId(); + lastGlobal = now; + if (opts.userId) lastByUser.set(opts.userId, now); + + repo.insertClip({ + id: clipId, streamId, trigger: kind, + triggerScore: opts.score ?? null, + source: 'obs-replay', status: 'pending', + }); + repo.event('clip.requested', { trigger: kind, by: opts.login ?? null }, streamId, + opts.userId ?? null); + + // Beide Wege anstossen, keiner blockiert den anderen + const obsOk = saveReplay(clipId); + if (cfg.alsoCreateTwitchClip) void createTwitchClip(clipId); + + if (!(await obsOk)) { + // Der Twitch-Clip kann trotzdem klappen - der Eintrag bleibt bestehen + return { ok: false, reason: 'OBS ist nicht verbunden.', clipId }; + } + return { ok: true, clipId }; + } + + async function saveReplay(clipId: string): Promise { + if (!obs.isConnected()) { + log.warn('Wiedergabepuffer nicht gesichert: keine OBS-Verbindung'); + return false; + } + try { + const status = await obs.request<{ outputActive: boolean }>('GetReplayBufferStatus'); + if (!status.outputActive) { + log.warn('Wiedergabepuffer laeuft nicht - in OBS unter Einstellungen -> Ausgabe aktivieren'); + return false; + } + awaitingFile.push({ clipId, at: Date.now() }); + await obs.request('SaveReplayBuffer'); + return true; + } catch (err) { + log.error(`SaveReplayBuffer fehlgeschlagen: ${(err as Error).message}`); + const i = awaitingFile.findIndex((e) => e.clipId === clipId); + if (i !== -1) awaitingFile.splice(i, 1); + return false; + } + } + + async function createTwitchClip(clipId: string): Promise { + const broadcasterId = deps.broadcasterId(); + if (!broadcasterId || !readSecrets().tokens.broadcaster) return; + try { + const clip = await helix.createClip(broadcasterId); + repo.updateClip(clipId, { twitch_clip_id: clip.id }); + log.info(`Twitch-Clip erstellt: ${clip.id}`); + } catch (err) { + // Kein harter Fehler: die Rohdatei aus OBS ist die wichtigere Quelle + log.warn(`Twitch-Clip nicht erstellt: ${(err as Error).message}`); + } + } + + /** Wird aufgerufen, wenn OBS eine gespeicherte Pufferdatei meldet. */ + async function onReplaySaved(hostPath: string): Promise { + const containerPath = mapReplayPath(hostPath, cfg.replayDirHost, cfg.replayDirContainer); + + // Auftrag zuordnen. Aeltere als 60 s gehoeren nicht mehr dazu - dann hat + // jemand den OBS-Hotkey direkt gedrueckt, ohne unseren Umweg. + const now = Date.now(); + while (awaitingFile.length && now - awaitingFile[0]!.at > 60_000) awaitingFile.shift(); + const job = awaitingFile.shift(); + + let clipId = job?.clipId; + if (!clipId) { + // Hotkey direkt in OBS gedrueckt - das ist ein vollwertiger Ausloeser + clipId = ulid(); + const streamId = deps.currentStreamId(); + repo.insertClip({ + id: clipId, streamId, trigger: 'hotkey', triggerScore: null, + source: 'obs-replay', status: 'pending', + }); + repo.event('clip.requested', { trigger: 'hotkey' }, streamId, null); + log.info('Wiedergabepuffer per OBS-Hotkey gespeichert'); + if (cfg.alsoCreateTwitchClip) void createTwitchClip(clipId); + } + + try { + const size = await waitForStableFile(containerPath); + // Vom nur-lesbaren Bind-Mount in den Ablageordner. Ab hier gehoert die + // Datei uns, OBS darf seinen Puffer aufraeumen. + const dir = path.join(cfg.clipsDir, new Date().toISOString().slice(0, 10), clipId); + await fs.mkdir(dir, { recursive: true }); + const target = path.join(dir, `raw${path.extname(containerPath) || '.mkv'}`); + await fs.copyFile(containerPath, target); + + repo.updateClip(clipId, { raw_path: target, status: 'raw' }); + repo.event('obs.replay_saved', { path: target, bytes: size }, deps.currentStreamId()); + log.info(`Rohdatei gesichert: ${target} (${(size / 1024 / 1024).toFixed(1)} MB)`); + deps.say('Clip gesichert.'); + } catch (err) { + repo.updateClip(clipId, { status: 'failed' }); + log.error(`Rohdatei nicht uebernommen: ${(err as Error).message}`); + log.error(` OBS meldete: ${hostPath}`); + log.error(` Gesucht unter: ${containerPath}`); + log.error(' Stimmt OBS_REPLAY_DIR in der .env mit dem OBS-Zielordner ueberein?'); + } + } + + /** `!clip` im Chat. Gibt die Antwort zurueck, die in den Chat soll. */ + async function handleCommand( + userId: string, login: string, isPrivileged: boolean + ): Promise { + if (cfg.followersOnly && !isPrivileged) { + // Follower-Pruefung braucht einen Helix-Aufruf je Nachricht; im MVP + // regeln das die Abklingzeiten. Siehe docs/projects/clip-shorts.md. + } + const res = await trigger('command', { userId, login }); + if (res.ok) return null; // Antwort kommt erst, wenn die Datei da ist + return res.reason ?? 'Hat nicht geklappt.'; + } + + return { trigger, onReplaySaved, handleCommand, pendingCount: () => awaitingFile.length }; +} diff --git a/apps/ingest/src/index.ts b/apps/ingest/src/index.ts index 255a4af..5ad0ebd 100644 --- a/apps/ingest/src/index.ts +++ b/apps/ingest/src/index.ts @@ -1,17 +1,20 @@ // Ingest-Daemon. Liest Chat und Zuschauerzahlen, schreibt beides in die // Datenbank und haelt die Einrichtungsseite bereit. // -// Stand S1: IRC + Helix-Polling. EventSub und OBS kommen in S2. +// 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 } from '#core/config.ts'; +import { loadConfig, 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 { helix } from '#twitch/helix.ts'; import { createIrc } from '#twitch/irc.ts'; import { readSecrets } from '#twitch/secrets.ts'; +import { createClipper } from './clips.ts'; import { createSpikeDetector } from './spike.ts'; import { startServer } from './server.ts'; @@ -26,12 +29,16 @@ export async function runIngest(): Promise { 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, }; @@ -44,6 +51,39 @@ export async function runIngest(): Promise { 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 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 } : {}), + replayDirContainer: paths.replay, + clipsDir: paths.clips, + }, + repo, + obs, + say: (text) => irc.say(text), + currentStreamId: () => state.streamId, + broadcasterId: () => state.broadcasterId, + }); + startServer({ channel: cfg.channel.login, port: cfg.dashboard.port, @@ -51,6 +91,7 @@ export async function runIngest(): Promise { ...state, db: repo.stats(), spikeArmed: spikes.isArmed(), + clipsWaitingForFile: clipper.pendingCount(), note: state.chatAnonymous ? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.' : undefined, @@ -78,6 +119,12 @@ export async function runIngest(): Promise { 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++; @@ -93,6 +140,7 @@ export async function runIngest(): Promise { }, }); void irc.run(abort.signal); + void obs.run(abort.signal); // ---- Zuschauerzahlen und Stream-Sessions void pollStream(cfg, repo, state, abort.signal); @@ -102,7 +150,8 @@ export async function runIngest(): Promise { } type State = { - streamId: string | null; lastSeenLive: number; viewers: number; lastError: string | null; + streamId: string | null; broadcasterId: string | null; + lastSeenLive: number; viewers: number; lastError: string | null; }; async function pollStream( @@ -133,8 +182,10 @@ async function pollStream( } } else { warnedMissingAuth = false; - const userId = cfg.channel.userId || + 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(); diff --git a/config/config.example.yaml b/config/config.example.yaml index 19d5633..9597d6e 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -49,10 +49,15 @@ clipper: crf: 20 encoder: libx264 # keine NVIDIA-GPU da, QSV aus WSL2 unzuverlaessig niceness: 15 # Encoding laeuft nachrangig zum Stream + # Abklingzeiten sind nicht optional: ohne sie ist !clip nach zwei Streams aus. + cooldownGlobalSeconds: 60 + cooldownUserSeconds: 300 + followersOnly: false triggers: chatCommand: "!clip" - hotkeyName: SaveReplay - onSpike: false + onSpike: false # erst nach Kalibrierung der Spitzen-Erkennung + # Der Hotkey wird in OBS auf "Wiedergabepuffer speichern" gelegt. Es gibt + # bewusst keinen eigenen Hotkey-Dienst - siehe ADR-0004, Punkt 4. broadcaster: enabled: true diff --git a/docs/01-architecture.md b/docs/01-architecture.md index ca47ba4..f99dab8 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -37,35 +37,28 @@ ## Prozesse -Der Verbund besteht aus wenigen langlaufenden Prozessen und mehreren -Kurzläufern, gestartet über `docker compose up -d`. +**Ein Prozess.** Oberflaeche, Ingest und die Werkzeugmodule laufen gemeinsam in +einem Container; schwere Arbeit (ffmpeg, whisper.cpp) laeuft als Kindprozess und +darf abstuerzen, ohne die Chat-Aufnahme mitzureissen. Begruendung und Grenzen: +[ADR-0006](decisions/ADR-0006-ein-dach.md). -> **Im MVP gilt das noch nicht.** Dort laufen Ingest und Clipper als *ein* -> Prozess in *einem* Container; die Aufteilung unten ist der Zielzustand ab -> Meilenstein M3, wenn es mehr als einen Konsumenten gibt. Siehe -> [`07-mvp-plan.md`](07-mvp-plan.md). +| Port | Wofuer | +|------|--------| +| 4792 | Weboberflaeche: Einrichtung, OAuth-Rueckruf, Sichtung, Dashboard, `/health` | +| 4791 | Overlay-Server fuer die OBS-Browser-Quellen (ab M3) | -**Langläufer (laufen immer, auch offline):** +Beide nur auf `127.0.0.1` des Hosts veroeffentlicht. -| Prozess | Port | Aufgabe | -|---------|------|---------| -| `ingest` | 4790 (WS) | EventSub, IRC, OBS-WS, Viewer-Polling → DB + Bus | -| `overlay-server` | 4791 (HTTP+WS) | liefert Overlay-Seiten, nimmt Chat-Commands entgegen | -| `dashboard` | 4792 (HTTP) | Analytics-UI | +Die Werkzeuge sind Module, keine eigenen Dienste: -**Kurzläufer (event- oder zeitgetriggert):** - -| Prozess | Trigger | -|---------|---------| -| `clipper` | Clip-Event vom Bus, oder manuell über CLI | -| `broadcaster` | `stream.online` | -| `recap` | `stream.offline` (+ 5 min Karenz) | -| `vod-chapters` | `stream.offline` | -| `raid-scout` | manuell / Chat-Command `!raidziel` | - -`community` und `loyalty` sind keine eigenen Prozesse, sondern Module, die im -`overlay-server` mitlaufen — sie brauchen dieselbe Chat-Verbindung und -denselben WS-Kanal zum Overlay. +| Modul | laeuft | Ausloeser | +|-------|--------|-----------| +| Ingest | dauernd | IRC, EventSub, Helix-Polling | +| Community, Loyalty | dauernd | Chat-Ereignisse | +| Clipper | bei Bedarf | `!clip`, OBS-Hotkey, spaeter Chat-Spitze | +| Broadcaster | bei Bedarf | `stream.online` | +| Recap, VOD-Kapitel | bei Bedarf | `stream.offline` | +| Raid-Scout | bei Bedarf | `!raidziel`, Oberflaeche | ## Event-Bus @@ -82,10 +75,10 @@ type StreamEvent = { }; ``` -Transport: In-Prozess ein `EventEmitter`, prozessübergreifend ein -WebSocket-Broadcast auf `ws://127.0.0.1:4790`. Kein Kafka, kein Redis. Wenn ein -Konsument abstürzt, holt er sich beim Neustart die verpassten Events aus der -`events`-Tabelle über `ts > lastSeen`. +Transport: ein `EventEmitter` im Prozess. Solange alles unter einem Dach laeuft +(ADR-0006), braucht es keinen Netzwerk-Bus. Die `events`-Tabelle ist trotzdem +die Wahrheit: sie wird immer geschrieben, und ein spaeter herausgeloester +Prozess kann seinen Rueckstand ueber `ts > lastSeen` aufholen. Vollständige Event-Liste: siehe [`03-data-model.md`](03-data-model.md). diff --git a/docs/07-mvp-plan.md b/docs/07-mvp-plan.md index daf9fe4..1d1b809 100644 --- a/docs/07-mvp-plan.md +++ b/docs/07-mvp-plan.md @@ -37,7 +37,8 @@ gibt. Vorher ist sie nur Ballast. | S0 Container + Toolchain | **fertig** — Image 258 MB, `check-env` ohne harte Fehler | | Layout eingemessen | **fertig** — `config/layout.yaml`, vorgezogen aus S3 | | S1 Einrichtung + Auth + DB + IRC-Ingest | **fertig** — 20 Tests grün, Daemon läuft | -| S2–S6 | offen | +| S2 OBS-Anbindung + Trigger | **Code fertig**, 29 Tests grün — Abnahme braucht laufendes OBS | +| S3–S6 | offen | **S1 abgenommen am 2026-09-09.** Der Daemon läuft, hängt am Chat, die Einrichtung ist unter `http://localhost:4792/setup` erreichbar. @@ -146,6 +147,17 @@ Bestätigung im Chat, genau eine pro Auslösung. **Fertig, wenn:** `!clip` im laufenden Stream erzeugt binnen 5 s eine `raw.mp4` im Ablageordner und eine Chat-Rückmeldung. +**Stand 2026-09-09:** Code steht und ist getestet, soweit es ohne OBS geht — +obs-websocket-Client mit Anmeldung und Wiederverbindung, Pfadübersetzung +Windows → Container, Abklingzeiten, Twitch-Clip parallel, Übernahme der Rohdatei +mit Warten auf fertigen Schreibvorgang. Der OBS-Hotkey wird ohne unseren Umweg +erkannt und als vollwertiger Auslöser behandelt. + +Die **Abnahme steht aus**: Auf dieser Entwicklungsmaschine läuft kein OBS. Was +geprüft ist: Anmeldeverfahren (gegen eine unabhängige Zweitimplementierung), +Pfadübersetzung in allen vier Fällen, Warten auf die fertige Datei. Was +ungeprüft bleibt: das tatsächliche Gespräch mit einem laufenden OBS. + ### S3 — 9:16-Render · ~1,5 Tage `calibrate`-Kommando: zieht einen Frame aus einer Replay-Datei, schreibt ihn @@ -170,15 +182,40 @@ mindestens 320 px. Ins Video kommt weiterhin die Mischung von Spur 1. Untertitel, und der Text wird auf dem Handy von der TikTok-Oberfläche nicht verdeckt. -### S5 — Sichtung · ~1 Tag +### S5 — Oberfläche: Onboarding und Sichtung · ~2 Tage -Ein kleiner HTTP-Server auf Port 4792 liefert eine Seite mit allen Kandidaten -des letzten Streams: Videovorschau, Score, Auslöser, der Chat-Ausschnitt aus -dem Clip-Fenster, Transkript, und die Knöpfe *Übernehmen* / *Verwerfen*. -Übernommene Clips wandern nach `data/clips/_upload/`. +**Erststart-Assistent.** Die heutige Einrichtungsseite ist ein Formularblock — +das reicht für mich, nicht für Abdu. Der Assistent führt durch sechs Schritte +und **prüft jeden davon live**, statt zu behaupten, es sei erledigt: -**Fertig, wenn:** Nach einem Stream lässt sich in unter zwei Minuten -entscheiden, welche Clips hochgeladen werden. +| Schritt | Wird geprüft | +|---|---| +| 1. Kanal bestätigen | Kanal existiert (Helix) | +| 2. Twitch-Apps anlegen | Redirect-URI zum Kopieren, Anleitung | +| 3. Verbinden | Token gültig, Scopes vollständig | +| 4. OBS | erreichbar, Wiedergabepuffer aktiv, Hotkey belegt | +| 5. Facecam einmessen | Vorschau direkt in der Seite | +| 6. Fertig | „So löst du einen Clip aus" | + +**Warum diese Scheibe hinten liegt:** Die Prüfungen sind nur so viel wert, wie +es zu prüfen gibt. Ein OBS-Schritt, der behauptet „alles gut", ohne den +Wiedergabepuffer wirklich abzufragen, ist schlimmer als kein Schritt. Deshalb +**erweitert jede Scheibe ab S2 den Assistenten um ihren eigenen Schritt**, und +S5 macht daraus einen zusammenhängenden Ablauf mit Texten, Reihenfolge und der +Startseite danach. + +Danach wird aus der Einrichtungsseite eine Einstellungsseite, und die Startseite +zeigt den laufenden Stream und die anstehenden Entscheidungen — nicht eine Liste +von Werkzeugen (siehe [ADR-0006](decisions/ADR-0006-ein-dach.md)). + +**Sichtung.** Auf derselben Oberfläche eine Seite mit allen Kandidaten des +letzten Streams: Videovorschau, Punktzahl, Auslöser, der Chat-Ausschnitt aus dem +Clip-Fenster, Transkript, und die Knöpfe *Übernehmen* / *Verwerfen*. Übernommene +Clips wandern nach `data/clips/_upload/`. + +**Fertig, wenn:** Ein Fremder kommt von „nichts eingerichtet" bis zum ersten +eigenen Clip, ohne die Dokumentation zu lesen. Und: nach einem Stream lässt sich +in unter zwei Minuten entscheiden, welche Clips hochgeladen werden. ### S6 — Erstchatter-Begrüßung · ~0,5 Tage · optional diff --git a/docs/decisions/ADR-0006-ein-dach.md b/docs/decisions/ADR-0006-ein-dach.md new file mode 100644 index 0000000..82d7e68 --- /dev/null +++ b/docs/decisions/ADR-0006-ein-dach.md @@ -0,0 +1,81 @@ +# ADR-0006: Ein Dach — lokale Weboberfläche, lokale Arbeitsprozesse + +**Status:** akzeptiert · **Datum:** 2026-09-09 + +## Kontext + +Die ursprüngliche Architektur ([`01-architecture.md`](../01-architecture.md)) +sah neun weitgehend eigenständige Apps vor, die über einen Event-Bus reden, und +drei getrennte Langläufer (Ingest, Overlay-Server, Dashboard). Beim Bau von S1 +hat sich gezeigt, dass das für diese Größe zu viel Struktur ist. + +Die Frage, die dahinter steht: Soll alles über **eine Weboberfläche** bedienbar +sein, mit lokalen Komponenten dort, wo tatsächlich lokal gearbeitet werden muss +(Video, OBS, Chat)? + +## Entscheidung + +Ja. Das Toolkit ist eine **lokale Web-Anwendung**: ein Prozess, der die +Oberfläche ausliefert und die Dauerverbindungen hält, plus **Kindprozesse für +schwere oder abstürzgefährdete Arbeit**. + +``` + Browser (localhost:4792) OBS ────────┐ + │ eine Oberflaeche, │ obs-websocket + │ neun Werkzeuge als Seiten │ + ▼ ▼ + ┌──────────────────────────────────────────────────────┐ + │ toolkit (ein Node-Prozess, ein Container) │ + │ │ + │ Weboberflaeche · Einrichtung · Sichtung · Dashboard │ + │ Ingest: IRC, EventSub, Helix-Polling │ + │ Module: Community, Loyalty, Raid, Recap, Kapitel │ + │ Overlay-Server fuer die OBS-Browser-Quellen │ + └───────────────┬──────────────────────────────────────┘ + │ spawn, ueberwacht, darf abstuerzen + ▼ + ffmpeg · whisper.cpp SQLite (Volume) +``` + +## Begründung + +**Die Trennlinie verläuft nicht zwischen Werkzeugen, sondern zwischen +Verbindungshalten und Rechenarbeit.** Die neun Werkzeuge teilen sich +Chat-Verbindung, Datenbank und Oberfläche — sie zu trennen erzeugt nur +Verbindungsverdopplung und Abstimmungsaufwand. Was dagegen wirklich getrennt +gehört, ist alles, was minutenlang die CPU belegt oder abstürzen kann: ffmpeg +und whisper. Die laufen als Kindprozesse und dürfen scheitern, ohne die +Chat-Aufnahme mitzureißen. + +**Eine Oberfläche ist der eigentliche Gewinn.** Neun Werkzeuge, die man einzeln +starten und einzeln aufrufen muss, benutzt man nach zwei Wochen nicht mehr. Ein +Tab mit Navigation ist der Unterschied zwischen Werkzeugkasten und +Bastelkeller. + +**Lokal, nicht Cloud.** Die Oberfläche liegt auf demselben Rechner wie OBS. Das +ist keine Einschränkung, sondern Voraussetzung: Replay-Buffer, Szenenwechsel und +Videoverarbeitung brauchen die lokale Maschine ohnehin. Es gibt nichts zu +verteilen. + +## Konsequenzen + +- **Der Ingest darf nie sterben.** Er läuft im selben Prozess wie die + Oberfläche, also gilt: jede Route hat eigenes Fehlerabfangen, kein + Oberflächenfehler beendet den Prozess. Rechenarbeit läuft ausschließlich in + Kindprozessen. +- **Die Oberfläche ist eine Sicht auf Zustand, kein Ort für Zustand.** Alles + Dauerhafte steht in SQLite. Ein Neustart der Oberfläche verliert nichts. +- **Neustart heißt Chat-Lücke.** Wer während eines Streams eine Änderung + ausrollt, verliert ein paar Sekunden Chat. Das ist hinnehmbar; wenn es stört, + lässt sich der Ingest später als eigener Prozess herauslösen — die Naht dafür + ist die Datenbank und existiert bereits. +- **Kein Fernzugriff, keine Anmeldung.** Der Dienst ist an `127.0.0.1` des + Hosts gebunden. Falls Abdu später vom Handy auf das Dashboard schauen will, + ist der Weg ein Tunnel (Tailscale, Cloudflare Tunnel) — **nicht** ein + geöffneter Port mit selbstgebauter Anmeldung. +- **Gefahr Rumpelkammer.** Neun Werkzeuge unter einer Navigation werden + unbrauchbar, wenn die Oberfläche einfach wächst. Gegenmaßnahme: die Startseite + zeigt den aktuellen Stream und die anstehenden Entscheidungen (Clips zum + Freigeben, Raid-Vorschläge, Erstchatter), nicht eine Liste von Werkzeugen. +- Die Prozessliste in [`01-architecture.md`](../01-architecture.md) ist damit + überholt und wurde ersetzt. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index de3e657..03fd5a0 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -7,6 +7,7 @@ | [0003](ADR-0003-eventsub-websocket.md) | EventSub ueber WebSocket, Chat ueber IRC | akzeptiert | | [0004](ADR-0004-docker.md) | Betrieb in Docker, whisper.cpp statt Python | akzeptiert | | [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert | +| [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert | Neue Entscheidung: Datei `ADR--.md`, Aufbau Kontext / Entscheidung / Begruendung / Konsequenzen. diff --git a/package.json b/package.json index 8e2059d..7b51078 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "#shared/*": "./packages/shared/src/*", "#core/*": "./packages/core/src/*", "#db/*": "./packages/db/src/*", - "#twitch/*": "./packages/twitch/src/*" + "#twitch/*": "./packages/twitch/src/*", + "#obs/*": "./packages/obs/src/*" }, "scripts": { "start": "docker compose up -d", diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 203dece..718664e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -27,6 +27,14 @@ export type Config = { }; }; obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] }; + clipper: { + enabled: boolean; + alsoCreateTwitchClip: boolean; + cooldownGlobalSeconds: number; + cooldownUserSeconds: number; + followersOnly: boolean; + renderWhileLive: boolean; + }; ingestServer: { port: number }; dashboard: { port: number }; raw: YamlMap; @@ -46,6 +54,14 @@ const DEFAULTS = { }, }, obs: { url: 'ws://host.docker.internal:4455', replayBufferSeconds: 90, chapterScenes: [] }, + clipper: { + enabled: true, + alsoCreateTwitchClip: true, + cooldownGlobalSeconds: 60, + cooldownUserSeconds: 300, + followersOnly: false, + renderWhileLive: false, + }, ingestServer: { port: 4790 }, dashboard: { port: 4792 }, }; diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 262241c..6323ee1 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -5,6 +5,20 @@ import type { Db } from './index.ts'; import type { ChatMessage, EventType, StreamEvent } from '#shared/events.ts'; import { ulid } from '#shared/events.ts'; +/** Spalten, die nach dem Anlegen eines Clips noch veraendert werden duerfen. */ +const CLIP_COLUMNS = [ + 'twitch_clip_id', 'raw_path', 'vertical_path', 'subtitle_path', + 'duration_s', 'title', 'transcript', 'status', 'score', 'uploaded_to', +] as const; +export type ClipColumn = (typeof CLIP_COLUMNS)[number]; + +export type ClipRow = { + id: string; twitch_clip_id: string | null; stream_id: string | null; + created_at: string; trigger: string; trigger_score: number | null; + source: string; raw_path: string | null; vertical_path: string | null; + status: string; score: number | null; +}; + export type StreamRow = { id: string; started_at: string; ended_at: string | null; peak_viewers: number; unique_chatters: number; @@ -62,6 +76,14 @@ export function repos(db: Db) { recentChat: db.prepare( 'SELECT ts, text, user_id FROM chat_messages WHERE ts >= ? ORDER BY ts' ), + insertClip: db.prepare(` + INSERT INTO clips (id, stream_id, created_at, trigger, trigger_score, source, status) + VALUES (?,?,?,?,?,?,?) + `), + getClip: db.prepare('SELECT * FROM clips WHERE id = ?'), + clipsOfStream: db.prepare( + 'SELECT * FROM clips WHERE stream_id IS ? ORDER BY created_at DESC' + ), stats: db.prepare(` SELECT (SELECT COUNT(*) FROM chat_messages) AS messages, @@ -119,6 +141,29 @@ export function repos(db: Db) { stats() { return stmt.stats.get() as { messages: number; users: number; streams: number; events: number }; }, + + insertClip(c: { + id: string; streamId: string | null; trigger: string; + triggerScore: number | null; source: string; status: string; + }): void { + stmt.insertClip.run(c.id, c.streamId, new Date().toISOString(), c.trigger, + c.triggerScore, c.source, c.status); + }, + + /** Aktualisiert einzelne Spalten eines Clips. Nur bekannte Spalten sind erlaubt. */ + updateClip(id: string, fields: Partial>): void { + const keys = Object.keys(fields).filter((k): k is ClipColumn => + (CLIP_COLUMNS as readonly string[]).includes(k)); + if (!keys.length) return; + const set = keys.map((k) => `${k} = ?`).join(', '); + db.prepare(`UPDATE clips SET ${set} WHERE id = ?`) + .run(...keys.map((k) => fields[k] ?? null), id); + }, + + getClip(id: string) { return stmt.getClip.get(id) as ClipRow | undefined; }, + clipsOfStream(streamId: string | null) { + return stmt.clipsOfStream.all(streamId) as ClipRow[]; + }, }; } diff --git a/packages/obs/src/client.ts b/packages/obs/src/client.ts new file mode 100644 index 0000000..195d4c8 --- /dev/null +++ b/packages/obs/src/client.ts @@ -0,0 +1,163 @@ +// obs-websocket v5. Eigene Umsetzung statt npm-Paket - das Protokoll ist klein +// und das Projekt kommt ohne Abhaengigkeiten aus (ADR-0001, Nachtrag 2). +// +// Ablauf: Server schickt Hello (op 0), Client antwortet mit Identify (op 1), +// Server bestaetigt mit Identified (op 2). Danach Requests (op 6) / Responses +// (op 7) und Events (op 5). + +import crypto from 'node:crypto'; +import { logger } from '#core/logger.ts'; +import { forever } from '#core/retry.ts'; + +const log = logger('obs'); + +/** Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs. */ +export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, outputs: 64 } as const; +const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes | SUBSCRIPTIONS.outputs; + +/** + * Antwort auf die Authentifizierungsaufforderung. + * base64(sha256( base64(sha256(passwort + salt)) + challenge )) + */ +export function authString(password: string, salt: string, challenge: string): string { + const secret = crypto.createHash('sha256').update(password + salt).digest('base64'); + return crypto.createHash('sha256').update(secret + challenge).digest('base64'); +} + +type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }; + +export type ObsEventHandlers = { + onReplaySaved?: (path: string) => void; + onSceneChanged?: (scene: string) => void; + onIdentified?: () => void; + onClosed?: () => void; +}; + +export type ObsClient = { + run: (signal: AbortSignal) => Promise; + request: (type: string, data?: unknown) => Promise; + isConnected: () => boolean; +}; + +export function createObs( + url: string, + password: () => string | undefined, + handlers: ObsEventHandlers +): ObsClient { + let ws: WebSocket | null = null; + let identified = false; + let seq = 0; + const pending = new Map(); + + function send(op: number, d: unknown): void { + if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error('OBS ist nicht verbunden'); + ws.send(JSON.stringify({ op, d })); + } + + function request(type: string, data?: unknown): Promise { + if (!identified) return Promise.reject(new Error('OBS ist nicht verbunden')); + const requestId = `r${++seq}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(requestId); + reject(new Error(`OBS antwortet nicht auf ${type}`)); + }, 10_000); + pending.set(requestId, { resolve: resolve as (v: unknown) => void, reject, timer }); + try { + send(6, { requestType: type, requestId, ...(data ? { requestData: data } : {}) }); + } catch (err) { + clearTimeout(timer); + pending.delete(requestId); + reject(err as Error); + } + }); + } + + function handleMessage(raw: string, fail: (why: string) => void): void { + let msg: { op: number; d: Record }; + try { msg = JSON.parse(raw); } catch { return; } + + switch (msg.op) { + case 0: { // Hello + const auth = msg.d['authentication'] as { challenge: string; salt: string } | undefined; + const identify: Record = { + rpcVersion: 1, eventSubscriptions: DEFAULT_SUBS, + }; + if (auth) { + const pw = password(); + if (!pw) return fail('OBS verlangt ein Passwort, es ist keines hinterlegt (/setup)'); + identify['authentication'] = authString(pw, auth.salt, auth.challenge); + } + send(1, identify); + break; + } + case 2: { // Identified + identified = true; + log.info(`Mit OBS verbunden (RPC ${String(msg.d['negotiatedRpcVersion'])})`); + handlers.onIdentified?.(); + break; + } + case 5: { // Event + const type = msg.d['eventType'] as string; + const data = (msg.d['eventData'] ?? {}) as Record; + if (type === 'ReplayBufferSaved') { + handlers.onReplaySaved?.(String(data['savedReplayPath'] ?? '')); + } else if (type === 'CurrentProgramSceneChanged') { + handlers.onSceneChanged?.(String(data['sceneName'] ?? '')); + } + break; + } + case 7: { // RequestResponse + const id = msg.d['requestId'] as string; + const entry = pending.get(id); + if (!entry) break; + pending.delete(id); + clearTimeout(entry.timer); + const status = msg.d['requestStatus'] as { result: boolean; code: number; comment?: string }; + if (status?.result) entry.resolve(msg.d['responseData'] ?? {}); + else entry.reject(new Error( + `OBS lehnt ab (${status?.code}): ${status?.comment ?? 'ohne Begruendung'}`)); + break; + } + default: break; + } + } + + function connectOnce(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + const socket = new WebSocket(url); + ws = socket; + identified = false; + + const fail = (why: string) => { + identified = false; + for (const [, p] of pending) { clearTimeout(p.timer); p.reject(new Error(why)); } + pending.clear(); + try { socket.close(); } catch { /* schon zu */ } + handlers.onClosed?.(); + reject(new Error(why)); + }; + signal.addEventListener('abort', () => fail('abgebrochen'), { once: true }); + + socket.addEventListener('message', (ev) => handleMessage(String(ev.data), fail)); + socket.addEventListener('error', () => fail('WebSocket-Fehler')); + socket.addEventListener('close', (ev) => fail(`Verbindung geschlossen (${ev.code})`)); + }); + } + + return { + run: (signal) => forever(connectOnce, { + signal, + baseMs: 2000, + maxMs: 60_000, + onWait: (ms, n) => { + // Erst ab dem dritten Versuch laut werden: OBS laeuft oft einfach + // noch nicht, wenn das Toolkit startet. + const line = `OBS nicht erreichbar, neuer Versuch in ${Math.round(ms / 1000)} s`; + if (n >= 3) log.warn(line); else log.debug(line); + }, + }), + request, + isConnected: () => identified, + }; +} diff --git a/packages/obs/src/obs.test.ts b/packages/obs/src/obs.test.ts new file mode 100644 index 0000000..cc4060f --- /dev/null +++ b/packages/obs/src/obs.test.ts @@ -0,0 +1,100 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { authString } from './client.ts'; +import { mapReplayPath, waitForStableFile } from './replay-path.ts'; + +// Passwort, Salt und Challenge stammen aus der offiziellen Protokollbeschreibung +// (obsproject/obs-websocket, docs/generated/protocol.md, Abschnitt "Creating an +// authentication string"). Ein erwartetes Ergebnis nennt die Spezifikation +// NICHT - sie beschreibt nur die vier Schritte. Der Erwartungswert unten wurde +// deshalb unabhaengig mit Pythons hashlib aus derselben Beschreibung gerechnet +// und stimmt mit dieser Umsetzung ueberein. Es ist also ein +// Quervergleich zweier Implementierungen, kein amtlicher Testvektor. Der echte +// Beweis ist die erste erfolgreiche Anmeldung an einem laufenden OBS. +const SPEC_PASSWORD = 'supersecretpassword'; +const SPEC_SALT = 'lM1GncleQOaCu9lT1yeUZhFYnqhsLLP1G5lAGo3ixaI='; +const SPEC_CHALLENGE = '+IxH4CnCiqpX1rM9scsNynZzbOe4KhDeYcTNS3PDaeY='; + +test('Authentifizierung folgt dem obs-websocket-v5-Verfahren', () => { + assert.equal( + authString(SPEC_PASSWORD, SPEC_SALT, SPEC_CHALLENGE), + '1Ct943GAT+6YQUUX47Ia/ncufilbe6+oD6lY+5kaCu4=' + ); +}); + +test('zweistufig, nicht einstufig', () => { + // Faengt die naheliegenden Verwechslungen ab: nur einmal hashen, Reihenfolge + // vertauschen, oder Hex statt base64. + const einstufig = crypto.createHash('sha256') + .update(SPEC_PASSWORD + SPEC_SALT + SPEC_CHALLENGE).digest('base64'); + const vertauscht = authString(SPEC_PASSWORD, SPEC_CHALLENGE, SPEC_SALT); + const richtig = authString(SPEC_PASSWORD, SPEC_SALT, SPEC_CHALLENGE); + assert.notEqual(richtig, einstufig); + assert.notEqual(richtig, vertauscht); + assert.match(richtig, /^[A-Za-z0-9+/]+=*$/, 'muss base64 sein, nicht hex'); +}); + +test('gleicher Eingang, gleicher Ausgang', () => { + const a = authString('pw', 'salt', 'challenge'); + const b = authString('pw', 'salt', 'challenge'); + assert.equal(a, b); + assert.notEqual(a, authString('pw2', 'salt', 'challenge')); +}); + +test('uebersetzt einen Windows-Pfad in den Container-Mount', () => { + assert.equal( + mapReplayPath('C:\\Users\\abdu\\Videos\\replay\\Replay 2026-09-09 20-15-33.mkv', + 'C:/Users/abdu/Videos/replay', '/replay'), + '/replay/Replay 2026-09-09 20-15-33.mkv' + ); +}); + +test('ignoriert Gross- und Kleinschreibung, wie Windows das tut', () => { + assert.equal( + mapReplayPath('c:/users/ABDU/Videos/Replay/x.mkv', 'C:/Users/abdu/Videos/replay', '/replay'), + '/replay/x.mkv' + ); +}); + +test('behaelt Unterordner bei', () => { + assert.equal( + mapReplayPath('C:/replay/2026-09/x.mkv', 'C:/replay', '/replay'), + '/replay/2026-09/x.mkv' + ); +}); + +test('faellt auf den Dateinamen zurueck, wenn der Ordner nicht passt', () => { + // Genau der Fall, wenn OBS_REPLAY_DIR falsch gesetzt ist + assert.equal( + mapReplayPath('D:/woanders/x.mkv', 'C:/replay', '/replay'), + '/replay/x.mkv' + ); + assert.equal(mapReplayPath('C:/irgendwo/x.mkv', undefined, '/replay'), '/replay/x.mkv'); +}); + +test('wartet, bis eine Datei fertig geschrieben ist', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'obs-test-')); + const file = path.join(dir, 'replay.mkv'); + fs.writeFileSync(file, 'a'); + // Datei waechst noch zwei Mal, danach ist sie stabil + let writes = 0; + const timer = setInterval(() => { + if (++writes > 2) { clearInterval(timer); return; } + fs.appendFileSync(file, 'b'.repeat(1000)); + }, 150); + + const size = await waitForStableFile(file, { intervalMs: 100, stableChecks: 3, timeoutMs: 8000 }); + clearInterval(timer); + assert.equal(size, fs.statSync(file).size); +}); + +test('gibt auf, wenn die Datei nie auftaucht', async () => { + await assert.rejects( + () => waitForStableFile('/gibt/es/nicht.mkv', { timeoutMs: 600, intervalMs: 100 }), + /nicht fertig geschrieben/ + ); +}); diff --git a/packages/obs/src/replay-path.ts b/packages/obs/src/replay-path.ts new file mode 100644 index 0000000..bd97145 --- /dev/null +++ b/packages/obs/src/replay-path.ts @@ -0,0 +1,59 @@ +// OBS laeuft auf dem Host und meldet Windows-Pfade; der Container sieht den +// Wiedergabepuffer-Ordner unter /replay. Diese Uebersetzung ist die Stelle, +// an der die Clip-Pipeline am ehesten scheitert - deshalb eigene Datei mit +// eigenen Tests. Siehe docs/07-mvp-plan.md, Scheibe S2. + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** `C:\Users\abdu\Videos\replay\Replay 2026-09-09.mkv` -> `/replay/Replay 2026-09-09.mkv` */ +export function mapReplayPath( + hostPath: string, hostDir: string | undefined, containerDir: string +): string { + const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, ''); + const file = norm(hostPath); + + if (hostDir) { + const dir = norm(hostDir); + // Windows-Pfade sind nicht gross-/kleinschreibungsempfindlich + if (file.toLowerCase().startsWith(`${dir.toLowerCase()}/`)) { + return path.posix.join(containerDir, file.slice(dir.length + 1)); + } + } + // Faellt zurueck auf den Dateinamen - das stimmt, solange OBS flach ablegt + return path.posix.join(containerDir, file.slice(file.lastIndexOf('/') + 1)); +} + +/** + * Wartet, bis die Datei existiert und ihre Groesse sich nicht mehr aendert. + * OBS meldet den gespeicherten Puffer, bevor der Schreibvorgang auf langsamen + * Platten sicher abgeschlossen ist. + */ +export async function waitForStableFile( + file: string, + opts: { timeoutMs?: number; intervalMs?: number; stableChecks?: number } = {} +): Promise { + const timeout = opts.timeoutMs ?? 30_000; + const interval = opts.intervalMs ?? 400; + const needed = opts.stableChecks ?? 3; + const deadline = Date.now() + timeout; + + let lastSize = -1; + let stable = 0; + + while (Date.now() < deadline) { + try { + const { size } = await fs.stat(file); + if (size > 0 && size === lastSize) { + if (++stable >= needed) return size; + } else { + stable = 0; + lastSize = size; + } + } catch { + stable = 0; // noch nicht da + } + await new Promise((r) => setTimeout(r, interval)); + } + throw new Error(`Datei wurde nicht fertig geschrieben: ${file}`); +}