openvibers/openvibe.live Commits
Expose VOD transcripts to Media: /api/chat-ai/vod-transcripts
Changes
@@@ -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);@@@ -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,
@@ -216,6 +216,60 @@ router.get('/timeline/:username', (req, res) => {
@@ -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.
// 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) => {
router.get('/transcript/:streamId', async (req, res) => {
try {
try {
const sid = parseInt(req.params.streamId, 10);
const sid = parseInt(req.params.streamId, 10);
@@ -2793,6 +2793,14 @@ function getTimelineCoverage(streamId) {
@@ -2793,6 +2793,14 @@ function getTimelineCoverage(streamId) {
return Math.round(covered);
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. */
/** Attach a vod_id to a finished stream's rows so VOD views can reuse the timeline. */
function linkTimelineToVod(streamId, vodId) {
function linkTimelineToVod(streamId, vodId) {
try { return run('UPDATE stream_timeline_events SET vod_id = ? WHERE stream_id = ? AND vod_id IS NULL', [vodId, streamId]); }
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 = {
@@ -7271,7 +7279,7 @@ module.exports = {
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
updatePasteAi, cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
updatePasteAi, cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
getStreamMemoriesByUser, countStreamMemoriesByUser, getAiMomentCandidates, getStreamTranscriptSegments, getUserPastesForAi,
getStreamMemoriesByUser, countStreamMemoriesByUser, getAiMomentCandidates, getStreamTranscriptSegments, getUserPastesForAi,
- addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod,
+ addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod,
getVodsForMomentRanking, getClipStartTimesForStream, getChatSpikeOffsets,
getVodsForMomentRanking, getClipStartTimesForStream, getChatSpikeOffsets,
getLiveChatBuckets, getRecentChatText, getVodsWithoutAutoClip,
getLiveChatBuckets, getRecentChatText, getVodsWithoutAutoClip,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,