openvibers/openvibe.live Commits
VOD/clip overviews: keep the full text so expanding actually expands
Changes
@@@ -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@@@ -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 { /* */ }@@@ -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 { /* */ }@@@ -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 { /* */ }@@@ -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 { /* */ }}}
@@ -267,6 +267,16 @@ function initDb() {
@@ -267,6 +267,16 @@ function initDb() {
clip_notified INTEGER DEFAULT 0,
clip_notified INTEGER DEFAULT 0,
clip_notify_at DATETIME
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 ────────────────────────────────────────────
// ── Migrations ────────────────────────────────────────────
try {
try {
@@ -2678,7 +2688,11 @@ function getLatestStreamMemory(streamId) {
@@ -2678,7 +2688,11 @@ function getLatestStreamMemory(streamId) {
// can be cached at write time and shown on listing cards.
// can be cached at write time and shown on listing cards.
function _shortOverview(text) {
function _shortOverview(text) {
const t = (text || '').replace(/\s+/g, ' ').trim();
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;
if (t.length <= 150) return t;
const m = t.match(/^.*?[.!?](\s|$)/);
const m = t.match(/^.*?[.!?](\s|$)/);
let s = m ? m[0].trim() : '';
let s = m ? m[0].trim() : '';
@@ -2733,13 +2747,17 @@ function getClipAiState(clipId) {
@@ -2733,13 +2747,17 @@ function getClipAiState(clipId) {
}
}
function setVodAiOverview(vodId, text) {
function setVodAiOverview(vodId, text) {
_ensureVodAiState(vodId);
_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 }) {
function setClipAiOverview(clipId, { overview = null, transcript = null, segments = null }) {
void transcript; // full transcript text lives in the segments JSON now
void transcript; // full transcript text lives in the segments JSON now
_ensureClipAiState(clipId);
_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) {
function _segJson(segments) {
if (!Array.isArray(segments)) return null; // null = never attempted
if (!Array.isArray(segments)) return null; // null = never attempted
@@ -48,11 +48,13 @@ function withUserFields(clip) {
@@ -48,11 +48,13 @@ function withUserFields(clip) {
// Absolutize Media-relative URLs so the SPA doesn't resolve them against Live.
// 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);
clip.playback_url = media.publicUrl(clip.playback_url) || media.clipUrl(clip.id);
if (clip.thumbnail_url) clip.thumbnail_url = media.publicUrl(clip.thumbnail_url);
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 {
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 */ }
} catch { /* best-effort */ }
}
}
return clip;
return clip;
@@ -215,7 +217,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -215,7 +217,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
const st = db.getClipAiState(clip.id);
const st = db.getClipAiState(clip.id);
if (st) {
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) {
if (st.ai_transcript_json) {
clip.ai_transcript_json = 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 { /* */ }
try { clip.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim() || clip.ai_transcript; } catch { /* */ }
@@ -97,11 +97,13 @@ function withUserFields(row) {
@@ -97,11 +97,13 @@ function withUserFields(row) {
// so the SPA doesn't resolve them against Live's origin.
// so the SPA doesn't resolve them against Live's origin.
row.playback_url = media.publicUrl(row.playback_url) || media.vodPlaybackUrl(row.id);
row.playback_url = media.publicUrl(row.playback_url) || media.vodPlaybackUrl(row.id);
if (row.thumbnail_url) row.thumbnail_url = media.publicUrl(row.thumbnail_url);
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 {
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 */ }
} catch { /* best-effort */ }
}
}
return row;
return row;
@@ -360,7 +362,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -360,7 +362,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
const st = db.getVodAiState(vod.id);
const st = db.getVodAiState(vod.id);
if (st) {
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) {
if (st.ai_transcript_json) {
vod.ai_transcript_json = 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 { /* */ }
try { vod.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim() || vod.ai_transcript; } catch { /* */ }
@@ -43,7 +43,7 @@ function _overlayAiState(row, kind) {
@@ -43,7 +43,7 @@ function _overlayAiState(row, kind) {
if (!st) return;
if (!st) return;
if (st.ai_overview_short) {
if (st.ai_overview_short) {
row.ai_overview_short = row.ai_overview_short || 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) {
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 { /* */ }
try { row.ai_transcript = JSON.parse(st.ai_transcript_json).map(s => s.text).join(' ').trim(); } catch { /* */ }
@@ -330,13 +330,15 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -330,13 +330,15 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
};
};
clips.forEach(nameClip);
clips.forEach(nameClip);
clipsOfStreams.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) => {
const aiShort = (rows, table, col) => {
for (const r of rows) {
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 {
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 */ }
} catch { /* best-effort */ }
}
}
};
};
@@ -1189,10 +1191,11 @@ router.get('/recent-vods', async (req, res) => {
@@ -1189,10 +1191,11 @@ router.get('/recent-vods', async (req, res) => {
const u = db.getUserById(v.user_id);
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 (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 {
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 { /* */ }
} catch { /* */ }
}
}
}
}