openvibers/openvibe.live Commits
Home: 'Lately on OpenVibe' digest for everyone + AI-picked Star of OpenVibe that rotates daily
Changes
@@@ -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; }@@@ -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; }@@@ -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>@@@ -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" — everyonevoid 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}@@@ -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() {nnew file mode 100644@@@ -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 };@@@ -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 { /* */ } };
@@ -218,3 +218,10 @@
@@ -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::after, .rs-lite .star-kicker i, .rs-lite .star-live-pill { animation: none; }
.rs-lite .star-btn-watch { box-shadow: none; }
.rs-lite .star-btn-watch { box-shadow: none; }
.rs-lite .ch-captions-dot, .rs-lite .chat-translate-hint { animation: none; 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; }
@@ -2266,26 +2266,39 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -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-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; }
.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 {
.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-row { display: flex; flex-wrap: wrap; gap: 8px; }
.digest-chip {
.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);
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-chip .stream-card-avatar { width: 24px; height: 24px; font-size: 0.7rem; }
.digest-name { font-weight: 700; }
.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-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 */
/* Recently Online cards: nearest-goal progress */
.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- 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/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -70,7 +70,7 @@
@@ -70,7 +70,7 @@
<link rel="stylesheet" href="/css/home-fx.css?v=4">
<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/home-tour.css?v=1">
<link rel="stylesheet" href="/css/broadcast-guide.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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- JSON-LD Structured Data -->
<!-- JSON-LD Structured Data -->
@@ -268,7 +268,7 @@
@@ -268,7 +268,7 @@
</div>
</div>
<div class="container">
<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>
<div class="home-digest" id="home-digest" style="display:none"></div>
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
<!-- 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
@@ -5046,7 +5046,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></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-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -1778,7 +1778,7 @@ async function loadHome() {
@@ -1778,7 +1778,7 @@ async function loadHome() {
loadHomeRecentOnline();
loadHomeRecentOnline();
void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update
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)
void loadHomeStar(); // "Star of OpenVibe" spotlight (star_streamer setting)
// Load recent VODs
// Load recent VODs
@@ -1866,26 +1866,45 @@ async function loadHomePulse() {
@@ -1866,26 +1866,45 @@ async function loadHomePulse() {
/* ── "While you were away" digest (logged-in returning users) ── */
/* ── "While you were away" digest (logged-in returning users) ── */
async function loadHomeDigest() {
async function loadHomeDigest() {
const box = document.getElementById('home-digest');
const box = document.getElementById('home-digest');
- if (!box || !currentUser) return;
+ if (!box) return;
const KEY = 'openvibe_last_visit';
const KEY = 'openvibe_last_visit';
let since = null;
let since = null;
try { since = localStorage.getItem(KEY); } catch { /* */ }
try { since = localStorage.getItem(KEY); } catch { /* */ }
try { localStorage.setItem(KEY, new Date().toISOString()); } 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;
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)}
${_avatarSpan(u.avatar_url, u.username, u.profile_color)}
<span class="digest-name">${esc(u.display_name || u.username)}</span>
<span class="digest-name">${esc(u.display_name || u.username)}</span>
<span class="digest-extra">${extra}</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>`;
</a>`;
const parts = [];
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 = '';
box.style.display = '';
}
}
@@ -8935,6 +8954,12 @@ function copyDocSection() {
@@ -8935,6 +8954,12 @@ function copyDocSection() {
/* ── Star of OpenVibe — home spotlight for the featured streamer ─────────────────────
/* ── Star of OpenVibe — home spotlight for the featured streamer ─────────────────────
GET /api/home/star → { star: { username, display_name, avatar_url, bio, bio_en, language,
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). */
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() {
async function loadHomeStar() {
const sec = document.getElementById('home-star-section');
const sec = document.getElementById('home-star-section');
if (!sec) return;
if (!sec) return;
@@ -8960,6 +8985,10 @@ async function loadHomeStar() {
@@ -8960,6 +8985,10 @@ async function loadHomeStar() {
bioHtml = `<p class="star-bio">${_linkify(esc(bioSrc))}</p>`;
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 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 = [
const chips = [
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
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>` : '',
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() {
@@ -8972,7 +9001,7 @@ async function loadHomeStar() {
sec.innerHTML = `
sec.innerHTML = `
<div class="section-header">
<div class="section-header">
<h2><i class="fa-solid fa-star" style="color:#fbbf24"></i> Star of OpenVibe</h2>
<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>
<div class="star-card-border">
<div class="star-card-border">
<div class="star-card${isLive ? ' is-live' : ''}">
<div class="star-card${isLive ? ' is-live' : ''}">
@@ -8985,9 +9014,10 @@ async function loadHomeStar() {
@@ -8985,9 +9014,10 @@ async function loadHomeStar() {
<span class="star-badge">⭐ Star</span>
<span class="star-badge">⭐ Star</span>
</div>
</div>
<div class="star-body">
<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>
<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>
<div class="star-chips">${chips}</div>
+ ${why}
${bioHtml}
${bioHtml}
${overview}
${overview}
${offline}
${offline}
@@ -244,6 +244,14 @@ router.get('/star', async (req, res) => {
@@ -244,6 +244,14 @@ router.get('/star', async (req, res) => {
ai_overview: channel.ai_overview || null, category: channel.ai_category || channel.category || null,
ai_overview: channel.ai_overview || null, category: channel.ai_category || channel.category || null,
follower_count, live: liveSafe,
follower_count, live: liveSafe,
last_live_at: live ? null : (last_stream && (last_stream.ended_at || last_stream.started_at)) || null,
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 };
_starCache = { at: Date.now(), key: uname.toLowerCase(), data };
@@ -286,34 +294,66 @@ function _latestUpdate() {
@@ -286,34 +294,66 @@ function _latestUpdate() {
return _updateCache.data;
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 {
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) {
} catch (err) {
console.error('[Home] digest error:', err.message);
console.error('[Home] digest error:', err.message);
res.status(500).json({ error: 'Failed to load digest' });
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.
// Sample the live viewer total every 5 minutes for the hero sparkline.
let _samplerTimer = null;
let _samplerTimer = null;
function startViewerSampler() {
function startViewerSampler() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -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 };
@@ -1161,6 +1161,7 @@ async function start() {
@@ -1161,6 +1161,7 @@ async function start() {
try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
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.
// 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/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.
// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.
try {
try {
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };