openvibers/openvibe.live Commits
AI state: adopt timeline rows orphaned after the one-shot vod.ready link
Changes
@@@ -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@@@ -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 { /* */ } }
@@ -211,6 +211,33 @@ function _dedupeStreamMemories(database) {
@@ -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() {
function initDb() {
const database = getDb();
const database = getDb();
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
@@ -1047,6 +1074,7 @@ function initDb() {
@@ -1047,6 +1074,7 @@ function initDb() {
_dedupeKeyedTable(database, 'vod_ai_state', 'vod_id');
_dedupeKeyedTable(database, 'vod_ai_state', 'vod_id');
_dedupeKeyedTable(database, 'clip_ai_state', 'clip_id');
_dedupeKeyedTable(database, 'clip_ai_state', 'clip_id');
_dedupeStreamMemories(database);
_dedupeStreamMemories(database);
+ _adoptOrphanedTimelineRows(database);
// ── Unified audio timeline ───────────────────────────────────────────────
// ── Unified audio timeline ───────────────────────────────────────────────
// One time-indexed row per thing heard on a stream: a phrase that was spoken
// One time-indexed row per thing heard on a stream: a phrase that was spoken
@@ -149,6 +149,24 @@ db.linkTimelineToVod(902, 501);
@@ -149,6 +149,24 @@ db.linkTimelineToVod(902, 501);
assert.strictEqual(db.getTimelineByVod(501).length, 1, 'linkTimelineToVod must adopt orphaned rows');
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');
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();
raw.close();
try { fs.unlinkSync(tmp); } catch { /* */ }
try { fs.unlinkSync(tmp); } catch { /* */ }
for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }