openvibers/openvibe.live Commits

VOD/clip overviews: keep the full text so expanding actually expands

OP OpenVibers committed 26 Aug 2026 · b3d4344f61 · 5 files, +47 −22

vod_ai_state/clip_ai_state only stored the ~150-char short teaser — the
full AI overview was thrown away at write time, so the card expander just
un-clamped the same truncated "…" string. Add an ai_overview column, store
both, and overlay short + full on every list/detail/SEO read path. Also fix
the ' ' "tried, nothing to say" sentinel: _shortOverview collapsed it to
NULL, which kept unprocessable VODs in the backfill queue forever.

Existing VODs summarized before this keep only their teaser (the full text
was never persisted); new overviews expand properly.

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

Changes

ddiff --git a/server/db/database.js b/server/db/database.js
iindex 7b703f3c..3869b984 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -267,6 +267,16 @@ function initDb() {
clip_notified INTEGER DEFAULT 0,
clip_notify_at DATETIME
)`);
++ // Full AI overview text. Originally only the ~150-char short was stored, which made
++ // the card expander a no-op on VODs/clips: expanding revealed the same truncated
++ // "…" string because the full version had been thrown away at write time. Shorts
++ // are a derivation, not the source of truth — keep both.
++ try {
++ for (const t of ['vod_ai_state', 'clip_ai_state']) {
++ const cols = database.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name);
++ if (!cols.includes('ai_overview')) database.exec(`ALTER TABLE ${t} ADD COLUMN ai_overview TEXT`);
++ }
++ } catch (e) { console.warn('[DB] ai-overview column migration:', e.message); }
// ── Migrations ────────────────────────────────────────────
try {
@@@ -2678,7 +2688,11 @@ function getLatestStreamMemory(streamId) {
// can be cached at write time and shown on listing cards.
function _shortOverview(text) {
const t = (text || '').replace(/\s+/g, ' ').trim();
-- if (!t || t === ' ') return null;
++ // ' ' is the "tried, nothing to say" sentinel the AI jobs store to mark a row done.
++ // It must round-trip as a non-empty value — the backfill queues treat NULL/'' as
++ // still-pending, so collapsing the sentinel to null meant unprocessable VODs were
++ // retried forever. The frontend trims before rendering, so ' ' never displays.
++ if (!t) return String(text || '').length ? ' ' : null;
if (t.length <= 150) return t;
const m = t.match(/^.*?[.!?](\s|$)/);
let s = m ? m[0].trim() : '';
@@@ -2733,13 +2747,17 @@ function getClipAiState(clipId) {
}
function setVodAiOverview(vodId, text) {
_ensureVodAiState(vodId);
-- return run('UPDATE vod_ai_state SET ai_overview_short = ? WHERE vod_id = ?', [_shortOverview(text), vodId]);
++ // Store the FULL overview alongside the derived short — the card expander swaps
++ // the short teaser for this full text, so losing it makes expansion pointless.
++ const full = (text || '').trim() || null;
++ return run('UPDATE vod_ai_state SET ai_overview = ?, ai_overview_short = ? WHERE vod_id = ?', [full, _shortOverview(text), vodId]);
}
function setClipAiOverview(clipId, { overview = null, transcript = null, segments = null }) {
void transcript; // full transcript text lives in the segments JSON now
_ensureClipAiState(clipId);
-- return run('UPDATE clip_ai_state SET ai_overview_short = ?, ai_transcript_json = COALESCE(?, ai_transcript_json) WHERE clip_id = ?',
-- [_shortOverview(overview), _segJson(segments), clipId]);
++ const full = (overview || '').trim() || null;
++ return run('UPDATE clip_ai_state SET ai_overview = ?, ai_overview_short = ?, ai_transcript_json = COALESCE(?, ai_transcript_json) WHERE clip_id = ?',
++ [full, _shortOverview(overview), _segJson(segments), clipId]);
}
function _segJson(segments) {
if (!Array.isArray(segments)) return null; // null = never attempted
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 9a856423..a8b45526 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -48,11 +48,13 @@ function withUserFields(clip) {
// Absolutize Media-relative URLs so the SPA doesn't resolve them against Live.
clip.playback_url = media.publicUrl(clip.playback_url) || media.clipUrl(clip.id);
if (clip.thumbnail_url) clip.thumbnail_url = media.publicUrl(clip.thumbnail_url);
-- // Short AI overview is Live-owned (clip_ai_state) — overlay for the cards.
-- if (clip.id != null && !clip.ai_overview_short) {
++ // AI overview is Live-owned (clip_ai_state) — overlay short + full for the cards
++ // (the expander swaps the short teaser for the full text).
++ if (clip.id != null && (!clip.ai_overview_short || !clip.ai_overview)) {
try {
-- const s = db.get('SELECT ai_overview_short FROM clip_ai_state WHERE clip_id = ?', [clip.id]);
-- if (s && s.ai_overview_short) clip.ai_overview_short = s.ai_overview_short;
++ const s = db.get('SELECT ai_overview_short, ai_overview FROM clip_ai_state WHERE clip_id = ?', [clip.id]);
++ if (s && s.ai_overview_short && !clip.ai_overview_short) clip.ai_overview_short = s.ai_overview_short;
++ if (s && s.ai_overview && !clip.ai_overview) clip.ai_overview = s.ai_overview;
} catch { /* best-effort */ }
}
return clip;
@@@ -215,7 +217,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
const st = db.getClipAiState(clip.id);
if (st) {
-- if (st.ai_overview_short) { clip.ai_overview_short = st.ai_overview_short; clip.ai_overview = clip.ai_overview || st.ai_overview_short; }
++ if (st.ai_overview_short) { clip.ai_overview_short = st.ai_overview_short; clip.ai_overview = st.ai_overview || clip.ai_overview || st.ai_overview_short; }
if (st.ai_transcript_json) {
clip.ai_transcript_json = st.ai_transcript_json;
try { clip.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim() || clip.ai_transcript; } catch { /* */ }
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex a9e06571..6406f071 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -97,11 +97,13 @@ function withUserFields(row) {
// so the SPA doesn't resolve them against Live's origin.
row.playback_url = media.publicUrl(row.playback_url) || media.vodPlaybackUrl(row.id);
if (row.thumbnail_url) row.thumbnail_url = media.publicUrl(row.thumbnail_url);
-- // Short AI overview is Live-owned (vod_ai_state) — overlay for the cards.
-- if (row.id != null && !row.ai_overview_short) {
++ // AI overview is Live-owned (vod_ai_state) — overlay short + full for the cards
++ // (the expander swaps the short teaser for the full text).
++ if (row.id != null && (!row.ai_overview_short || !row.ai_overview)) {
try {
-- const s = db.get('SELECT ai_overview_short FROM vod_ai_state WHERE vod_id = ?', [row.id]);
-- if (s && s.ai_overview_short) row.ai_overview_short = s.ai_overview_short;
++ const s = db.get('SELECT ai_overview_short, ai_overview FROM vod_ai_state WHERE vod_id = ?', [row.id]);
++ if (s && s.ai_overview_short && !row.ai_overview_short) row.ai_overview_short = s.ai_overview_short;
++ if (s && s.ai_overview && !row.ai_overview) row.ai_overview = s.ai_overview;
} catch { /* best-effort */ }
}
return row;
@@@ -360,7 +362,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
const st = db.getVodAiState(vod.id);
if (st) {
-- if (st.ai_overview_short) { vod.ai_overview_short = st.ai_overview_short; vod.ai_overview = vod.ai_overview || st.ai_overview_short; }
++ if (st.ai_overview_short) { vod.ai_overview_short = st.ai_overview_short; vod.ai_overview = st.ai_overview || vod.ai_overview || st.ai_overview_short; }
if (st.ai_transcript_json) {
vod.ai_transcript_json = st.ai_transcript_json;
try { vod.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim() || vod.ai_transcript; } catch { /* */ }
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 9a2ceb99..763351ca 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -43,7 +43,7 @@ function _overlayAiState(row, kind) {
if (!st) return;
if (st.ai_overview_short) {
row.ai_overview_short = row.ai_overview_short || st.ai_overview_short;
-- row.ai_overview = row.ai_overview || st.ai_overview_short;
++ row.ai_overview = row.ai_overview || st.ai_overview || st.ai_overview_short;
}
if (st.ai_transcript_json && !row.ai_transcript) {
try { row.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim(); } catch { /* */ }
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex c8bf16ef..6224bacc 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -330,13 +330,15 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
};
clips.forEach(nameClip);
clipsOfStreams.forEach(nameClip);
-- // Short AI overviews are Live-owned (vod_ai_state / clip_ai_state).
++ // AI overviews are Live-owned (vod_ai_state / clip_ai_state). Attach the
++ // full text too — the card expander swaps the short teaser for it.
const aiShort = (rows, table, col) => {
for (const r of rows) {
-- if (!r || r.id == null || r.ai_overview_short) continue;
++ if (!r || r.id == null || (r.ai_overview_short && r.ai_overview)) continue;
try {
-- const s = db.get(`SELECT ai_overview_short FROM ${table} WHERE ${col} = ?`, [r.id]);
-- if (s && s.ai_overview_short) r.ai_overview_short = s.ai_overview_short;
++ const s = db.get(`SELECT ai_overview_short, ai_overview FROM ${table} WHERE ${col} = ?`, [r.id]);
++ if (s && s.ai_overview_short && !r.ai_overview_short) r.ai_overview_short = s.ai_overview_short;
++ if (s && s.ai_overview && !r.ai_overview) r.ai_overview = s.ai_overview;
} catch { /* best-effort */ }
}
};
@@@ -1189,10 +1191,11 @@ router.get('/recent-vods', async (req, res) => {
const u = db.getUserById(v.user_id);
if (u) { v.username = u.username; v.display_name = u.display_name; v.avatar_url = u.avatar_url; }
}
-- if (v && v.id != null && !v.ai_overview_short) {
++ if (v && v.id != null && (!v.ai_overview_short || !v.ai_overview)) {
try {
-- const s = db.get('SELECT ai_overview_short FROM vod_ai_state WHERE vod_id = ?', [v.id]);
-- if (s && s.ai_overview_short) v.ai_overview_short = s.ai_overview_short;
++ const s = db.get('SELECT ai_overview_short, ai_overview FROM vod_ai_state WHERE vod_id = ?', [v.id]);
++ if (s && s.ai_overview_short && !v.ai_overview_short) v.ai_overview_short = s.ai_overview_short;
++ if (s && s.ai_overview && !v.ai_overview) v.ai_overview = s.ai_overview;
} catch { /* */ }
}
}