openvibers/openvibe.live Commits
Live home: a streamer who is live right now says so on the Recently Online cards (LIVE badge with viewers, "Live now · uptime", live slot first), a thumbnail that fails to load falls back to the placeholder, and AI overviews written as a list are stored and shown as prose (#153)
Changes
@@@ -167,6 +167,11 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.streamer-group-stream:hover { background: var(--bg-hover); }.streamer-group-stream-thumb-img { width: 100%; height: 100%; object-fit: cover; }.streamer-group-stream-thumb-placeholder { color: var(--text-muted); font-size: 1rem; }++.sg-live { color: #ef4444; font-weight: 700; }++.sg-live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #ef4444; margin-right: 2px; vertical-align: middle; animation: sg-pulse 1.6s ease-in-out infinite; }++@keyframes sg-pulse { 50% { opacity: .35; } }++.streamer-group-stream.is-live .streamer-group-stream-thumb { box-shadow: 0 0 0 2px #ef4444; border-radius: 8px; }++@media (prefers-reduced-motion: reduce) { .sg-live-dot { animation: none; } }.streamer-group-stream-title { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.streamer-group-stream-time { font-size: 0.75rem; }.hero-rotate {@@@ -1387,6 +1387,18 @@ async function loadHomeRecentOnline(opts) {} catch { rail.filling = false; }}++const _SG_THUMB_PLACEHOLDER = '<div class="streamer-group-stream-thumb-placeholder"><i class="fa-solid fa-video"></i></div>';++// openvibe.media thumbnails come sized (Media's /t/:id?w=&fmt=): a 320 px WebP is about a third of the JPEG.++function _sgThumbSrc(url) {++ if (!url) return null;++ return /^https:\/\/openvibe\.media\/t\/[^?#\s]+$/.test(url) ? `${url}?w=320&fmt=webp` : url;++}++// A thumbnail that does not load (a dropped request on a phone, a frame not written yet) becomes the placeholder.++function _sgThumbFail(img) {++ const box = img && img.parentNode;++ if (box) box.innerHTML = _SG_THUMB_PLACEHOLDER;++}++function renderRecentlyOnline(containerId, streamers, append) {const container = document.getElementById(containerId);if (!container) return;@@@ -1401,15 +1413,21 @@ function renderRecentlyOnline(containerId, streamers, append) {const channelHref = `/@${s.username}`;const streamsHtml = msList.length ? msList.map(ms => {const href = channelPath(s.username, ms.slug || ms.managed_stream_id);-- const thumb = ms.vod_thumbnail-- ? `<img src="${esc(ms.vod_thumbnail)}" alt="" loading="lazy" class="streamer-group-stream-thumb-img">`-- : `<div class="streamer-group-stream-thumb-placeholder"><i class="fa-solid fa-video"></i></div>`;++ // A live slot shows its current frame; otherwise the latest recording's. A picture that fails to load++ // becomes the placeholder (_sgThumbFail) instead of a broken image.++ const src = _sgThumbSrc(ms.is_live && ms.live_thumbnail ? ms.live_thumbnail : ms.vod_thumbnail);++ const thumb = src++ ? `<img src="${esc(src)}" alt="" loading="lazy" class="streamer-group-stream-thumb-img" onerror="_sgThumbFail(this)">`++ : _SG_THUMB_PLACEHOLDER;++ const when = ms.is_live++ ? `<span class="sg-live"><span class="sg-live-dot"></span> Live now</span>${ms.live_started_at ? ` · ${formatUptime(ms.live_started_at)}` : ''}`++ : `<i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}`;return `-- <a class="streamer-group-stream" href="${href}" onclick="return handleLinkClick(event, '${href}')">++ <a class="streamer-group-stream${ms.is_live ? ' is-live' : ''}" href="${href}" onclick="return handleLinkClick(event, '${href}')"><div class="streamer-group-stream-thumb">${thumb}</div><div class="streamer-group-stream-info"><div class="streamer-group-stream-title">${esc(ms.title || 'Stream')}</div>-- <div class="streamer-group-stream-time muted"><i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}</div>++ <div class="streamer-group-stream-time muted">${when}</div></div></a>`;}).join('') : `<a class="streamer-group-stream" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">@@@ -1421,7 +1439,9 @@ function renderRecentlyOnline(containerId, streamers, append) {<a class="streamer-group-identity" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">${avatar}<span class="streamer-group-name">${esc(s.display_name || s.username)}</span>-- <span class="streamer-group-last-online muted"><i class="fa-solid fa-clock"></i> ${timeAgo(s.last_online_at)}</span>++ ${s.live++ ? `<span class="streamer-group-last-online sg-live"><span class="sg-live-dot"></span> LIVE · <i class="fa-solid fa-eye"></i> ${Number(s.live.viewer_count) || 0}</span>`++ : `<span class="streamer-group-last-online muted"><i class="fa-solid fa-clock"></i> ${timeAgo(s.last_online_at)}</span>`}</a></div>${_cardAiHTML(s.ai_overview_short, s.ai_overview)}@@@ -372,11 +372,25 @@ async function getStreamMemories(streamId) {async function getLatestStreamMemory(streamId) {return await get('SELECT * FROM stream_memories WHERE stream_id = ? ORDER BY offset_seconds DESC LIMIT 1', [streamId]);}++// AI text shown publicly is one paragraph of prose: a model that answered with a++// list, headings or bold labels ("- Streamer handle: x (@x).") has its headings++// left out and its list markers, bold and labels dropped before the lines are joined.++const _PROSE_LABEL = /^(?:streamer(?: handle)?|handle|(?:profile )?overview|summary|profile|content(?: niche)?|niche|tone(?:\/vibe)?|vibe|(?:recurring )?themes|activities|moderation(?: notes)?|notes?|bio|category)\s*:\s*/i;++function _plainProse(text) {++ const raw = String(text || '');++ if (!/^\s*(?:#{1,6}\s|[-*•]\s|\d+[.)]\s|>)|\*\*|__/m.test(raw) && !_PROSE_LABEL.test(raw.trim())) return raw;++ return raw.split(/\r?\n/)++ .filter((l) => !/^\s*#{1,6}\s/.test(l))++ .map((l) => l.replace(/^\s*(?:[-*•]\s+|\d+[.)]\s+|>\s*)/, '').replace(/\*\*|__/g, '').trim().replace(_PROSE_LABEL, '').trim())++ .filter(Boolean)++ .map((l) => (/[.!?…"')\]]$/.test(l) ? l : `${l}.`))++ .join(' ');++}// Derive a concise short overview from a long one — the lead sentence(s), capped// ~150 chars at a sentence/word boundary. Deterministic + free (no AI call), so it// can be cached at write time and shown on listing cards.function _shortOverview(text) {-- const t = (text || '').replace(/\s+/g, ' ').trim();++ const t = _plainProse(text).replace(/\s+/g, ' ').trim();// ' ' 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@@@ -619,6 +633,17 @@ async function cleanupMalformedAiText() {if (clean && clean !== r.v) { await run('UPDATE streamer_overviews SET overview = ? WHERE user_id = ?', [clean, r.k]); fixed++; }}} catch { /* */ }++ // Streamer overviews written as a list ("- Streamer handle: …") before the++ // prompt asked for prose: the stored text and its card line become prose.++ try {++ const rows = await all(`SELECT user_id AS k, overview AS v FROM streamer_overviews++ WHERE overview ~ '(^|\n)\\s*([-*•#]|\\d+[.)])\\s' OR overview LIKE '%**%' OR overview_short ~ '^\\s*[-*•#]'`);++ for (const r of rows) {++ const clean = _plainProse(r.v);++ await run('UPDATE streamer_overviews SET overview = ?, overview_short = ? WHERE user_id = ?', [clean, _shortOverview(clean), r.k]);++ fixed++;++ }++ } catch { /* */ }if (fixed) console.log(`[AI] Cleaned ${fixed} malformed JSON AI text value(s)`);return fixed;}@@@ -816,7 +841,7 @@ async function upsertStreamerOverview(userId, { overview, model = null, sourcesON CONFLICT(user_id) DO UPDATE SEToverview = excluded.overview, overview_short = excluded.overview_short, model = excluded.model,sources = excluded.sources, generated_at = ov_now()`,-- [userId, overview || '', _shortOverview(overview), model, sources]);++ [userId, _plainProse(overview || ''), _shortOverview(overview), model, sources]);}async function getStreamerOverview(userId) {return await get('SELECT * FROM streamer_overviews WHERE user_id = ?', [userId]);@@@ -1264,6 +1264,25 @@ router.get('/recently-online', async (req, res) => {} catch (err) {console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);}++ // Who on this page is live right now: the card says so (and which slot), instead of "last online 22h ago".++ try {++ const ids = streamers.map(s => s.user_id).filter(Boolean);++ if (ids.length) {++ const live = await db.all(`SELECT id, user_id, managed_stream_id, title, started_at, viewer_count, thumbnail_url FROM streams++ WHERE is_live = 1 AND user_id IN (${ids.map(() => '?').join(', ')}) ORDER BY started_at DESC`, ids);++ for (const s of streamers) {++ const mine = live.filter(l => l.user_id === s.user_id);++ if (!mine.length) continue;++ s.live = { started_at: mine[0].started_at, viewer_count: mine.reduce((n, l) => n + (Number(l.viewer_count) || 0), 0), title: mine[0].title || null };++ for (const ms of (s.managed_streams || [])) {++ const l = mine.find(x => x.managed_stream_id != null && Number(x.managed_stream_id) === Number(ms.managed_stream_id));++ if (l) { ms.is_live = true; ms.live_started_at = l.started_at; if (l.thumbnail_url) ms.live_thumbnail = l.thumbnail_url; }++ }++ // The live slot leads the card's list.++ s.managed_streams.sort((a, b) => (b.is_live ? 1 : 0) - (a.is_live ? 1 : 0));++ }++ }++ } catch (err) { console.warn('[Streaming] recently-online live status failed:', err.message); }// Each streamer's nearest active donation goal → progress bar on the card.try {const goals = await db.getActiveGoalsForUsers(streamers.map(s => s.user_id).filter(Boolean));nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++// GET /api/streams/recently-online says who is live right now (server/streaming/routes.js): a streamer live on a slot++// gets `live` (started_at, viewer count summed over their live slots, title), that slot is marked is_live with its frame++// and listed first; an offline streamer gets neither. The home card reads them (public/js/app-home.js++// renderRecentlyOnline): LIVE instead of "last online 22h ago".++// node --import ./test/helpers/pg-preload.mjs test/recently-online-live.test.js++const assert = require('assert');++const http = require('http');++const express = require('express');++++process.env.NODE_ENV = 'test';++console.warn = () => {};++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++++const db = require('../server/db/database');++(async () => {++ await db.initDb();++ const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };++ const izor = await mk('izor'), quiet1 = await mk('quietone');++ const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);++ const ranked = await slot(izor, 'ranked'), chill = await slot(izor, 'chill'), q = await slot(quiet1, 'main');++ const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, viewers = 0 }) => await db.run(++ `INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live, viewer_count, started_at, ended_at, thumbnail_url, created_at)++ VALUES (?, ?, ?, 'webrtc', ?, ?, to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS'), ${live ? 'NULL' : "to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS')"}, ?, ov_now())`,++ live ? [uid, ms, 'DUO RANKED', 1, viewers, startedAgo, 'https://openvibe.media/live/frame.jpg'] : [uid, ms, 'old', 0, 0, startedAgo, endedAgo, null]);++ await stream(izor, chill, { startedAgo: 3000, endedAgo: 2900 });++ await stream(izor, ranked, { startedAgo: 2000, endedAgo: 1900 });++ await stream(izor, ranked, { live: true, startedAgo: 120, viewers: 7 });++ await stream(quiet1, q, { startedAgo: 600, endedAgo: 500 });++++ const app = express();++ app.use('/api/streams', require('../server/streaming/routes'));++ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });++ try {++ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/streams/recently-online?limit=10`);++ assert.strictEqual(res.status, 200);++ const body = await res.json();++ const iz = body.streamers.find((s) => s.username === 'izor');++ const qu = body.streamers.find((s) => s.username === 'quietone');++ assert.ok(iz && qu, JSON.stringify(body.streamers.map((s) => s.username)));++ assert.deepStrictEqual([iz.live && iz.live.viewer_count, iz.live && iz.live.title], [7, 'DUO RANKED']);++ assert.deepStrictEqual(iz.managed_streams.map((m) => [m.slug, !!m.is_live]), [['ranked', true], ['chill', false]], 'the live slot first');++ assert.strictEqual(iz.managed_streams[0].live_thumbnail, 'https://openvibe.media/live/frame.jpg');++ assert.ok(!qu.live && !qu.managed_streams.some((m) => m.is_live), 'an offline streamer is not live');++ console.log('recently-online live: all checks passed');++ } finally { server.close(); }++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++// A streamer's AI overview is public text (home cards, the channel page's description): a model answer written as a++// list ("- Streamer handle: x (@x).") is stored as one paragraph of prose with a card line that starts with words, and++// rows stored as lists before that are repaired by the AI cleanup pass. The "nothing to say" sentinel still survives.++const assert = require('assert');++console.warn = () => {};++++const db = require('../server/db/database');++(async () => {++ await db.initDb();++ const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; };++ const fixer = await mk('finditfixit'), old = await mk('oldlist'), plain = await mk('plainann');++++ await db.upsertStreamerOverview(fixer, { overview: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios on stream\n- Tone: calm, patient' });++ const a = await db.getStreamerOverview(fixer);++ assert.strictEqual(a.overview, 'finditfixit (@finditfixit). repairs old TVs and radios on stream. calm, patient.');++ assert.ok(!/^[-*#\s]/.test(a.overview_short) && !a.overview_short.includes('**'), a.overview_short);++++ const prose = 'plainann streams Rust and talks through every build. Tone: chill.\nThe chat is small and friendly.';++ await db.upsertStreamerOverview(plain, { overview: prose });++ assert.strictEqual((await db.getStreamerOverview(plain)).overview, prose, 'prose is stored as written');++++ // A row stored as a list before the prompt asked for prose.++ await db.run("INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at) VALUES (?, ?, ?, 'x', '{}', ov_now())",++ [old, '- Streamer handle: oldlist (@oldlist).\n- Streams Minecraft builds most nights.', '- Streamer handle: oldlist (@oldlist).']);++ await db.cleanupMalformedAiText();++ const b = await db.getStreamerOverview(old);++ assert.strictEqual(b.overview, 'oldlist (@oldlist). Streams Minecraft builds most nights.');++ assert.strictEqual(b.overview_short, 'oldlist (@oldlist). Streams Minecraft builds most nights.');++ assert.strictEqual((await db.getStreamerOverview(plain)).overview, prose, 'the repair leaves prose alone');++++ await db.upsertStreamerOverview(plain, { overview: ' ' });++ assert.strictEqual((await db.getStreamerOverview(plain)).overview_short, ' ', 'the tried-and-empty sentinel round-trips');++++ console.log('streamer-overview-prose: all checks passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -167,6 +167,11 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -167,6 +167,11 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.streamer-group-stream:hover { background: var(--bg-hover); }
.streamer-group-stream:hover { background: var(--bg-hover); }
.streamer-group-stream-thumb-img { width: 100%; height: 100%; object-fit: cover; }
.streamer-group-stream-thumb-img { width: 100%; height: 100%; object-fit: cover; }
.streamer-group-stream-thumb-placeholder { color: var(--text-muted); font-size: 1rem; }
.streamer-group-stream-thumb-placeholder { color: var(--text-muted); font-size: 1rem; }
+.sg-live { color: #ef4444; font-weight: 700; }
+.sg-live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #ef4444; margin-right: 2px; vertical-align: middle; animation: sg-pulse 1.6s ease-in-out infinite; }
+@keyframes sg-pulse { 50% { opacity: .35; } }
+.streamer-group-stream.is-live .streamer-group-stream-thumb { box-shadow: 0 0 0 2px #ef4444; border-radius: 8px; }
+@media (prefers-reduced-motion: reduce) { .sg-live-dot { animation: none; } }
.streamer-group-stream-title { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.streamer-group-stream-title { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.streamer-group-stream-time { font-size: 0.75rem; }
.streamer-group-stream-time { font-size: 0.75rem; }
.hero-rotate {
.hero-rotate {
@@ -1387,6 +1387,18 @@ async function loadHomeRecentOnline(opts) {
@@ -1387,6 +1387,18 @@ async function loadHomeRecentOnline(opts) {
} catch { rail.filling = false; }
} catch { rail.filling = false; }
}
}
+const _SG_THUMB_PLACEHOLDER = '<div class="streamer-group-stream-thumb-placeholder"><i class="fa-solid fa-video"></i></div>';
+// openvibe.media thumbnails come sized (Media's /t/:id?w=&fmt=): a 320 px WebP is about a third of the JPEG.
+function _sgThumbSrc(url) {
+ if (!url) return null;
+ return /^https:\/\/openvibe\.media\/t\/[^?#\s]+$/.test(url) ? `${url}?w=320&fmt=webp` : url;
+}
+// A thumbnail that does not load (a dropped request on a phone, a frame not written yet) becomes the placeholder.
+function _sgThumbFail(img) {
+ const box = img && img.parentNode;
+ if (box) box.innerHTML = _SG_THUMB_PLACEHOLDER;
+}
+
function renderRecentlyOnline(containerId, streamers, append) {
function renderRecentlyOnline(containerId, streamers, append) {
const container = document.getElementById(containerId);
const container = document.getElementById(containerId);
if (!container) return;
if (!container) return;
@@ -1401,15 +1413,21 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1401,15 +1413,21 @@ function renderRecentlyOnline(containerId, streamers, append) {
const channelHref = `/@${s.username}`;
const channelHref = `/@${s.username}`;
const streamsHtml = msList.length ? msList.map(ms => {
const streamsHtml = msList.length ? msList.map(ms => {
const href = channelPath(s.username, ms.slug || ms.managed_stream_id);
const href = channelPath(s.username, ms.slug || ms.managed_stream_id);
- const thumb = ms.vod_thumbnail
- ? `<img src="${esc(ms.vod_thumbnail)}" alt="" loading="lazy" class="streamer-group-stream-thumb-img">`
- : `<div class="streamer-group-stream-thumb-placeholder"><i class="fa-solid fa-video"></i></div>`;
+ // A live slot shows its current frame; otherwise the latest recording's. A picture that fails to load
+ // becomes the placeholder (_sgThumbFail) instead of a broken image.
+ const src = _sgThumbSrc(ms.is_live && ms.live_thumbnail ? ms.live_thumbnail : ms.vod_thumbnail);
+ const thumb = src
+ ? `<img src="${esc(src)}" alt="" loading="lazy" class="streamer-group-stream-thumb-img" onerror="_sgThumbFail(this)">`
+ : _SG_THUMB_PLACEHOLDER;
+ const when = ms.is_live
+ ? `<span class="sg-live"><span class="sg-live-dot"></span> Live now</span>${ms.live_started_at ? ` · ${formatUptime(ms.live_started_at)}` : ''}`
+ : `<i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}`;
return `
return `
- <a class="streamer-group-stream" href="${href}" onclick="return handleLinkClick(event, '${href}')">
+ <a class="streamer-group-stream${ms.is_live ? ' is-live' : ''}" href="${href}" onclick="return handleLinkClick(event, '${href}')">
<div class="streamer-group-stream-thumb">${thumb}</div>
<div class="streamer-group-stream-thumb">${thumb}</div>
<div class="streamer-group-stream-info">
<div class="streamer-group-stream-info">
<div class="streamer-group-stream-title">${esc(ms.title || 'Stream')}</div>
<div class="streamer-group-stream-title">${esc(ms.title || 'Stream')}</div>
- <div class="streamer-group-stream-time muted"><i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}</div>
+ <div class="streamer-group-stream-time muted">${when}</div>
</div>
</div>
</a>`;
</a>`;
}).join('') : `<a class="streamer-group-stream" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">
}).join('') : `<a class="streamer-group-stream" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">
@@ -1421,7 +1439,9 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1421,7 +1439,9 @@ function renderRecentlyOnline(containerId, streamers, append) {
<a class="streamer-group-identity" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">
<a class="streamer-group-identity" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">
${avatar}
${avatar}
<span class="streamer-group-name">${esc(s.display_name || s.username)}</span>
<span class="streamer-group-name">${esc(s.display_name || s.username)}</span>
- <span class="streamer-group-last-online muted"><i class="fa-solid fa-clock"></i> ${timeAgo(s.last_online_at)}</span>
+ ${s.live
+ ? `<span class="streamer-group-last-online sg-live"><span class="sg-live-dot"></span> LIVE · <i class="fa-solid fa-eye"></i> ${Number(s.live.viewer_count) || 0}</span>`
+ : `<span class="streamer-group-last-online muted"><i class="fa-solid fa-clock"></i> ${timeAgo(s.last_online_at)}</span>`}
</a>
</a>
</div>
</div>
${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
@@ -372,11 +372,25 @@ async function getStreamMemories(streamId) {
@@ -372,11 +372,25 @@ async function getStreamMemories(streamId) {
async function getLatestStreamMemory(streamId) {
async function getLatestStreamMemory(streamId) {
return await get('SELECT * FROM stream_memories WHERE stream_id = ? ORDER BY offset_seconds DESC LIMIT 1', [streamId]);
return await get('SELECT * FROM stream_memories WHERE stream_id = ? ORDER BY offset_seconds DESC LIMIT 1', [streamId]);
}
}
+// AI text shown publicly is one paragraph of prose: a model that answered with a
+// list, headings or bold labels ("- Streamer handle: x (@x).") has its headings
+// left out and its list markers, bold and labels dropped before the lines are joined.
+const _PROSE_LABEL = /^(?:streamer(?: handle)?|handle|(?:profile )?overview|summary|profile|content(?: niche)?|niche|tone(?:\/vibe)?|vibe|(?:recurring )?themes|activities|moderation(?: notes)?|notes?|bio|category)\s*:\s*/i;
+function _plainProse(text) {
+ const raw = String(text || '');
+ if (!/^\s*(?:#{1,6}\s|[-*•]\s|\d+[.)]\s|>)|\*\*|__/m.test(raw) && !_PROSE_LABEL.test(raw.trim())) return raw;
+ return raw.split(/\r?\n/)
+ .filter((l) => !/^\s*#{1,6}\s/.test(l))
+ .map((l) => l.replace(/^\s*(?:[-*•]\s+|\d+[.)]\s+|>\s*)/, '').replace(/\*\*|__/g, '').trim().replace(_PROSE_LABEL, '').trim())
+ .filter(Boolean)
+ .map((l) => (/[.!?…"')\]]$/.test(l) ? l : `${l}.`))
+ .join(' ');
+}
// Derive a concise short overview from a long one — the lead sentence(s), capped
// Derive a concise short overview from a long one — the lead sentence(s), capped
// ~150 chars at a sentence/word boundary. Deterministic + free (no AI call), so it
// ~150 chars at a sentence/word boundary. Deterministic + free (no AI call), so it
// 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 = _plainProse(text).replace(/\s+/g, ' ').trim();
// ' ' is the "tried, nothing to say" sentinel the AI jobs store to mark a row done.
// ' ' 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
// 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
// still-pending, so collapsing the sentinel to null meant unprocessable VODs were
@@ -619,6 +633,17 @@ async function cleanupMalformedAiText() {
@@ -619,6 +633,17 @@ async function cleanupMalformedAiText() {
if (clean && clean !== r.v) { await run('UPDATE streamer_overviews SET overview = ? WHERE user_id = ?', [clean, r.k]); fixed++; }
if (clean && clean !== r.v) { await run('UPDATE streamer_overviews SET overview = ? WHERE user_id = ?', [clean, r.k]); fixed++; }
}
}
} catch { /* */ }
} catch { /* */ }
+ // Streamer overviews written as a list ("- Streamer handle: …") before the
+ // prompt asked for prose: the stored text and its card line become prose.
+ try {
+ const rows = await all(`SELECT user_id AS k, overview AS v FROM streamer_overviews
+ WHERE overview ~ '(^|\n)\\s*([-*•#]|\\d+[.)])\\s' OR overview LIKE '%**%' OR overview_short ~ '^\\s*[-*•#]'`);
+ for (const r of rows) {
+ const clean = _plainProse(r.v);
+ await run('UPDATE streamer_overviews SET overview = ?, overview_short = ? WHERE user_id = ?', [clean, _shortOverview(clean), r.k]);
+ fixed++;
+ }
+ } catch { /* */ }
if (fixed) console.log(`[AI] Cleaned ${fixed} malformed JSON AI text value(s)`);
if (fixed) console.log(`[AI] Cleaned ${fixed} malformed JSON AI text value(s)`);
return fixed;
return fixed;
}
}
@@ -816,7 +841,7 @@ async function upsertStreamerOverview(userId, { overview, model = null, sources
@@ -816,7 +841,7 @@ async function upsertStreamerOverview(userId, { overview, model = null, sources
ON CONFLICT(user_id) DO UPDATE SET
ON CONFLICT(user_id) DO UPDATE SET
overview = excluded.overview, overview_short = excluded.overview_short, model = excluded.model,
overview = excluded.overview, overview_short = excluded.overview_short, model = excluded.model,
sources = excluded.sources, generated_at = ov_now()`,
sources = excluded.sources, generated_at = ov_now()`,
- [userId, overview || '', _shortOverview(overview), model, sources]);
+ [userId, _plainProse(overview || ''), _shortOverview(overview), model, sources]);
}
}
async function getStreamerOverview(userId) {
async function getStreamerOverview(userId) {
return await get('SELECT * FROM streamer_overviews WHERE user_id = ?', [userId]);
return await get('SELECT * FROM streamer_overviews WHERE user_id = ?', [userId]);
@@ -1264,6 +1264,25 @@ router.get('/recently-online', async (req, res) => {
@@ -1264,6 +1264,25 @@ router.get('/recently-online', async (req, res) => {
} catch (err) {
} catch (err) {
console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);
console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);
}
}
+ // Who on this page is live right now: the card says so (and which slot), instead of "last online 22h ago".
+ try {
+ const ids = streamers.map(s => s.user_id).filter(Boolean);
+ if (ids.length) {
+ const live = await db.all(`SELECT id, user_id, managed_stream_id, title, started_at, viewer_count, thumbnail_url FROM streams
+ WHERE is_live = 1 AND user_id IN (${ids.map(() => '?').join(', ')}) ORDER BY started_at DESC`, ids);
+ for (const s of streamers) {
+ const mine = live.filter(l => l.user_id === s.user_id);
+ if (!mine.length) continue;
+ s.live = { started_at: mine[0].started_at, viewer_count: mine.reduce((n, l) => n + (Number(l.viewer_count) || 0), 0), title: mine[0].title || null };
+ for (const ms of (s.managed_streams || [])) {
+ const l = mine.find(x => x.managed_stream_id != null && Number(x.managed_stream_id) === Number(ms.managed_stream_id));
+ if (l) { ms.is_live = true; ms.live_started_at = l.started_at; if (l.thumbnail_url) ms.live_thumbnail = l.thumbnail_url; }
+ }
+ // The live slot leads the card's list.
+ s.managed_streams.sort((a, b) => (b.is_live ? 1 : 0) - (a.is_live ? 1 : 0));
+ }
+ }
+ } catch (err) { console.warn('[Streaming] recently-online live status failed:', err.message); }
// Each streamer's nearest active donation goal → progress bar on the card.
// Each streamer's nearest active donation goal → progress bar on the card.
try {
try {
const goals = await db.getActiveGoalsForUsers(streamers.map(s => s.user_id).filter(Boolean));
const goals = await db.getActiveGoalsForUsers(streamers.map(s => s.user_id).filter(Boolean));
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+// GET /api/streams/recently-online says who is live right now (server/streaming/routes.js): a streamer live on a slot
+// gets `live` (started_at, viewer count summed over their live slots, title), that slot is marked is_live with its frame
+// and listed first; an offline streamer gets neither. The home card reads them (public/js/app-home.js
+// renderRecentlyOnline): LIVE instead of "last online 22h ago".
+// node --import ./test/helpers/pg-preload.mjs test/recently-online-live.test.js
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+
+process.env.NODE_ENV = 'test';
+console.warn = () => {};
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+
+const db = require('../server/db/database');
+(async () => {
+ await db.initDb();
+ const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };
+ const izor = await mk('izor'), quiet1 = await mk('quietone');
+ const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);
+ const ranked = await slot(izor, 'ranked'), chill = await slot(izor, 'chill'), q = await slot(quiet1, 'main');
+ const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, viewers = 0 }) => await db.run(
+ `INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live, viewer_count, started_at, ended_at, thumbnail_url, created_at)
+ VALUES (?, ?, ?, 'webrtc', ?, ?, to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS'), ${live ? 'NULL' : "to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS')"}, ?, ov_now())`,
+ live ? [uid, ms, 'DUO RANKED', 1, viewers, startedAgo, 'https://openvibe.media/live/frame.jpg'] : [uid, ms, 'old', 0, 0, startedAgo, endedAgo, null]);
+ await stream(izor, chill, { startedAgo: 3000, endedAgo: 2900 });
+ await stream(izor, ranked, { startedAgo: 2000, endedAgo: 1900 });
+ await stream(izor, ranked, { live: true, startedAgo: 120, viewers: 7 });
+ await stream(quiet1, q, { startedAgo: 600, endedAgo: 500 });
+
+ const app = express();
+ app.use('/api/streams', require('../server/streaming/routes'));
+ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
+ try {
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/streams/recently-online?limit=10`);
+ assert.strictEqual(res.status, 200);
+ const body = await res.json();
+ const iz = body.streamers.find((s) => s.username === 'izor');
+ const qu = body.streamers.find((s) => s.username === 'quietone');
+ assert.ok(iz && qu, JSON.stringify(body.streamers.map((s) => s.username)));
+ assert.deepStrictEqual([iz.live && iz.live.viewer_count, iz.live && iz.live.title], [7, 'DUO RANKED']);
+ assert.deepStrictEqual(iz.managed_streams.map((m) => [m.slug, !!m.is_live]), [['ranked', true], ['chill', false]], 'the live slot first');
+ assert.strictEqual(iz.managed_streams[0].live_thumbnail, 'https://openvibe.media/live/frame.jpg');
+ assert.ok(!qu.live && !qu.managed_streams.some((m) => m.is_live), 'an offline streamer is not live');
+ console.log('recently-online live: all checks passed');
+ } finally { server.close(); }
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+// A streamer's AI overview is public text (home cards, the channel page's description): a model answer written as a
+// list ("- Streamer handle: x (@x).") is stored as one paragraph of prose with a card line that starts with words, and
+// rows stored as lists before that are repaired by the AI cleanup pass. The "nothing to say" sentinel still survives.
+const assert = require('assert');
+console.warn = () => {};
+
+const db = require('../server/db/database');
+(async () => {
+ await db.initDb();
+ const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; };
+ const fixer = await mk('finditfixit'), old = await mk('oldlist'), plain = await mk('plainann');
+
+ await db.upsertStreamerOverview(fixer, { overview: '## Overview\n- Streamer handle: finditfixit (@finditfixit).\n- **Content:** repairs old TVs and radios on stream\n- Tone: calm, patient' });
+ const a = await db.getStreamerOverview(fixer);
+ assert.strictEqual(a.overview, 'finditfixit (@finditfixit). repairs old TVs and radios on stream. calm, patient.');
+ assert.ok(!/^[-*#\s]/.test(a.overview_short) && !a.overview_short.includes('**'), a.overview_short);
+
+ const prose = 'plainann streams Rust and talks through every build. Tone: chill.\nThe chat is small and friendly.';
+ await db.upsertStreamerOverview(plain, { overview: prose });
+ assert.strictEqual((await db.getStreamerOverview(plain)).overview, prose, 'prose is stored as written');
+
+ // A row stored as a list before the prompt asked for prose.
+ await db.run("INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at) VALUES (?, ?, ?, 'x', '{}', ov_now())",
+ [old, '- Streamer handle: oldlist (@oldlist).\n- Streams Minecraft builds most nights.', '- Streamer handle: oldlist (@oldlist).']);
+ await db.cleanupMalformedAiText();
+ const b = await db.getStreamerOverview(old);
+ assert.strictEqual(b.overview, 'oldlist (@oldlist). Streams Minecraft builds most nights.');
+ assert.strictEqual(b.overview_short, 'oldlist (@oldlist). Streams Minecraft builds most nights.');
+ assert.strictEqual((await db.getStreamerOverview(plain)).overview, prose, 'the repair leaves prose alone');
+
+ await db.upsertStreamerOverview(plain, { overview: ' ' });
+ assert.strictEqual((await db.getStreamerOverview(plain)).overview_short, ' ', 'the tried-and-empty sentinel round-trips');
+
+ console.log('streamer-overview-prose: all checks passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });