openvibers/openvibe.live Commits

Home: 'Lately on OpenVibe' digest for everyone + AI-picked Star of OpenVibe that rotates daily

OP OpenVibers committed 13 Sept 2026 · a03a378f21 · 7 files, +275 −50

- /api/home/digest is optionalAuth: site-wide live/streamed channels (followed first when signed in), activity numbers, hottest Arena mic lines; window 6h–14d
- digest box restyled (balanced padding, header row with since-label, stat strip, hot-mic footer)
- server/home/star-job.js: daily AI pick from streamers active in the last 14d with real stats, recent stars excluded (score fallback with AI off); star_streamer_pinned = manual override; POST /api/home/star/rotate (admin)
- star card shows the pick's headline/reason, who picked, and the countdown to the next star

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

Changes

ddiff --git a/public/css/i18n-star.css b/public/css/i18n-star.css
iindex ac6938a0..d5276633 100644
---- a/public/css/i18n-star.css
++++ b/public/css/i18n-star.css
@@@ -218,3 +218,10 @@
.rs-lite .star-btn-watch::after, .rs-lite .star-kicker i, .rs-lite .star-live-pill { animation: none; }
.rs-lite .star-btn-watch { box-shadow: none; }
.rs-lite .ch-captions-dot, .rs-lite .chat-translate-hint { animation: none; box-shadow: none; }
++
++/* Why they're today's star (AI pick) */
++.star-why { margin: 10px 0 12px; padding: 12px 14px; border-radius: 12px; background: linear-gradient(135deg, rgba(251,191,36,0.12), rgba(244,114,182,0.08)); border: 1px solid rgba(251,191,36,0.35); }
++.star-why-head { font-size: 1.05rem; font-weight: 900; color: #fff; margin-bottom: 4px; }
++.star-why-body { font-size: 0.92rem; line-height: 1.45; color: var(--text-secondary); }
++.star-why-meta { margin-top: 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #fbbf24; }
++.star-why-meta i { margin-right: 4px; }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex c1bdc5ee..611795c0 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -2266,26 +2266,39 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.moment-title { font-size: 0.78rem; font-weight: 700; color: #fff; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.moment-user { font-size: 0.68rem; color: rgba(255, 255, 255, 0.75); margin-top: 2px; }
--/* ── "While you were away" digest ── */
++/* ── "While you were away" / "Lately on OpenVibe" digest ── */
.home-digest {
-- margin-bottom: 22px; padding: 13px 16px; border-radius: 14px;
-- background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--bg-secondary)), var(--bg-secondary));
-- border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
--}
--.digest-head { font-size: 0.8rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 9px; }
--.digest-head i { color: var(--accent); margin-right: 5px; }
++ margin: 20px 0 26px; padding: 16px 18px 15px; border-radius: 16px;
++ background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 12%, var(--bg-secondary)), var(--bg-secondary) 70%);
++ border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
++ box-shadow: 0 10px 30px rgba(0,0,0,0.18);
++}
++.digest-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
++.digest-title { font-size: 0.82rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-primary); }
++.digest-title i { color: var(--accent); margin-right: 6px; }
++.digest-since { font-size: 0.76rem; font-weight: 600; color: var(--text-muted); }
++.digest-stats { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0 0 12px; font-size: 0.84rem; color: var(--text-secondary); }
++.digest-stat i { color: var(--accent); margin-right: 3px; font-size: 0.78rem; }
++.digest-stat b { color: var(--text-primary); font-weight: 800; }
.digest-row { display: flex; flex-wrap: wrap; gap: 8px; }
.digest-chip {
-- display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 7px; border-radius: 999px;
++ position: relative; display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 7px; border-radius: 999px;
background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary);
-- font-size: 0.84rem; transition: border-color 0.15s ease;
++ font-size: 0.84rem; transition: border-color 0.15s ease, transform 0.15s ease;
}
--.digest-chip:hover { border-color: var(--accent); }
--.digest-chip--live { border-color: color-mix(in srgb, #e0245e 55%, var(--border)); }
++.digest-chip:hover { border-color: var(--accent); transform: translateY(-1px); }
++.digest-chip--live { border-color: color-mix(in srgb, #e0245e 55%, var(--border)); background: color-mix(in srgb, #e0245e 10%, var(--bg-tertiary)); }
++.digest-chip--followed { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
.digest-chip .stream-card-avatar { width: 24px; height: 24px; font-size: 0.7rem; }
.digest-name { font-weight: 700; }
--.digest-extra { color: var(--text-muted); font-size: 0.76rem; }
++.digest-extra { color: var(--text-muted); font-size: 0.76rem; white-space: nowrap; }
.digest-extra .live-dot { font-size: 0.5rem; }
++.digest-follow { color: var(--accent); font-size: 0.7rem; margin-left: -2px; }
++.digest-hot { display: grid; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--border)); }
++.digest-hot-line { font-size: 0.84rem; color: var(--text-secondary); font-style: italic; line-height: 1.4; }
++.digest-hot-line i { color: #f472b6; margin-right: 4px; font-style: normal; }
++.digest-hot-line b { font-style: normal; color: var(--text-primary); font-weight: 700; }
++@media (max-width: 640px) { .home-digest { padding: 14px 14px 12px; margin: 14px 0 20px; } .digest-extra { white-space: normal; } }
/* Recently Online cards: nearest-goal progress */
.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
ddiff --git a/public/index.html b/public/index.html
iindex 62d54490..0e3ec919 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=157">
++ <link rel="stylesheet" href="/css/style.css?v=158">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@@ -70,7 +70,7 @@
<link rel="stylesheet" href="/css/home-fx.css?v=4">
<link rel="stylesheet" href="/css/home-tour.css?v=1">
<link rel="stylesheet" href="/css/broadcast-guide.css?v=1">
-- <link rel="stylesheet" href="/css/i18n-star.css?v=3">
++ <link rel="stylesheet" href="/css/i18n-star.css?v=4">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- JSON-LD Structured Data -->
@@@ -268,7 +268,7 @@
</div>
<div class="container">
-- <!-- "While you were away" — returning users' followed-channel digest -->
++ <!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->
<div class="home-digest" id="home-digest" style="display:none"></div>
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
@@@ -5046,7 +5046,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
--<script src="/js/app.js?v=135" defer></script>
++<script src="/js/app.js?v=136" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex b6ee4b9e..172a1869 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1778,7 +1778,7 @@ async function loadHome() {
loadHomeRecentOnline();
void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update
-- void loadHomeDigest(); // "while you were away" for returning logged-in users
++ void loadHomeDigest(); // "while you were away" / "lately on OpenVibe" — everyone
void loadHomeStar(); // "Star of OpenVibe" spotlight (star_streamer setting)
// Load recent VODs
@@@ -1866,26 +1866,45 @@ async function loadHomePulse() {
/* ── "While you were away" digest (logged-in returning users) ── */
async function loadHomeDigest() {
const box = document.getElementById('home-digest');
-- if (!box || !currentUser) return;
++ if (!box) return;
const KEY = 'openvibe_last_visit';
let since = null;
try { since = localStorage.getItem(KEY); } catch { /* */ }
try { localStorage.setItem(KEY, new Date().toISOString()); } catch { /* */ }
-- // First visit (nothing to compare against) → no banner, just start the clock.
-- if (!since) return;
let d;
-- try { d = await api(`/home/digest?since=${encodeURIComponent(since)}`); } catch { return; }
-- if (!d || (!d.liveNow?.length && !d.missed?.length)) return;
-- const chip = (u, extra, live) => `
-- <a class="digest-chip ${live ? 'digest-chip--live' : ''}" href="/@${esc(u.username)}" onclick="return handleLinkClick(event, '/@${esc(u.username)}')">
++ try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); } catch { return; }
++ if (!d || (!d.liveNow?.length && !d.streamed?.length && !d.hot?.length)) { box.style.display = 'none'; return; }
++ const away = !!(since && currentUser);
++ const n = (v) => Number(v || 0).toLocaleString();
++ const chip = (u, extra, mods) => `
++ <a class="digest-chip ${mods}" href="/@${esc(u.username)}" onclick="return handleLinkClick(event, '/@${esc(u.username)}')" title="${esc(u.last_title || u.title || '')}">
${_avatarSpan(u.avatar_url, u.username, u.profile_color)}
<span class="digest-name">${esc(u.display_name || u.username)}</span>
<span class="digest-extra">${extra}</span>
++ ${u.followed ? '<span class="digest-follow" title="You follow them"><i class="fa-solid fa-heart"></i></span>' : ''}
</a>`;
const parts = [];
-- for (const u of (d.liveNow || [])) parts.push(chip(u, '<i class="fa-solid fa-circle live-dot"></i> LIVE now', true));
-- for (const u of (d.missed || [])) parts.push(chip(u, `streamed ${u.sessions > 1 ? u.sessions + '× ' : ''}${esc(timeAgo(u.last_at))}`, false));
-- box.innerHTML = `<div class="digest-head"><i class="fa-solid fa-clock-rotate-left"></i> While you were away</div><div class="digest-row">${parts.join('')}</div>`;
++ for (const u of (d.liveNow || [])) parts.push(chip(u, `<i class="fa-solid fa-circle live-dot"></i> LIVE${u.viewer_count ? ` · ${n(u.viewer_count)} watching` : ' now'}`, `digest-chip--live${u.followed ? ' digest-chip--followed' : ''}`));
++ for (const u of (d.streamed || [])) parts.push(chip(u, `${u.sessions > 1 ? `${u.sessions}× · ` : ''}${u.hours >= 0.1 ? `${u.hours}h · ` : ''}${esc(timeAgo(u.last_at))}${u.peak_viewers > 1 ? ` · peak ${n(u.peak_viewers)}` : ''}`, u.followed ? 'digest-chip--followed' : ''));
++ const st = d.stats || {};
++ const stats = [
++ st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',
++ st.hours >= 0.5 ? `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(Math.round(st.hours))}</b>h live</span>` : '',
++ st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat lines</span>` : '',
++ st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
++ st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
++ st.mic_moments ? `<span class="digest-stat"><i class="fa-solid fa-microphone-lines"></i> <b>${n(st.mic_moments)}</b> Arena mic moment${st.mic_moments === 1 ? '' : 's'}</span>` : '',
++ ].filter(Boolean).join('');
++ const hot = (d.hot || []).map(h => `<div class="digest-hot-line"><i class="fa-solid fa-microphone-lines"></i> “${esc(String(h.text || '').slice(0, 140))}” <b>— ${esc(h.display_name || h.username)}</b>${h.aimed_at ? ` <span class="muted">at ${esc(h.aimed_at)}</span>` : ''}</div>`).join('');
++ const sinceLabel = d.since ? (away ? `since your last visit · ${esc(timeAgo(d.since))}` : `last ${Math.max(1, Math.round((Date.now() - Date.parse(d.since)) / 3600000))}h`) : '';
++ box.innerHTML = `
++ <div class="digest-head">
++ <span class="digest-title"><i class="fa-solid ${away ? 'fa-clock-rotate-left' : 'fa-bolt'}"></i> ${away ? 'While you were away' : 'Lately on OpenVibe'}</span>
++ <span class="digest-since">${sinceLabel}</span>
++ </div>
++ ${stats ? `<div class="digest-stats">${stats}</div>` : ''}
++ ${parts.length ? `<div class="digest-row">${parts.join('')}</div>` : ''}
++ ${hot ? `<div class="digest-hot">${hot}</div>` : ''}`;
box.style.display = '';
}
@@@ -8935,6 +8954,12 @@ function copyDocSection() {
/* ── Star of OpenVibe — home spotlight for the featured streamer ─────────────────────
GET /api/home/star → { star: { username, display_name, avatar_url, bio, bio_en, language,
live, ai_overview, follower_count, last_live_at } } (null when no star is configured). */
++function _starCountdown(ts) {
++ const ms = Number(ts) - Date.now();
++ if (!(ms > 0)) return 'soon';
++ const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000);
++ return h >= 1 ? `in ${h}h${m ? ` ${m}m` : ''}` : `in ${Math.max(1, m)}m`;
++}
async function loadHomeStar() {
const sec = document.getElementById('home-star-section');
if (!sec) return;
@@@ -8960,6 +8985,10 @@ async function loadHomeStar() {
bioHtml = `<p class="star-bio">${_linkify(esc(bioSrc))}</p>`;
}
const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';
++ const pick = s.pick || null;
++ const why = pick && (pick.headline || pick.reason)
++ ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><i class="fa-solid fa-wand-magic-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}${pick.next_at ? ` · next star ${esc(_starCountdown(pick.next_at))}` : ''}</div></div>`
++ : '';
const chips = [
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
foreign ? `<span class="star-chip"><i class="fa-solid fa-language"></i> Chat auto-translated both ways</span>` : '',
@@@ -8972,7 +9001,7 @@ async function loadHomeStar() {
sec.innerHTML = `
<div class="section-header">
<h2><i class="fa-solid fa-star" style="color:#fbbf24"></i> Star of OpenVibe</h2>
-- <span class="muted" style="font-size:0.8rem">the streamer we're rolling out the red carpet for</span>
++ <span class="muted" style="font-size:0.8rem">${s.rotates ? 'a new star every day — picked by the site\'s AI from who actually showed up' : 'the streamer we\'re rolling out the red carpet for'}</span>
</div>
<div class="star-card-border">
<div class="star-card${isLive ? ' is-live' : ''}">
@@@ -8985,9 +9014,10 @@ async function loadHomeStar() {
<span class="star-badge">⭐ Star</span>
</div>
<div class="star-body">
-- <div class="star-kicker"><i class="fa-solid fa-star"></i> Featured streamer</div>
++ <div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>
<h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
<div class="star-chips">${chips}</div>
++ ${why}
${bioHtml}
${overview}
${offline}
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 707fb8e1..040f3348 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -244,6 +244,14 @@ router.get('/star', async (req, res) => {
ai_overview: channel.ai_overview || null, category: channel.ai_category || channel.category || null,
follower_count, live: liveSafe,
last_live_at: live ? null : (last_stream && (last_stream.ended_at || last_stream.started_at)) || null,
++ pick: (() => {
++ try {
++ const p = require('./star-job').loadPick();
++ if (p && String(p.username || '').toLowerCase() === user.username.toLowerCase()) return { headline: p.headline || null, reason: p.reason || null, picked_at: p.picked_at || null, next_at: p.next_at || null, by: p.by || null };
++ } catch { /* */ }
++ return null;
++ })(),
++ rotates: !String(db.getSetting('star_streamer_pinned') || '').trim(),
},
};
_starCache = { at: Date.now(), key: uname.toLowerCase(), data };
@@@ -286,34 +294,66 @@ function _latestUpdate() {
return _updateCache.data;
}
--// ── "While you were away" digest for returning users ─────────────────────────
--// ?since=<ISO> (the client remembers its own last visit). Followed channels that
--// are live right now + those that streamed since the last visit.
--const { requireAuth } = require('../auth/auth');
--router.get('/digest', requireAuth, (req, res) => {
++// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────
++// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).
++// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in
++// users), the numbers, and the hottest Arena mic lines.
++const { requireAuth, optionalAuth } = require('../auth/auth');
++router.get('/digest', optionalAuth, (req, res) => {
try {
-- const since = req.query.since && !Number.isNaN(Date.parse(req.query.since))
-- ? new Date(req.query.since).toISOString()
-- : new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString();
-- const liveNow = db.all(`SELECT DISTINCT u.username, u.display_name, u.avatar_url
-- FROM follows f JOIN users u ON u.id = f.streamer_id
-- JOIN streams s ON s.user_id = f.streamer_id AND s.is_live = 1
-- WHERE f.follower_id = ?`, [req.user.id]) || [];
-- const missed = db.all(`SELECT u.username, u.display_name, u.avatar_url,
-- COUNT(s.id) AS sessions, MAX(s.started_at) AS last_at, MAX(s.title) AS last_title
-- FROM follows f JOIN users u ON u.id = f.streamer_id
-- JOIN streams s ON s.user_id = f.streamer_id AND s.started_at >= ? AND s.is_live = 0
-- WHERE f.follower_id = ?
-- GROUP BY f.streamer_id ORDER BY last_at DESC LIMIT 6`, [since, req.user.id]) || [];
-- // Don't list a channel as "missed" when it's live right now — it's in liveNow.
-- const liveSet = new Set(liveNow.map(x => x.username));
-- res.json({ since, liveNow, missed: missed.filter(m => !liveSet.has(m.username)) });
++ const now = Date.now();
++ let sinceMs = req.query.since && !Number.isNaN(Date.parse(req.query.since)) ? Date.parse(req.query.since) : now - 48 * 3600 * 1000;
++ sinceMs = Math.max(now - 14 * 24 * 3600 * 1000, Math.min(sinceMs, now - 6 * 3600 * 1000)); // 6h … 14d window
++ const since = new Date(sinceMs).toISOString();
++ const sinceSql = since.replace('T', ' ').slice(0, 19);
++ const uid = req.user ? req.user.id : null;
++ const followed = new Set(uid ? (db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [uid]) || []).map(r => r.streamer_id) : []);
++ const liveNow = (db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_at
++ FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0
++ ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10`) || []).map(r => ({ ...r, followed: followed.has(r.user_id) }));
++ const liveIds = new Set(liveNow.map(r => r.user_id));
++ const streamed = (db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color,
++ COUNT(s.id) AS sessions, MAX(s.started_at) AS last_at, MAX(COALESCE(s.peak_viewers, 0)) AS peak_viewers,
++ ROUND(SUM(COALESCE(s.duration_seconds, CASE WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at) - julianday(s.started_at)) * 86400 ELSE 0 END)) / 3600.0, 1) AS hours,
++ (SELECT title FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_title
++ FROM streams s JOIN users u ON u.id = s.user_id
++ WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0
++ GROUP BY u.id ORDER BY hours DESC, last_at DESC LIMIT 14`, [sinceSql, sinceSql]) || [])
++ .filter(r => !liveIds.has(r.user_id)).map(r => ({ ...r, followed: followed.has(r.user_id) }))
++ .sort((a, b) => (b.followed - a.followed) || (b.hours - a.hours)).slice(0, 8);
++ const one = (sql, params) => { try { return get(sql, params); } catch { return null; } };
++ const get = (sql, params) => db.get(sql, params);
++ const stats = {
++ streams: Number((one('SELECT COUNT(*) AS n FROM streams WHERE started_at >= ?', [sinceSql]) || {}).n || 0),
++ hours: Number((one(`SELECT ROUND(SUM(COALESCE(duration_seconds, CASE WHEN ended_at IS NOT NULL THEN (julianday(ended_at) - julianday(started_at)) * 86400 ELSE (julianday('now') - julianday(started_at)) * 86400 END)) / 3600.0, 1) AS h FROM streams WHERE started_at >= ?`, [sinceSql]) || {}).h || 0),
++ chat_lines: Number((one("SELECT COUNT(*) AS n FROM chat_messages WHERE timestamp >= ? AND COALESCE(is_deleted, 0) = 0", [sinceSql]) || {}).n || 0),
++ new_follows: Number((one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
++ new_members: Number((one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
++ mic_moments: Number((one('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE said_at >= ?', [sinceSql]) || {}).n || 0),
++ };
++ let hot = [];
++ try {
++ hot = (db.all(`SELECT m.text, m.quality, m.said_at, m.aimed_at, u.username, u.display_name FROM arena_mic_moments m JOIN users u ON u.id = m.user_id
++ WHERE m.said_at >= ? AND m.quality >= 7 ORDER BY m.quality DESC, m.said_at DESC LIMIT 3`, [sinceSql]) || []);
++ } catch { hot = []; }
++ res.set('Cache-Control', uid ? 'private, max-age=30' : 'public, max-age=60');
++ res.json({ since, signed_in: !!uid, liveNow, streamed, stats, hot });
} catch (err) {
console.error('[Home] digest error:', err.message);
res.status(500).json({ error: 'Failed to load digest' });
}
});
++// Admin: pick a new Star of OpenVibe right now (the job otherwise rotates daily).
++router.post('/star/rotate', requireAuth, async (req, res) => {
++ if (!req.user || req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
++ try {
++ const out = await require('./star-job').rotate({ force: true });
++ _starCache = { at: 0, key: '', data: null };
++ res.json(out);
++ } catch (err) { res.status(500).json({ error: err.message }); }
++});
++
// Sample the live viewer total every 5 minutes for the hero sparkline.
let _samplerTimer = null;
function startViewerSampler() {
ddiff --git a/server/home/star-job.js b/server/home/star-job.js
nnew file mode 100644
iindex 00000000..d1c303f7
---- /dev/null
++++ b/server/home/star-job.js
@@@ -0,0 +1,134 @@
++/**
++ * star-job.js — "Star of OpenVibe" picked by the site's AI, rotating daily.
++ *
++ * Candidates are streamers who went live in the last CANDIDATE_DAYS days (not banned). Each one
++ * gets real stats — sessions, hours, peak/avg viewers, chat lines in their streams, new followers,
++ * Arena mic moments, language, what they stream (AI overview) — and the shared LLM picks who gets
++ * the red carpet today and writes a one-line reason. Recent stars are excluded so it rotates
++ * instead of parking on one person. With AI off (or a bad answer) a plain score picks instead.
++ *
++ * State: site_state `star_pick` = { username, reason, headline, picked_at, next_at, by, history[] }
++ * site_setting `star_streamer` = the current username (what /api/home/star reads)
++ * site_setting `star_streamer_pinned` = a manual override: set → the job leaves it alone.
++ */
++'use strict';
++const db = require('../db/database');
++
++const ROTATE_MS = 24 * 60 * 60 * 1000; // a new star every day
++const CHECK_MS = 30 * 60 * 1000; // how often the job looks at the clock
++const CANDIDATE_DAYS = 14;
++const HISTORY_KEEP = 4; // last N stars are ineligible
++const STATE_KEY = 'star_pick';
++
++let _timer = null, _busy = false;
++
++function loadPick() {
++ try { const s = db.getState(STATE_KEY); const o = typeof s === 'string' ? JSON.parse(s) : s; if (o && typeof o === 'object') return o; } catch { /* */ }
++ return null;
++}
++function pinned() { try { return String(db.getSetting('star_streamer_pinned') || '').trim(); } catch { return ''; } }
++
++/** Everyone who streamed recently, with the numbers the picker looks at. */
++function candidates() {
++ const rows = db.all(`
++ SELECT u.id, u.username, u.display_name, u.bio,
++ COUNT(s.id) AS sessions,
++ ROUND(SUM(COALESCE(s.duration_seconds, CASE WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at) - julianday(s.started_at)) * 86400 ELSE 0 END)) / 3600.0, 1) AS hours,
++ MAX(COALESCE(s.peak_viewers, 0)) AS peak_viewers,
++ ROUND(AVG(COALESCE(s.peak_viewers, 0)), 1) AS avg_peak,
++ MAX(s.started_at) AS last_live_at,
++ (SELECT COUNT(*) FROM chat_messages c WHERE c.stream_id IN (SELECT id FROM streams x WHERE x.user_id = u.id AND x.started_at >= datetime('now', ?)) AND COALESCE(c.is_deleted, 0) = 0) AS chat_lines,
++ (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id AND f.created_at >= datetime('now', ?)) AS new_followers,
++ (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers,
++ (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.user_id = u.id AND m.said_at >= datetime('now', ?)) AS mic_moments,
++ (SELECT COALESCE(so.overview_short, so.overview) FROM streamer_overviews so WHERE so.user_id = u.id) AS overview,
++ (SELECT ch.ai_category FROM channels ch WHERE ch.user_id = u.id) AS category
++ FROM users u JOIN streams s ON s.user_id = u.id
++ WHERE s.started_at >= datetime('now', ?) AND COALESCE(u.is_banned, 0) = 0
++ GROUP BY u.id
++ ORDER BY hours DESC LIMIT 40`, [`-${CANDIDATE_DAYS} days`, `-${CANDIDATE_DAYS} days`, `-${CANDIDATE_DAYS} days`, `-${CANDIDATE_DAYS} days`]) || [];
++ let i18n = null; try { i18n = require('../i18n/translate'); } catch { i18n = null; }
++ for (const r of rows) {
++ r.language = i18n ? (() => { try { return i18n.channelLanguage(r.id); } catch { return 'en'; } })() : 'en';
++ r.score = Number(r.hours || 0) * 1.0 + Number(r.peak_viewers || 0) * 2 + Number(r.chat_lines || 0) / 40 + Number(r.new_followers || 0) * 3 + Number(r.mic_moments || 0) * 1.5 + Math.min(Number(r.sessions || 0), 10) * 0.5;
++ }
++ return rows;
++}
++
++function scorePick(cands, exclude) {
++ const ok = cands.filter(c => !exclude.has(c.username.toLowerCase()));
++ const pool = ok.length ? ok : cands;
++ return pool.slice().sort((a, b) => b.score - a.score)[0] || null;
++}
++
++async function aiPick(cands, exclude, previous) {
++ let llm = null; try { llm = require('../ai/llm'); } catch { return null; }
++ if (!llm || !llm.isEnabled() || !llm.withinBudget()) return null;
++ const list = cands.filter(c => !exclude.has(c.username.toLowerCase())).slice(0, 25).map(c => ({
++ username: c.username, name: c.display_name || c.username, language: c.language, category: c.category || null,
++ last_14d: { sessions: c.sessions, hours: c.hours, peak_viewers: c.peak_viewers, avg_peak_viewers: c.avg_peak, chat_lines: c.chat_lines, new_followers: c.new_followers, arena_mic_moments: c.mic_moments },
++ followers: c.followers, last_live: c.last_live_at,
++ about: String(c.overview || c.bio || '').replace(/\s+/g, ' ').slice(0, 220) || null,
++ }));
++ if (!list.length) return null;
++ const schema = { type: 'object', properties: { username: { type: 'string' }, headline: { type: 'string' }, reason: { type: 'string' } }, required: ['username', 'headline', 'reason'] };
++ const system = `You pick today's "Star of OpenVibe" — the one streamer OpenVibe.Live (a scrappy, open-source, community-run live-streaming site) rolls out the red carpet for on its home page for the next 24 hours.
++Pick from the candidates ONLY (use the exact username). Spread the love: favour people who showed up and put in real hours, grew, got chat talking, or bring something different (a language, a niche, a robot, a vibe) — not just the biggest number. Small streamers who are consistent deserve their day. Never pick anyone in the "recent stars" list.
++Write a "headline" (≤ 60 chars, punchy, warm, no quotes) and a "reason" (≤ 170 chars, one sentence, second person is fine, reference their real numbers or what they do — no emojis, no hashtags, no sarcasm, never mock). Output only JSON.`;
++ const user = JSON.stringify({ recent_stars: Array.from(exclude), previous_star: previous || null, candidates: list });
++ let r = null;
++ try { r = await llm.complete({ role: 'summary', kind: 'home_star', source: 'home', system, user, json: schema, maxTokens: 300, temperature: 0.9, timeoutMs: 30000 }); } catch (e) { console.warn('[Star] model call failed:', e.message); return null; }
++ let out = null;
++ if (r && typeof r === 'object') out = r.json || r.parsed || (r.text ? llm.parseJsonLoose(r.text) : null) || (r.content ? llm.parseJsonLoose(r.content) : null);
++ else if (typeof r === 'string') out = llm.parseJsonLoose(r);
++ if (!out || !out.username) return null;
++ const c = cands.find(x => x.username.toLowerCase() === String(out.username).toLowerCase());
++ if (!c || exclude.has(c.username.toLowerCase())) return null;
++ return { cand: c, headline: String(out.headline || '').trim().slice(0, 60), reason: String(out.reason || '').trim().slice(0, 170) };
++}
++
++/** Pick a new star now (force) or when the current one has had its day. */
++async function rotate({ force = false } = {}) {
++ if (_busy) return { busy: true };
++ if (pinned()) return { pinned: pinned() };
++ const cur = loadPick();
++ const now = Date.now();
++ if (!force && cur && cur.next_at && now < Number(cur.next_at)) return { current: cur.username, next_at: cur.next_at };
++ _busy = true;
++ try {
++ const cands = candidates();
++ if (!cands.length) return { none: true };
++ // Recent stars (and whatever the setting / env currently holds) are ineligible.
++ const history = Array.isArray(cur && cur.history) ? cur.history.slice() : [];
++ let currentName = ''; try { currentName = String(db.getSetting('star_streamer') || require('../config').starStreamer || '').trim(); } catch { /* */ }
++ if (currentName && !history.includes(currentName)) history.push(currentName);
++ const exclude = new Set(history.slice(-HISTORY_KEEP).map(s => String(s).toLowerCase()));
++ let by = 'ai';
++ let pick = await aiPick(cands, exclude, currentName || null);
++ if (!pick) {
++ const c = scorePick(cands, exclude);
++ if (!c) return { none: true };
++ by = 'score';
++ pick = { cand: c, headline: `${c.display_name || c.username} is today's star`, reason: `${c.hours || 0}h live across ${c.sessions} stream${c.sessions === 1 ? '' : 's'} in the last two weeks${c.peak_viewers ? `, peaking at ${c.peak_viewers} viewers` : ''}.` };
++ }
++ const nextHistory = history.filter(h => h.toLowerCase() !== pick.cand.username.toLowerCase()).concat(pick.cand.username).slice(-12);
++ const state = { username: pick.cand.username, headline: pick.headline, reason: pick.reason, picked_at: now, next_at: now + ROTATE_MS, by, history: nextHistory };
++ db.setState(STATE_KEY, JSON.stringify(state));
++ db.setSetting('star_streamer', pick.cand.username);
++ console.log(`[Star] ${pick.cand.username} is the Star of OpenVibe for the next 24h (${by})${pick.headline ? ` — ${pick.headline}` : ''}`);
++ return { picked: pick.cand.username, by };
++ } catch (e) {
++ console.warn('[Star] rotate failed:', e.message);
++ return { error: e.message };
++ } finally { _busy = false; }
++}
++
++function start() {
++ if (_timer) return;
++ setTimeout(() => { rotate().catch(() => {}); }, 60 * 1000);
++ _timer = setInterval(() => { rotate().catch(() => {}); }, CHECK_MS);
++ if (_timer.unref) _timer.unref();
++ console.log('[Star] star-of-OpenVibe picker started (rotates daily, AI-picked)');
++}
++
++module.exports = { start, rotate, loadPick, candidates, ROTATE_MS, STATE_KEY };
ddiff --git a/server/index.js b/server/index.js
iindex 280ed982..fcd7d431 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1161,6 +1161,7 @@ async function start() {
try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
// 5-minute live-viewer samples → the home hero's 24h sparkline.
try { require('./home/routes').startViewerSampler(); } catch (e) { console.warn('[Home] viewer sampler not started:', e.message); }
++ try { require('./home/star-job').start(); } catch (e) { console.warn('[Home] star picker not started:', e.message); }
// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.
try {
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };