openvibers/openvibe.live Commits

Expose VOD transcripts to Media: /api/chat-ai/vod-transcripts

OP OpenVibers committed 18 Aug 2026 · 835111f174 · 2 files, +63 −1

openvibe.media/live/:sel/transcript.json has been reporting transcript: null
for every recording since the cutover. Media reads its own vods.ai_transcript
column, but transcript ownership moved to Live at migration — into
vod_ai_state.ai_transcript_json, and now stream_timeline_events — and nothing
writes Media's column any more. So it only ever served pre-migration text.

Add a batch endpoint Media can read instead. Prefers the timeline (whole-stream
coverage plus non-speech sound events, linked to the VOD by linkTimelineToVod)
and falls back to the batch-transcribed blob for VODs with no live timeline.
Returns flat text, timestamped segments, sound events and the short overview.

Also adds db.getTimelineByVod().

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex 3da45ccd..e9776064 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -216,6 +216,60 @@ router.get('/timeline/:username', (req, res) => {
});
// Full audio transcript for a stream (AI Timeline transcript viewer), with a VOD id for links.
++/**
++ * Batch VOD transcripts, by Media vod id: /api/chat-ai/vod-transcripts?ids=1,2,3
++ *
++ * Live owns transcripts — they were moved here at the cutover into
++ * vod_ai_state.ai_transcript_json (and now stream_timeline_events). Media still has its
++ * own vods.ai_transcript column, but nothing writes it any more, which is why
++ * openvibe.media/live/:sel/transcript.json served null for every post-cutover VOD.
++ * This is the endpoint Media reads instead of its own dead column.
++ */
++router.get('/vod-transcripts', (req, res) => {
++ try {
++ const ids = String(req.query.ids || '')
++ .split(',').map(x => parseInt(x, 10)).filter(Number.isFinite).slice(0, 50);
++ if (!ids.length) return res.json({ transcripts: {} });
++
++ const out = {};
++ for (const vodId of ids) {
++ let segments = [];
++ let events = [];
++ // Prefer the timeline: it covers the whole stream and carries sound events.
++ try {
++ const rows = db.getTimelineByVod(vodId);
++ for (const r of rows) {
++ if (r.kind === 'speech') segments.push({ start: r.start_sec, end: r.end_sec, text: r.text });
++ else events.push({ start: r.start_sec, end: r.end_sec, label: r.label, confidence: r.confidence });
++ }
++ } catch { /* */ }
++ // Fall back to the batch-transcribed blob for VODs with no live timeline.
++ if (!segments.length) {
++ try {
++ const st = db.getVodAiState(vodId);
++ if (st && st.ai_transcript_json) {
++ const parsed = JSON.parse(st.ai_transcript_json);
++ if (Array.isArray(parsed)) segments = parsed;
++ }
++ } catch { /* */ }
++ }
++ let overviewShort = null;
++ try { overviewShort = (db.getVodAiState(vodId) || {}).ai_overview_short || null; } catch { /* */ }
++ const text = segments.map(s => String(s.text || '').trim()).filter(Boolean).join(' ');
++ out[vodId] = {
++ transcript: text || null,
++ segments,
++ events,
++ ai_overview_short: overviewShort,
++ };
++ }
++ res.set('Cache-Control', 'public, max-age=15');
++ res.json({ transcripts: out });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load transcripts' });
++ }
++});
++
router.get('/transcript/:streamId', async (req, res) => {
try {
const sid = parseInt(req.params.streamId, 10);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 81fa5dc3..f145930a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -2793,6 +2793,14 @@ function getTimelineCoverage(streamId) {
return Math.round(covered);
}
++/** Timeline rows for a finished VOD (set by linkTimelineToVod when the recording lands). */
++function getTimelineByVod(vodId) {
++ try {
++ return all(`SELECT kind, start_sec, end_sec, text, label, confidence
++ FROM stream_timeline_events WHERE vod_id = ? ORDER BY start_sec ASC LIMIT 20000`, [vodId]);
++ } catch { return []; }
++}
++
/** Attach a vod_id to a finished stream's rows so VOD views can reuse the timeline. */
function linkTimelineToVod(streamId, vodId) {
try { return run('UPDATE stream_timeline_events SET vod_id = ? WHERE stream_id = ? AND vod_id IS NULL', [vodId, streamId]); }
@@@ -7271,7 +7279,7 @@ module.exports = {
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
updatePasteAi, cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
getStreamMemoriesByUser, countStreamMemoriesByUser, getAiMomentCandidates, getStreamTranscriptSegments, getUserPastesForAi,
-- addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod,
++ addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod,
getVodsForMomentRanking, getClipStartTimesForStream, getChatSpikeOffsets,
getLiveChatBuckets, getRecentChatText, getVodsWithoutAutoClip,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,