openvibers/openvibe.live Commits

AI state: adopt timeline rows orphaned after the one-shot vod.ready link

OP OpenVibers committed 19 Aug 2026 · aa132a1c0e · 2 files, +46 −0

linkTimelineToVod() fires once, when Media reports the recording ready, but
transcription of spooled audio keeps appending rows for a while afterwards.
Those rows were written with vod_id NULL and nothing ever came back for them,
so a VOD's transcript silently stopped at whatever the webhook happened to
catch — stream 2128 ended with 11 orphaned speech rows against 2 linked.

If any row for a stream already points at a VOD then the rest belong to it too
(one recording per stream), so adopt them at startup. New writes stamp
themselves, so this only drains the existing backlog. Streams that never
produced a VOD are left alone — there is nothing to infer from.

Co-Authored-By: Claude Opus 5 <[email protected]>

Changes

ddiff --git a/server/db/database.js b/server/db/database.js
iindex 617bbc04..073a5ac8 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -211,6 +211,33 @@ function _dedupeStreamMemories(database) {
}
}
++/**
++ * Adopt timeline rows left behind by the one-shot vod.ready link.
++ *
++ * linkTimelineToVod() is a single UPDATE fired when Media says the recording is ready,
++ * but transcription of spooled audio keeps running for a while afterwards. Those later
++ * rows were written with vod_id NULL and nothing ever came back for them, so the VOD's
++ * transcript silently stopped at whatever had been transcribed by the webhook — stream
++ * 2128 ended up with 11 orphaned speech rows against 2 linked ones.
++ *
++ * If any row for a stream already points at a VOD, the stream's remaining rows belong to
++ * that same VOD by construction: one recording per stream. New writes stamp themselves
++ * (see timeline-job), so this only has to clean up the existing backlog.
++ */
++function _adoptOrphanedTimelineRows(database) {
++ try {
++ const res = database.prepare(`UPDATE stream_timeline_events AS t
++ SET vod_id = (SELECT s.vod_id FROM stream_timeline_events s
++ WHERE s.stream_id = t.stream_id AND s.vod_id IS NOT NULL LIMIT 1)
++ WHERE t.vod_id IS NULL
++ AND EXISTS (SELECT 1 FROM stream_timeline_events s
++ WHERE s.stream_id = t.stream_id AND s.vod_id IS NOT NULL)`).run();
++ if (res.changes) console.log(`[DB] stream_timeline_events: adopted ${res.changes} orphaned row(s) onto their VOD`);
++ } catch (e) {
++ console.warn('[DB] timeline orphan adoption skipped:', e.message);
++ }
++}
++
function initDb() {
const database = getDb();
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
@@@ -1047,6 +1074,7 @@ function initDb() {
_dedupeKeyedTable(database, 'vod_ai_state', 'vod_id');
_dedupeKeyedTable(database, 'clip_ai_state', 'clip_id');
_dedupeStreamMemories(database);
++ _adoptOrphanedTimelineRows(database);
// ── Unified audio timeline ───────────────────────────────────────────────
// One time-indexed row per thing heard on a stream: a phrase that was spoken
ddiff --git a/test/ai-state-integrity.test.js b/test/ai-state-integrity.test.js
iindex f66a8821..aad99328 100644
---- a/test/ai-state-integrity.test.js
++++ b/test/ai-state-integrity.test.js
@@@ -149,6 +149,24 @@ db.linkTimelineToVod(902, 501);
assert.strictEqual(db.getTimelineByVod(501).length, 1, 'linkTimelineToVod must adopt orphaned rows');
console.log('OK 7: live timeline rows reachable by vod_id; orphans still adoptable');
++// ── 7: rows written AFTER the one-shot vod.ready link get adopted on restart ────────
++// This is the real-world shape: the webhook links what exists, then transcription of
++// spooled audio keeps appending rows that nobody ever comes back for.
++db.addTimelineEvents([
++ { stream_id: 902, kind: 'speech', start_sec: 90, text: 'transcribed after vod.ready' },
++ { stream_id: 902, kind: 'speech', start_sec: 120, text: 'and later still' },
++]);
++assert.strictEqual(db.getTimelineByVod(501).length, 1, 'late rows start orphaned (the bug)');
++db.initDb(); // startup repair
++assert.strictEqual(db.getTimelineByVod(501).length, 3,
++ 'orphaned rows must be adopted onto the VOD their siblings already point at');
++// A stream that never had a VOD must stay untouched — nothing to infer from.
++db.addTimelineEvents([{ stream_id: 900, kind: 'speech', start_sec: 5, text: 'never recorded' }]);
++db.initDb();
++const orphanCount = raw.prepare('SELECT COUNT(*) n FROM stream_timeline_events WHERE stream_id = 900 AND vod_id IS NULL').get().n;
++assert.strictEqual(orphanCount, 1, 'a stream with no VOD at all must not be given one');
++console.log('OK 8: late rows adopted onto their VOD; VOD-less streams left alone');
++
raw.close();
try { fs.unlinkSync(tmp); } catch { /* */ }
for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }