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

+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}`);
}