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

+45
View File
@@ -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<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[];
},
};
}