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)

OP OpenVibers committed 28 days ago · a4fb484bca · 6 files, +163 −8

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 758233aa..66bc269e 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -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 {
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 155fe9c9..74828803 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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)}
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 3c609f2e..cab7fae8 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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, sources
ON CONFLICT(user_id) DO UPDATE SET
overview = 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]);
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 20e702f2..5e4eadfc 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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));
ddiff --git a/test/recently-online-live.test.js b/test/recently-online-live.test.js
nnew file mode 100644
iindex 00000000..118eed5c
---- /dev/null
++++ b/test/recently-online-live.test.js
@@@ -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); });
ddiff --git a/test/streamer-overview-prose.test.js b/test/streamer-overview-prose.test.js
nnew file mode 100644
iindex 00000000..ded9387a
---- /dev/null
++++ b/test/streamer-overview-prose.test.js
@@@ -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); });