S2: obs-websocket, Wiedergabepuffer und der !clip-Ausloeser

Eigener obs-websocket-v5-Client (das Protokoll ist klein genug, und das
Projekt bleibt ohne Abhaengigkeiten). Anmeldung, Wiederverbindung mit
wachsender Pause, Ereignisse fuer ReplayBufferSaved und Szenenwechsel.

Der Clipper loest zwei Wege gleichzeitig aus: OBS-Wiedergabepuffer fuer die
Rohdatei in Aufnahmequalitaet, Twitch-Clip fuer die teilbare URL. Faellt
einer aus, ist der andere trotzdem etwas wert. Ein direkt in OBS gedrueckter
Hotkey wird ohne unseren Umweg erkannt und als vollwertiger Ausloeser
behandelt.

Die Pfaduebersetzung Windows -> Container hat eine eigene Datei und eigene
Tests - das ist die Stelle, an der die Pipeline am ehesten scheitert. Dazu
Warten auf den abgeschlossenen Schreibvorgang, bevor die Datei uebernommen
wird.

Zum Testvektor der OBS-Anmeldung: die Spezifikation nennt Salt und Challenge,
aber kein erwartetes Ergebnis. Der Erwartungswert wurde unabhaengig mit
Pythons hashlib aus derselben Beschreibung gerechnet; der Test sagt das im
Kommentar, statt einen amtlichen Vektor vorzutaeuschen.

29 Tests gruen. Die Abnahme von S2 braucht ein laufendes OBS und steht aus.

Ausserdem: ADR-0006 haelt fest, dass alles unter einem Dach laeuft - eine
lokale Weboberflaeche plus Kindprozesse fuer Rechenarbeit. Die Prozessliste
in der Architekturdoku ist entsprechend ersetzt. Der Onboarding-Assistent
ist als Teil von S5 aufgenommen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 10:10:54 +02:00
1 parent d07c18441b
commit 684f10eb02
13 files changed
+796 -44

No files matched your search

+200
View File
@@ -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<string, number>();
// 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<boolean> {
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<void> {
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<void> {
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<string | null> {
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 };
}
+55 -4
View File
@@ -1,17 +1,20 @@
// Ingest-Daemon. Liest Chat und Zuschauerzahlen, schreibt beides in die // Ingest-Daemon. Liest Chat und Zuschauerzahlen, schreibt beides in die
// Datenbank und haelt die Einrichtungsseite bereit. // 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. // 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 { logger } from '#core/logger.ts';
import { sleep } from '#core/retry.ts'; import { sleep } from '#core/retry.ts';
import { openDb, closeDb } from '#db/index.ts'; import { openDb, closeDb } from '#db/index.ts';
import { repos } from '#db/repos.ts'; import { repos } from '#db/repos.ts';
import { createObs } from '#obs/client.ts';
import { helix } from '#twitch/helix.ts'; import { helix } from '#twitch/helix.ts';
import { createIrc } from '#twitch/irc.ts'; import { createIrc } from '#twitch/irc.ts';
import { readSecrets } from '#twitch/secrets.ts'; import { readSecrets } from '#twitch/secrets.ts';
import { createClipper } from './clips.ts';
import { createSpikeDetector } from './spike.ts'; import { createSpikeDetector } from './spike.ts';
import { startServer } from './server.ts'; import { startServer } from './server.ts';
@@ -26,12 +29,16 @@ export async function runIngest(): Promise<void> {
const state = { const state = {
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
streamId: null as string | null, streamId: null as string | null,
broadcasterId: null as string | null,
lastSeenLive: 0, lastSeenLive: 0,
viewers: 0, viewers: 0,
chatConnected: false, chatConnected: false,
chatAnonymous: true, chatAnonymous: true,
obsConnected: false,
obsScene: null as string | null,
messages: 0, messages: 0,
spikeCandidates: 0, spikeCandidates: 0,
clipsTriggered: 0,
lastError: null as string | null, lastError: null as string | null,
}; };
@@ -44,6 +51,39 @@ export async function runIngest(): Promise<void> {
process.on('SIGINT', () => stop('SIGINT')); process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM')); 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({ startServer({
channel: cfg.channel.login, channel: cfg.channel.login,
port: cfg.dashboard.port, port: cfg.dashboard.port,
@@ -51,6 +91,7 @@ export async function runIngest(): Promise<void> {
...state, ...state,
db: repo.stats(), db: repo.stats(),
spikeArmed: spikes.isArmed(), spikeArmed: spikes.isArmed(),
clipsWaitingForFile: clipper.pendingCount(),
note: state.chatAnonymous note: state.chatAnonymous
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.' ? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
: undefined, : undefined,
@@ -78,6 +119,12 @@ export async function runIngest(): Promise<void> {
log.debug(`Erster Stream fuer ${msg.displayName}`); 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); const spike = spikes.push(msg);
if (spike) { if (spike) {
state.spikeCandidates++; state.spikeCandidates++;
@@ -93,6 +140,7 @@ export async function runIngest(): Promise<void> {
}, },
}); });
void irc.run(abort.signal); void irc.run(abort.signal);
void obs.run(abort.signal);
// ---- Zuschauerzahlen und Stream-Sessions // ---- Zuschauerzahlen und Stream-Sessions
void pollStream(cfg, repo, state, abort.signal); void pollStream(cfg, repo, state, abort.signal);
@@ -102,7 +150,8 @@ export async function runIngest(): Promise<void> {
} }
type State = { 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( async function pollStream(
@@ -133,8 +182,10 @@ async function pollStream(
} }
} else { } else {
warnedMissingAuth = false; warnedMissingAuth = false;
const userId = cfg.channel.userId || const userId = state.broadcasterId || cfg.channel.userId ||
(await helix.userByLogin(cfg.channel.login))?.id || ''; (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 live = userId ? await helix.streamByUserId(userId) : null;
const now = Date.now(); const now = Date.now();
+7 -2
View File
@@ -49,10 +49,15 @@ clipper:
crf: 20 crf: 20
encoder: libx264 # keine NVIDIA-GPU da, QSV aus WSL2 unzuverlaessig encoder: libx264 # keine NVIDIA-GPU da, QSV aus WSL2 unzuverlaessig
niceness: 15 # Encoding laeuft nachrangig zum Stream 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: triggers:
chatCommand: "!clip" chatCommand: "!clip"
hotkeyName: SaveReplay onSpike: false # erst nach Kalibrierung der Spitzen-Erkennung
onSpike: false # Der Hotkey wird in OBS auf "Wiedergabepuffer speichern" gelegt. Es gibt
# bewusst keinen eigenen Hotkey-Dienst - siehe ADR-0004, Punkt 4.
broadcaster: broadcaster:
enabled: true enabled: true
+22 -29
View File
@@ -37,35 +37,28 @@
## Prozesse ## Prozesse
Der Verbund besteht aus wenigen langlaufenden Prozessen und mehreren **Ein Prozess.** Oberflaeche, Ingest und die Werkzeugmodule laufen gemeinsam in
Kurzläufern, gestartet über `docker compose up -d`. 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* | Port | Wofuer |
> Prozess in *einem* Container; die Aufteilung unten ist der Zielzustand ab |------|--------|
> Meilenstein M3, wenn es mehr als einen Konsumenten gibt. Siehe | 4792 | Weboberflaeche: Einrichtung, OAuth-Rueckruf, Sichtung, Dashboard, `/health` |
> [`07-mvp-plan.md`](07-mvp-plan.md). | 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 | Die Werkzeuge sind Module, keine eigenen Dienste:
|---------|------|---------|
| `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 |
**Kurzläufer (event- oder zeitgetriggert):** | Modul | laeuft | Ausloeser |
|-------|--------|-----------|
| Prozess | Trigger | | Ingest | dauernd | IRC, EventSub, Helix-Polling |
|---------|---------| | Community, Loyalty | dauernd | Chat-Ereignisse |
| `clipper` | Clip-Event vom Bus, oder manuell über CLI | | Clipper | bei Bedarf | `!clip`, OBS-Hotkey, spaeter Chat-Spitze |
| `broadcaster` | `stream.online` | | Broadcaster | bei Bedarf | `stream.online` |
| `recap` | `stream.offline` (+ 5 min Karenz) | | Recap, VOD-Kapitel | bei Bedarf | `stream.offline` |
| `vod-chapters` | `stream.offline` | | Raid-Scout | bei Bedarf | `!raidziel`, Oberflaeche |
| `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.
## Event-Bus ## Event-Bus
@@ -82,10 +75,10 @@ type StreamEvent = {
}; };
``` ```
Transport: In-Prozess ein `EventEmitter`, prozessübergreifend ein Transport: ein `EventEmitter` im Prozess. Solange alles unter einem Dach laeuft
WebSocket-Broadcast auf `ws://127.0.0.1:4790`. Kein Kafka, kein Redis. Wenn ein (ADR-0006), braucht es keinen Netzwerk-Bus. Die `events`-Tabelle ist trotzdem
Konsument abstürzt, holt er sich beim Neustart die verpassten Events aus der die Wahrheit: sie wird immer geschrieben, und ein spaeter herausgeloester
`events`-Tabelle über `ts > lastSeen`. Prozess kann seinen Rueckstand ueber `ts > lastSeen` aufholen.
Vollständige Event-Liste: siehe [`03-data-model.md`](03-data-model.md). Vollständige Event-Liste: siehe [`03-data-model.md`](03-data-model.md).
+45 -8
View File
@@ -37,7 +37,8 @@ gibt. Vorher ist sie nur Ballast.
| S0 Container + Toolchain | **fertig** — Image 258 MB, `check-env` ohne harte Fehler | | S0 Container + Toolchain | **fertig** — Image 258 MB, `check-env` ohne harte Fehler |
| Layout eingemessen | **fertig** — `config/layout.yaml`, vorgezogen aus S3 | | Layout eingemessen | **fertig** — `config/layout.yaml`, vorgezogen aus S3 |
| S1 Einrichtung + Auth + DB + IRC-Ingest | **fertig** — 20 Tests grün, Daemon läuft | | 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 **S1 abgenommen am 2026-09-09.** Der Daemon läuft, hängt am Chat, die
Einrichtung ist unter `http://localhost:4792/setup` erreichbar. 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 **Fertig, wenn:** `!clip` im laufenden Stream erzeugt binnen 5 s eine
`raw.mp4` im Ablageordner und eine Chat-Rückmeldung. `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 ### S3 — 9:16-Render · ~1,5 Tage
`calibrate`-Kommando: zieht einen Frame aus einer Replay-Datei, schreibt ihn `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 Untertitel, und der Text wird auf dem Handy von der TikTok-Oberfläche nicht
verdeckt. 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 **Erststart-Assistent.** Die heutige Einrichtungsseite ist ein Formularblock —
des letzten Streams: Videovorschau, Score, Auslöser, der Chat-Ausschnitt aus das reicht für mich, nicht für Abdu. Der Assistent führt durch sechs Schritte
dem Clip-Fenster, Transkript, und die Knöpfe *Übernehmen* / *Verwerfen*. und **prüft jeden davon live**, statt zu behaupten, es sei erledigt:
Übernommene Clips wandern nach `data/clips/_upload/`.
**Fertig, wenn:** Nach einem Stream lässt sich in unter zwei Minuten | Schritt | Wird geprüft |
entscheiden, welche Clips hochgeladen werden. |---|---|
| 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 ### S6 — Erstchatter-Begrüßung · ~0,5 Tage · optional
+81
View File
@@ -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.
+1
View File
@@ -7,6 +7,7 @@
| [0003](ADR-0003-eventsub-websocket.md) | EventSub ueber WebSocket, Chat ueber IRC | akzeptiert | | [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 | | [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 | | [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-<nummer>-<kurzname>.md`, Aufbau Kontext / Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
Entscheidung / Begruendung / Konsequenzen. Entscheidung / Begruendung / Konsequenzen.
+2 -1
View File
@@ -11,7 +11,8 @@
"#shared/*": "./packages/shared/src/*", "#shared/*": "./packages/shared/src/*",
"#core/*": "./packages/core/src/*", "#core/*": "./packages/core/src/*",
"#db/*": "./packages/db/src/*", "#db/*": "./packages/db/src/*",
"#twitch/*": "./packages/twitch/src/*" "#twitch/*": "./packages/twitch/src/*",
"#obs/*": "./packages/obs/src/*"
}, },
"scripts": { "scripts": {
"start": "docker compose up -d", "start": "docker compose up -d",
+16
View File
@@ -27,6 +27,14 @@ export type Config = {
}; };
}; };
obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] }; obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] };
clipper: {
enabled: boolean;
alsoCreateTwitchClip: boolean;
cooldownGlobalSeconds: number;
cooldownUserSeconds: number;
followersOnly: boolean;
renderWhileLive: boolean;
};
ingestServer: { port: number }; ingestServer: { port: number };
dashboard: { port: number }; dashboard: { port: number };
raw: YamlMap; raw: YamlMap;
@@ -46,6 +54,14 @@ const DEFAULTS = {
}, },
}, },
obs: { url: 'ws://host.docker.internal:4455', replayBufferSeconds: 90, chapterScenes: [] }, 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 }, ingestServer: { port: 4790 },
dashboard: { port: 4792 }, dashboard: { port: 4792 },
}; };
+45
View File
@@ -5,6 +5,20 @@ import type { Db } from './index.ts';
import type { ChatMessage, EventType, StreamEvent } from '#shared/events.ts'; import type { ChatMessage, EventType, StreamEvent } from '#shared/events.ts';
import { ulid } 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 = { export type StreamRow = {
id: string; started_at: string; ended_at: string | null; id: string; started_at: string; ended_at: string | null;
peak_viewers: number; unique_chatters: number; peak_viewers: number; unique_chatters: number;
@@ -62,6 +76,14 @@ export function repos(db: Db) {
recentChat: db.prepare( recentChat: db.prepare(
'SELECT ts, text, user_id FROM chat_messages WHERE ts >= ? ORDER BY ts' '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(` stats: db.prepare(`
SELECT SELECT
(SELECT COUNT(*) FROM chat_messages) AS messages, (SELECT COUNT(*) FROM chat_messages) AS messages,
@@ -119,6 +141,29 @@ export function repos(db: Db) {
stats() { stats() {
return stmt.stats.get() as { messages: number; users: number; streams: number; events: number }; 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<Record<ClipColumn, string | number | null>>): 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[];
},
}; };
} }
+163
View File
@@ -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<void>;
request: <T = unknown>(type: string, data?: unknown) => Promise<T>;
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<string, Pending>();
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<T>(type: string, data?: unknown): Promise<T> {
if (!identified) return Promise.reject(new Error('OBS ist nicht verbunden'));
const requestId = `r${++seq}`;
return new Promise<T>((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<string, unknown> };
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<string, unknown> = {
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<string, unknown>;
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<void> {
return new Promise<void>((_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,
};
}
+100
View File
@@ -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/
);
});
+59
View File
@@ -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<number> {
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}`);
}