openvibers/openvibe.live Commits

After-show reports: /recap/:streamId — AI-graded stream report with viewer curve, chat leaders, mic moments, clips, VOD, share; auto-built + announced in chat after every stream ≥ 8 min

OP OpenVibers committed 13 Sept 2026 · c01a105967 · 12 files, +583 −6

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

Changes

ddiff --git a/docs/recap.md b/docs/recap.md
nnew file mode 100644
iindex 00000000..b24719eb
---- /dev/null
++++ b/docs/recap.md
@@@ -0,0 +1,19 @@
++# After-show reports (`/recap/:streamId`)
++
++Every stream that ran at least 8 minutes gets a report a couple of minutes after it ends
++(`server/recap/recap.js`, job every 2 min, streams that ended in the last 12 h):
++
++- **Grade + headline + summary + moment + tags** — written by the shared LLM (`role: summary`,
++ kind `stream_recap`) from the real numbers; template copy when AI is off or the call fails.
++- **Numbers** — peak/avg viewers (from `viewer_snapshots`), chat lines and unique chatters, top 5
++ chatters (the host excluded), busiest 5-minute window, sound commands, new follows during the
++ stream, tips (`transactions` type donation), Arena mic moments, clips + the VOD (from Media).
++- **The room, minute by minute** — viewer curve with chat bars, peak marker.
++- Announced in the channel's chat room when it lands. Stored in `stream_recaps` (one JSON row per
++ stream). Owner/admin can rebuild it (`POST /api/recap/:id/regenerate`).
++- Crawlable: `/recap/:id` gets OG/Twitter meta + Article JSON-LD (`server/seo/seo.js`), so a
++ shared link previews with the headline and the VOD thumbnail.
++
++API: `GET /api/recap/:streamId` (builds on first view if missing), `GET /api/recap/channel/:username`.
++Client: `public/js/recap.js`, `public/css/recap.css`. The home digest's "streamed" chips link to
++the latest report with a 📋 button. Test: `node test/recap.test.js`.
ddiff --git a/public/css/recap.css b/public/css/recap.css
nnew file mode 100644
iindex 00000000..dfb0be6f
---- /dev/null
++++ b/public/css/recap.css
@@@ -0,0 +1,78 @@
++/* ── After-show report (/recap/:id) — public/js/recap.js ── */
++.recap-container { max-width: 980px; }
++.recap { --g: #a78bfa; padding: 22px 0 30px; }
++.recap-head { display: flex; gap: 22px; align-items: flex-start; margin-bottom: 18px; }
++.recap-grade { flex: 0 0 auto; width: 104px; height: 104px; border-radius: 26px; display: grid; place-items: center; align-content: center; gap: 2px;
++ background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--g) 35%, transparent), transparent 60%), var(--bg-secondary);
++ border: 2px solid color-mix(in srgb, var(--g) 70%, transparent); box-shadow: 0 0 40px color-mix(in srgb, var(--g) 35%, transparent), inset 0 0 30px color-mix(in srgb, var(--g) 12%, transparent); }
++.recap-grade span { font-size: 3rem; font-weight: 900; line-height: 1; color: var(--g); text-shadow: 0 0 24px color-mix(in srgb, var(--g) 70%, transparent); }
++.recap-grade small { font-size: 0.62rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); font-weight: 800; }
++.recap-head-body { min-width: 0; flex: 1; }
++.recap-kicker { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--g); display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
++.recap-ai { color: var(--text-muted); letter-spacing: 0.04em; font-weight: 700; text-transform: none; font-size: 0.72rem; }
++.recap-headline { margin: 6px 0 10px; font-size: clamp(1.5rem, 4vw, 2.4rem); line-height: 1.12; font-weight: 900; letter-spacing: -0.01em; }
++.recap-by { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 0.92rem; color: var(--text-secondary); }
++.recap-by-name { font-weight: 800; color: var(--text-primary); text-decoration: none; }
++.recap-by b { color: var(--text-primary); }
++.recap-by-meta { color: var(--text-muted); font-size: 0.82rem; }
++.recap-avatar { display: inline-grid; place-items: center; border-radius: 50%; overflow: hidden; background: var(--accent); color: #fff; font-weight: 800; font-size: 0.8rem; flex: 0 0 auto; }
++.recap-avatar img { width: 100%; height: 100%; object-fit: cover; }
++.recap-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
++.recap-tags span { font-size: 0.74rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--g) 14%, var(--bg-tertiary)); color: var(--text-primary); border: 1px solid color-mix(in srgb, var(--g) 35%, var(--border)); }
++.recap-summary { font-size: 1.08rem; line-height: 1.6; color: var(--text-primary); margin: 0 0 18px; padding: 14px 18px; border-left: 3px solid var(--g); background: color-mix(in srgb, var(--g) 7%, var(--bg-secondary)); border-radius: 0 12px 12px 0; }
++.recap-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 10px; margin-bottom: 16px; }
++.recap-tile { padding: 12px 12px 10px; border-radius: 14px; background: var(--bg-secondary); border: 1px solid var(--border); display: grid; gap: 2px; }
++.recap-tile i { color: var(--g); font-size: 0.85rem; }
++.recap-tile b { font-size: 1.5rem; font-weight: 900; line-height: 1.1; }
++.recap-tile span { font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; }
++.recap-card, .recap-section { margin-bottom: 16px; padding: 14px 16px; border-radius: 14px; background: var(--bg-secondary); border: 1px solid var(--border); }
++.recap-card h3, .recap-section h3 { margin: 0 0 10px; font-size: 0.95rem; display: flex; align-items: center; gap: 8px; }
++.recap-card h3 i, .recap-section h3 i { color: var(--g); }
++.recap-legend { margin-left: auto; font-size: 0.72rem; color: var(--text-muted); font-weight: 600; display: inline-flex; align-items: center; gap: 5px; }
++.recap-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; }
++.recap-legend .l1 { background: #c4b5fd; } .recap-legend .l2 { background: #f472b6; }
++.recap-curve { width: 100%; height: 150px; display: block; }
++.recap-bar { fill: #f472b6; opacity: 0.8; }
++.recap-curve-axis { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text-muted); margin-top: 4px; }
++.recap-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
++.recap-cols > * { margin-bottom: 16px; }
++.recap-moment-text { font-size: 1.05rem; font-weight: 700; line-height: 1.45; margin: 0 0 10px; }
++.recap-mics { display: grid; gap: 6px; }
++.recap-mic { display: block; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.88rem; line-height: 1.4; color: var(--text-primary); text-decoration: none; font-style: italic; position: relative; }
++.recap-mic i.fa-play { color: var(--g); font-style: normal; margin-right: 4px; font-size: 0.75rem; }
++.recap-mic:hover { border-color: var(--g); }
++.recap-mic-q { position: absolute; right: 8px; top: 6px; font-size: 0.66rem; font-weight: 800; color: var(--g); font-style: normal; }
++.recap-chatters { list-style: none; margin: 0; padding: 0; display: grid; gap: 7px; }
++.recap-chatters li { display: grid; grid-template-columns: 26px 26px minmax(70px, 1fr) minmax(60px, 2fr) 40px; align-items: center; gap: 8px; font-size: 0.9rem; }
++.recap-medal { text-align: center; font-weight: 800; color: var(--text-muted); }
++.recap-chatter-name { font-weight: 700; color: var(--text-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.recap-chatter-bar { height: 8px; border-radius: 999px; background: var(--bg-tertiary); overflow: hidden; }
++.recap-chatter-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--g), #f472b6); border-radius: 999px; }
++.recap-chatter-n { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
++.recap-fine { margin-top: 10px; font-size: 0.76rem; color: var(--text-muted); }
++.recap-clips { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
++.recap-clip { position: relative; display: block; border-radius: 12px; overflow: hidden; background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary); aspect-ratio: 16 / 10; }
++.recap-clip img, .recap-clip-ph { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: grid; place-items: center; color: var(--text-muted); font-size: 1.6rem; }
++.recap-clip-dur { position: absolute; right: 6px; top: 6px; font-size: 0.68rem; font-weight: 800; padding: 2px 6px; border-radius: 6px; background: rgba(0,0,0,0.7); color: #fff; }
++.recap-clip-title { position: absolute; left: 0; right: 0; bottom: 0; padding: 18px 8px 7px; font-size: 0.78rem; font-weight: 700; color: #fff; background: linear-gradient(transparent, rgba(0,0,0,0.85)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.recap-clip-views { position: absolute; left: 6px; top: 6px; font-size: 0.68rem; font-weight: 700; padding: 2px 6px; border-radius: 6px; background: rgba(0,0,0,0.6); color: #fff; }
++.recap-vod { display: flex; gap: 14px; align-items: center; margin-bottom: 16px; padding: 10px; border-radius: 14px; background: linear-gradient(135deg, color-mix(in srgb, var(--g) 16%, var(--bg-secondary)), var(--bg-secondary)); border: 1px solid color-mix(in srgb, var(--g) 40%, var(--border)); text-decoration: none; color: var(--text-primary); }
++.recap-vod img { width: 160px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 10px; flex: 0 0 auto; }
++.recap-vod-body { display: grid; gap: 3px; }
++.recap-vod-body b { font-size: 1.05rem; } .recap-vod-body b i { color: var(--g); margin-right: 4px; }
++.recap-vod-body span { font-size: 0.84rem; color: var(--text-secondary); }
++.recap-share { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 20px; }
++.recap-more { display: grid; gap: 8px; }
++.recap-more-item { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary); }
++.recap-more-item:hover { border-color: var(--g); }
++.recap-grade-mini { --g: #a78bfa; width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; font-weight: 900; color: var(--g); border: 1.5px solid color-mix(in srgb, var(--g) 60%, transparent); background: color-mix(in srgb, var(--g) 12%, transparent); }
++.recap-more-title { font-weight: 700; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.recap-more-meta { font-size: 0.74rem; color: var(--text-muted); white-space: nowrap; }
++@media (max-width: 720px) {
++ .recap-head { gap: 14px; }
++ .recap-grade { width: 74px; height: 74px; border-radius: 18px; } .recap-grade span { font-size: 2.1rem; } .recap-grade small { display: none; }
++ .recap-cols { grid-template-columns: 1fr; gap: 0; }
++ .recap-vod img { width: 110px; }
++ .recap-more-item { grid-template-columns: 30px 1fr; } .recap-more-meta { grid-column: 2; white-space: normal; }
++ .recap-chatters li { grid-template-columns: 22px 24px minmax(60px, 1fr) minmax(40px, 1.4fr) 34px; font-size: 0.84rem; }
++}
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 611795c0..367ee596 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -2294,6 +2294,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.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-recap { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; margin-left: -4px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-muted); font-size: 0.78rem; text-decoration: none; align-self: center; }
++.digest-recap:hover { color: var(--accent); border-color: var(--accent); }
.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; }
ddiff --git a/public/index.html b/public/index.html
iindex 0e3ec919..c9c22dbd 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=158">
++ <link rel="stylesheet" href="/css/style.css?v=159">
<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">
@@@ -71,6 +71,7 @@
<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=4">
++ <link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- JSON-LD Structured Data -->
@@@ -3520,6 +3521,12 @@ WantedBy=multi-user.target</code>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ARENA — streamer vs streamer (public/js/arena.js) -->
<!-- ═══════════════════════════════════════════════════════════ -->
++ <section id="page-recap" class="page">
++ <div class="content-container recap-container">
++ <div id="recap-root"><div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>
++ </div>
++ </section>
++
<section id="page-arena" class="page">
<div class="content-container arena-container">
<div id="arena-root">
@@@ -5046,7 +5053,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=136" defer></script>
++<script src="/js/app.js?v=137" 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>
@@@ -5119,6 +5126,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/arena.js?v=18" defer></script>
++<script src="/js/recap.js?v=1" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 172a1869..3702f8d3 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -58,7 +58,7 @@ function isStaffUser(user = currentUser) {
}
// Reserved paths (not usernames)
--const RESERVED = new Set(['vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena']);
++const RESERVED = new Set(['vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
function normalizeChannelUsername(username) {
@@@ -990,6 +990,10 @@ function routeFromURL() {
});
}).catch(() => {});
}
++ } else if (segments[0] === 'recap' && segments[1]) {
++ // After-show report: /recap/:streamId
++ showPage('recap');
++ if (typeof loadRecapPage === 'function') loadRecapPage(segments[1]);
} else if (segments[0] === 'arena') {
showPage('arena');
if (typeof loadArenaPage === 'function') loadArenaPage(segments);
@@@ -1882,7 +1886,7 @@ async function loadHomeDigest() {
<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>`;
++ </a>${u.recap_stream_id ? `<a class="digest-recap" href="/recap/${u.recap_stream_id}" onclick="return handleLinkClick(event, '/recap/${u.recap_stream_id}')" title="After-show report"><i class="fa-solid fa-clipboard-list"></i></a>` : ''}`;
const parts = [];
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' : ''));
ddiff --git a/public/js/recap.js b/public/js/recap.js
nnew file mode 100644
iindex 00000000..1a6c6d1b
---- /dev/null
++++ b/public/js/recap.js
@@@ -0,0 +1,110 @@
++/**
++ * recap.js — the after-show report page (/recap/:streamId).
++ * Renders what /api/recap/:id returns: grade + headline + AI summary, stat tiles, the viewer
++ * curve with chat bars, moment of the night + mic lines, top chatters, clips, the VOD, share.
++ */
++(function () {
++ 'use strict';
++ const GRADE = { S: { color: '#fbbf24', label: 'Legendary night' }, A: { color: '#a78bfa', label: 'Great night' }, B: { color: '#38bdf8', label: 'Solid night' }, C: { color: '#94a3b8', label: 'Quiet night' } };
++ const n = (v) => Number(v || 0).toLocaleString();
++ const dur = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; };
++ const when = (ts) => { const t = Date.parse(String(ts || '').replace(' ', 'T') + (String(ts || '').endsWith('Z') ? '' : 'Z')); return Number.isFinite(t) ? new Date(t).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''; };
++ const av = (u, size) => `<span class="recap-avatar" style="width:${size}px;height:${size}px;${u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="" loading="lazy">` : esc(String(u.display_name || u.username || '?').charAt(0).toUpperCase())}</span>`;
++ const go = (href) => `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`;
++
++ function curveSvg(curve, startedAt) {
++ if (!curve || curve.length < 2) return '';
++ const W = 720, H = 150, padL = 4, padR = 4, top = 14, base = 112, barTop = 118, barBase = 148;
++ const maxV = Math.max(1, ...curve.map(p => p[1]));
++ const maxC = Math.max(1, ...curve.map(p => p[2]));
++ const x = (i) => padL + (i / (curve.length - 1)) * (W - padL - padR);
++ const y = (v) => base - (v / maxV) * (base - top);
++ const pts = curve.map((p, i) => `${x(i).toFixed(1)},${y(p[1]).toFixed(1)}`);
++ const area = `M${pts[0]} L${pts.join(' L')} L${x(curve.length - 1).toFixed(1)},${base} L${x(0).toFixed(1)},${base} Z`;
++ const line = `M${pts.join(' L')}`;
++ const bw = Math.max(1.5, (W - padL - padR) / curve.length - 1);
++ const bars = curve.map((p, i) => p[2] > 0 ? `<rect x="${(x(i) - bw / 2).toFixed(1)}" y="${(barBase - (p[2] / maxC) * (barBase - barTop)).toFixed(1)}" width="${bw.toFixed(1)}" height="${((p[2] / maxC) * (barBase - barTop)).toFixed(1)}" rx="1" class="recap-bar"/>` : '').join('');
++ let peakI = 0; curve.forEach((p, i) => { if (p[1] > curve[peakI][1]) peakI = i; });
++ const t0 = Date.parse(String(startedAt || '').replace(' ', 'T') + 'Z');
++ const tick = (i) => { const t = Date.parse(String(curve[i][0]).replace(' ', 'T') + 'Z'); return Number.isFinite(t) && Number.isFinite(t0) ? dur((t - t0) / 1000) : ''; };
++ return `<svg class="recap-curve" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Viewers over the stream">
++ <defs><linearGradient id="rcg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#a78bfa" stop-opacity="0.55"/><stop offset="1" stop-color="#a78bfa" stop-opacity="0.02"/></linearGradient></defs>
++ <path d="${area}" fill="url(#rcg)"/><path d="${line}" fill="none" stroke="#c4b5fd" stroke-width="2.2" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>
++ ${bars}
++ <circle cx="${x(peakI).toFixed(1)}" cy="${y(curve[peakI][1]).toFixed(1)}" r="4.5" fill="#fbbf24" stroke="#0b0b12" stroke-width="2"/>
++ </svg>
++ <div class="recap-curve-axis"><span>start</span><span>peak ${n(curve[peakI][1])} · ${tick(peakI)} in</span><span>${tick(curve.length - 1)}</span></div>`;
++ }
++
++ function render(root, data, id) {
++ const r = data.recap, w = r.write || {}, s = r.stream, u = r.streamer;
++ const g = GRADE[w.grade] || GRADE.B;
++ const path = `/@${u.username}`;
++ const owner = window.currentUser && (currentUser.id === u.id || currentUser.role === 'admin');
++ const tiles = [
++ ['fa-eye', n(s.peak_viewers), 'peak viewers'],
++ r.viewers.avg != null ? ['fa-chart-line', n(r.viewers.avg), 'avg viewers'] : null,
++ ['fa-comments', n(r.chat.messages), 'chat lines'],
++ ['fa-users', n(r.chat.chatters), 'chatters'],
++ ['fa-heart', `+${n(r.love.follows)}`, 'new follows'],
++ r.love.tips ? ['fa-coins', n(r.love.tips_total), `in ${r.love.tips} tip${r.love.tips === 1 ? '' : 's'}`] : null,
++ r.clips.length ? ['fa-scissors', n(r.clips.length), 'clips'] : null,
++ r.chat.sounds ? ['fa-volume-high', n(r.chat.sounds), 'sound commands'] : null,
++ r.mic.length ? ['fa-microphone-lines', n(r.mic.length), 'mic moments'] : null,
++ ].filter(Boolean).map(([i, v, l]) => `<div class="recap-tile"><i class="fa-solid ${i}"></i><b>${v}</b><span>${esc(l)}</span></div>`).join('');
++ const medals = ['🥇', '🥈', '🥉', '4', '5'];
++ const topN = r.chat.top[0] ? r.chat.top[0].n : 1;
++ const chatters = r.chat.top.length ? `<div class="recap-card"><h3><i class="fa-solid fa-ranking-star"></i> Loudest in chat</h3><ol class="recap-chatters">${r.chat.top.map((c, i) => `<li><span class="recap-medal">${medals[i]}</span>${av(c, 26)}<a class="recap-chatter-name" ${go(`/@${c.username}`)}>${esc(c.display_name || c.username)}</a><span class="recap-chatter-bar"><i style="width:${Math.max(6, Math.round(c.n / topN * 100))}%"></i></span><span class="recap-chatter-n">${n(c.n)}</span></li>`).join('')}</ol>${r.chat.busiest_at ? `<div class="recap-fine">Busiest stretch: ${esc(when(r.chat.busiest_at))} (${n(r.chat.busiest_n)} lines in 5 min)</div>` : ''}</div>` : '';
++ const micLine = (m) => { const href = m.vod_id ? `/vod/${m.vod_id}?t=${Math.max(0, Math.round(m.sec || 0))}` : null; const inner = `“${esc(m.text)}”${m.aimed_at ? ` <span class="muted">at ${esc(m.aimed_at)}</span>` : ''}<span class="recap-mic-q">${m.quality}/10</span>`; return href ? `<a class="recap-mic" ${go(href)}><i class="fa-solid fa-play"></i> ${inner}</a>` : `<div class="recap-mic">${inner}</div>`; };
++ const moment = (w.moment || r.mic.length) ? `<div class="recap-card recap-moment"><h3><i class="fa-solid fa-fire"></i> Moment of the night</h3>${w.moment ? `<p class="recap-moment-text">${esc(w.moment)}</p>` : ''}${r.mic.length ? `<div class="recap-mics">${r.mic.map(micLine).join('')}</div>` : ''}</div>` : '';
++ const clips = r.clips.length ? `<div class="recap-section"><h3><i class="fa-solid fa-scissors"></i> Clips from this stream</h3><div class="recap-clips">${r.clips.map(c => `<a class="recap-clip" ${go(`/clip/${c.id}`)}>${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="recap-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}<span class="recap-clip-dur">${dur(c.duration_seconds)}</span><span class="recap-clip-title">${esc(c.title)}</span>${c.view_count ? `<span class="recap-clip-views"><i class="fa-solid fa-eye"></i> ${n(c.view_count)}</span>` : ''}</a>`).join('')}</div></div>` : '';
++ const vod = r.vod ? `<a class="recap-vod" ${go(`/vod/${r.vod.id}`)}>${r.vod.thumbnail_url ? `<img src="${esc(r.vod.thumbnail_url)}" alt="" loading="lazy">` : ''}<span class="recap-vod-body"><b><i class="fa-solid fa-play"></i> Watch the VOD</b><span>${dur(r.vod.duration_seconds)} · the whole stream, with the transcript and AI moments</span></span></a>` : '';
++ const more = (data.more || []).length ? `<div class="recap-section"><h3><i class="fa-solid fa-clock-rotate-left"></i> More nights from ${esc(u.display_name)}</h3><div class="recap-more">${data.more.map(m => `<a class="recap-more-item" ${go(`/recap/${m.stream_id}`)}><span class="recap-grade-mini" style="--g:${(GRADE[m.grade] || GRADE.B).color}">${esc(m.grade)}</span><span class="recap-more-title">${esc(m.headline || m.title)}</span><span class="recap-more-meta">${esc(timeAgo(m.ended_at))} · ${dur(m.duration_seconds)} · peak ${n(m.peak_viewers)}</span></a>`).join('')}</div></div>` : '';
++ const shareUrl = `${location.origin}/recap/${id}`;
++ const shareText = `${w.headline || s.title} — ${u.display_name}'s stream report on OpenVibe.Live`;
++ root.innerHTML = `
++ <article class="recap" style="--g:${g.color}">
++ <header class="recap-head">
++ <div class="recap-grade" title="${esc(g.label)}"><span>${esc(w.grade || 'B')}</span><small>${esc(g.label)}</small></div>
++ <div class="recap-head-body">
++ <div class="recap-kicker"><i class="fa-solid fa-clipboard-list"></i> After-show report ${r.ai ? '<span class="recap-ai"><i class="fa-solid fa-wand-magic-sparkles"></i> written by OpenVibe AI</span>' : ''}</div>
++ <h1 class="recap-headline">${esc(w.headline || s.title)}</h1>
++ <div class="recap-by">${av(u, 34)}<a class="recap-by-name" ${go(path)}>${esc(u.display_name)}</a><span class="muted">streamed</span><b>${esc(s.title)}</b><span class="recap-by-meta">${esc(when(s.started_at))} · ${dur(s.duration_seconds)}${s.category ? ` · ${esc(s.category)}` : ''}</span></div>
++ ${(w.tags || []).length ? `<div class="recap-tags">${w.tags.map(t => `<span>#${esc(t)}</span>`).join('')}</div>` : ''}
++ </div>
++ </header>
++ ${w.summary ? `<p class="recap-summary">${esc(w.summary)}</p>` : ''}
++ <div class="recap-tiles">${tiles}</div>
++ ${r.viewers.curve.length >= 2 ? `<div class="recap-card recap-curve-card"><h3><i class="fa-solid fa-chart-area"></i> The room, minute by minute <span class="recap-legend"><i class="l1"></i> viewers <i class="l2"></i> chat</span></h3>${curveSvg(r.viewers.curve, s.started_at)}</div>` : ''}
++ <div class="recap-cols">${moment}${chatters}</div>
++ ${clips}
++ ${vod}
++ <div class="recap-share">
++ <button type="button" class="btn btn-primary" id="recap-copy"><i class="fa-solid fa-link"></i> Copy link</button>
++ <a class="btn btn-outline" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}"><i class="fa-brands fa-x-twitter"></i> Post</a>
++ <a class="btn btn-outline" ${go(path)}><i class="fa-solid fa-user"></i> ${esc(u.display_name)}'s channel</a>
++ ${owner ? `<button type="button" class="btn btn-outline" id="recap-regen"><i class="fa-solid fa-rotate"></i> Rewrite report</button>` : ''}
++ </div>
++ ${more}
++ </article>`;
++ const copy = root.querySelector('#recap-copy');
++ if (copy) copy.onclick = async () => { try { await navigator.clipboard.writeText(shareUrl); copy.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { copy.innerHTML = '<i class="fa-solid fa-link"></i> Copy link'; }, 1800); } catch { prompt('Copy this link', shareUrl); } };
++ const regen = root.querySelector('#recap-regen');
++ if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };
++ try { document.title = `${w.headline || s.title} — ${u.display_name}'s stream report | OpenVibe.Live`; } catch { /* */ }
++ }
++
++ window.loadRecapPage = async function (id) {
++ const root = document.getElementById('recap-root');
++ if (!root) return;
++ root.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
++ let data = null;
++ try { data = await api(`/recap/${encodeURIComponent(id)}`); } catch (e) {
++ const live = e && /live/i.test(e.message || '');
++ root.innerHTML = `<div class="empty-state"><i class="fa-solid ${live ? 'fa-tower-broadcast' : 'fa-clipboard-list'} fa-3x"></i><p>${live ? 'This stream is still live — the report lands a couple of minutes after it ends.' : 'No report for this stream.'}</p><p class="muted">${esc((e && e.message) || '')}</p><a class="btn btn-outline" ${go('/')}>Back home</a></div>`;
++ return;
++ }
++ if (!data || !data.recap) { root.innerHTML = '<div class="empty-state"><p>No report for this stream.</p></div>'; return; }
++ render(root, data, id);
++ };
++})();
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 040f3348..56a3d473 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -319,7 +319,7 @@ router.get('/digest', optionalAuth, (req, res) => {
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) }))
++ .filter(r => !liveIds.has(r.user_id)).map(r => ({ ...r, followed: followed.has(r.user_id), recap_stream_id: (() => { try { const x = db.get('SELECT r.stream_id FROM stream_recaps r JOIN streams s ON s.id = r.stream_id WHERE r.user_id = ? AND s.started_at >= ? ORDER BY r.created_at DESC LIMIT 1', [r.user_id, sinceSql]); return x ? x.stream_id : null; } catch { return null; } })() }))
.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);
ddiff --git a/server/index.js b/server/index.js
iindex fcd7d431..d110b265 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -588,6 +588,7 @@ app.use('/api/clips', clipRoutes);
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
app.use('/api/arena', require('./arena/routes')); // streamer vs streamer (docs/arena.md)
++app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/comments', commentRoutes);
app.use('/api/controls', controlRoutes);
app.use('/api/onvif', onvifRoutes);
@@@ -1134,6 +1135,7 @@ async function start() {
try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
try { require('./arena/arena-job').start(); } catch (e) { console.warn('[Arena] job not started:', e.message); }
++ try { require('./recap/recap').start(); } catch (e) { console.warn('[Recap] job not started:', e.message); }
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// (default off), so starting it is a no-op until switched on in admin.
try { require('./ai/timeline-job').start(); } catch (e) { console.warn('[AI] timeline job not started:', e.message); }
ddiff --git a/server/recap/recap.js b/server/recap/recap.js
nnew file mode 100644
iindex 00000000..80ae3ae1
---- /dev/null
++++ b/server/recap/recap.js
@@@ -0,0 +1,232 @@
++/**
++ * recap.js — the after-show report. Every stream that ran long enough gets a recap page
++ * (/recap/<streamId>) a few minutes after it ends: the viewer curve, chat numbers and top
++ * chatters, the best Arena mic lines, clips, tips and follows gained, the VOD — and an AI
++ * headline + summary written from all of it (template copy when AI is off).
++ *
++ * buildRecap(streamId) gather + (optionally) AI → { …recap } and store it
++ * getRecap(streamId) stored recap or null
++ * ensureRecap(streamId) stored or freshly built
++ * start() job: recap + chat announcement for streams that ended recently
++ *
++ * Stored in `stream_recaps` (one row per stream, JSON). Rebuilt on demand by the owner/admin.
++ */
++'use strict';
++
++const db = require('../db/database');
++
++const MIN_DURATION_SEC = 8 * 60; // shorter streams don't get a report
++const LOOKBACK_HOURS = 12; // job only looks at streams that ended this recently
++const SETTLE_SEC = 150; // wait for VOD/clips/moments to land after the end
++const JOB_INTERVAL_MS = 2 * 60 * 1000;
++
++let _tableReady = false;
++function ensureTable() {
++ if (_tableReady) return;
++ try {
++ db.getDb().exec(`CREATE TABLE IF NOT EXISTS stream_recaps (
++ stream_id INTEGER PRIMARY KEY,
++ user_id INTEGER,
++ json TEXT NOT NULL,
++ ai INTEGER DEFAULT 0,
++ announced_at DATETIME,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_stream_recaps_user ON stream_recaps(user_id, created_at)');
++ _tableReady = true;
++ } catch (e) { console.warn('[Recap] table:', e.message); }
++}
++
++const sqlTs = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
++const parseTs = (s) => { if (!s) return NaN; const t = Date.parse(String(s).replace(' ', 'T') + (String(s).endsWith('Z') ? '' : 'Z')); return t; };
++const safe = (fn, dflt) => { try { const v = fn(); return v == null ? dflt : v; } catch { return dflt; } };
++function fmtDur(sec) { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; }
++
++/** Everything we know about one finished stream. */
++async function gather(streamId) {
++ const stream = db.getStreamById(streamId);
++ if (!stream || stream.is_live) return null;
++ const user = db.getUserById(stream.user_id);
++ if (!user) return null;
++ const startMs = parseTs(stream.started_at);
++ const endMs = parseTs(stream.ended_at) || (startMs + (stream.duration_seconds || 0) * 1000);
++ const durationSec = Number(stream.duration_seconds) || Math.max(0, Math.round((endMs - startMs) / 1000));
++ const start = sqlTs(startMs), end = sqlTs(endMs);
++
++ // Viewer curve (sampled every ~minute by the chat server while live).
++ const curve = safe(() => db.all(`SELECT recorded_at AS t, viewer_count AS v, COALESCE(chat_messages_5m, 0) AS c FROM viewer_snapshots WHERE stream_id = ? ORDER BY recorded_at ASC`, [streamId]), []);
++ const viewersAvg = curve.length ? Math.round(curve.reduce((n, p) => n + Number(p.v || 0), 0) / curve.length * 10) / 10 : null;
++ const peakAt = curve.length ? curve.reduce((best, p) => (Number(p.v) > Number(best.v) ? p : best), curve[0]) : null;
++
++ // Chat.
++ const chat = safe(() => db.get(`SELECT COUNT(*) AS n, COUNT(DISTINCT COALESCE(user_id, anon_id, username)) AS chatters FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0`, [streamId]), { n: 0, chatters: 0 });
++ const topChatters = safe(() => db.all(`SELECT c.username, u.display_name, u.avatar_url, u.profile_color, COUNT(*) AS n
++ FROM chat_messages c LEFT JOIN users u ON u.id = c.user_id
++ WHERE c.stream_id = ? AND COALESCE(c.is_deleted, 0) = 0 AND c.username IS NOT NULL AND (c.user_id IS NULL OR c.user_id != ?)
++ GROUP BY COALESCE(c.user_id, c.username) ORDER BY n DESC LIMIT 5`, [streamId, stream.user_id]), []);
++ const busiest = curve.length ? curve.reduce((best, p) => (Number(p.c) > Number(best.c) ? p : best), curve[0]) : null;
++ const sounds = safe(() => db.get(`SELECT COUNT(*) AS n FROM chat_messages WHERE stream_id = ? AND message_type = 'soundboard'`, [streamId]).n, 0);
++
++ // Arena mic lines said on this stream.
++ const micLines = safe(() => db.all(`SELECT text, quality, kind, aimed_at, sec, vod_id FROM arena_mic_moments WHERE stream_id = ? ORDER BY quality DESC, said_at ASC LIMIT 4`, [streamId]), []);
++
++ // Money + love.
++ const tips = safe(() => db.get(`SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0) AS total FROM transactions WHERE type = 'donation' AND COALESCE(status, 'completed') NOT IN ('failed', 'refunded', 'pending') AND ((stream_id = ?) OR (to_user_id = ? AND created_at BETWEEN ? AND ?))`, [streamId, stream.user_id, start, end]), { n: 0, total: 0 });
++ const topTipper = safe(() => db.get(`SELECT u.username, u.display_name, SUM(t.amount) AS total FROM transactions t JOIN users u ON u.id = t.from_user_id
++ WHERE t.type = 'donation' AND ((t.stream_id = ?) OR (t.to_user_id = ? AND t.created_at BETWEEN ? AND ?)) GROUP BY t.from_user_id ORDER BY total DESC LIMIT 1`, [streamId, stream.user_id, start, end]), null);
++ const follows = safe(() => db.get(`SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?`, [stream.user_id, start, end]).n, 0);
++ const followersNow = safe(() => db.getFollowerCount(stream.user_id), null);
++
++ // What was said (for the model): a slice of the transcript, English when we have it.
++ const speech = safe(() => db.all(`SELECT COALESCE(text_en, text) AS t, start_sec AS s FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' ORDER BY start_sec ASC`, [streamId]), []);
++ const speechSample = (() => {
++ if (!speech.length) return '';
++ const pick = [];
++ const n = speech.length, take = Math.min(n, 36);
++ for (let i = 0; i < take; i++) pick.push(speech[Math.floor(i * n / take)].t);
++ return pick.join(' ').replace(/\s+/g, ' ').slice(0, 1600);
++ })();
++
++ // Media: the VOD + clips of this stream.
++ let vod = null, clips = [];
++ try {
++ const media = require('../media-client');
++ const out = await media.listVods({ stream_id: streamId, limit: 3 }).catch(() => null);
++ const vods = (out?.vods || []).filter(v => v.status === 'ready' && (v.duration_seconds || v.duration));
++ if (vods.length) { const v = vods.sort((a, b) => (b.duration_seconds || b.duration || 0) - (a.duration_seconds || a.duration || 0))[0]; vod = { id: v.id, thumbnail_url: v.thumbnail_url || null, duration_seconds: v.duration_seconds || v.duration || 0 }; }
++ const co = await media.listClips({ stream_id: streamId, limit: 12 }).catch(() => null);
++ clips = (co?.clips || (Array.isArray(co) ? co : [])).filter(c => c && (c.is_public == null || Number(c.is_public) === 1))
++ .sort((a, b) => (Number(b.view_count) || 0) - (Number(a.view_count) || 0)).slice(0, 6)
++ .map(c => ({ id: c.id, title: c.title || 'Clip', thumbnail_url: c.thumbnail_url || null, duration_seconds: c.duration_seconds || c.duration || 0, view_count: Number(c.view_count) || 0, by: c.display_name || c.username || null }));
++ } catch { /* Media down — the report still ships */ }
++
++ return {
++ stream: { id: stream.id, title: stream.title || 'Untitled stream', category: stream.ai_category || stream.category || null, protocol: stream.protocol || null, started_at: stream.started_at, ended_at: stream.ended_at || end, duration_seconds: durationSec, peak_viewers: Math.max(Number(stream.peak_viewers) || 0, peakAt ? Number(peakAt.v) || 0 : 0), ai_overview: stream.ai_overview_short || stream.ai_overview || null },
++ streamer: { id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, profile_color: user.profile_color || null },
++ viewers: { avg: viewersAvg, peak_at: peakAt ? peakAt.t : null, curve: curve.map(p => [p.t, Number(p.v) || 0, Number(p.c) || 0]) },
++ chat: { messages: Number(chat.n) || 0, chatters: Number(chat.chatters) || 0, top: topChatters.map(r => ({ username: r.username, display_name: r.display_name || r.username, avatar_url: r.avatar_url || null, profile_color: r.profile_color || null, n: Number(r.n) })), busiest_at: busiest && Number(busiest.c) > 0 ? busiest.t : null, busiest_n: busiest ? Number(busiest.c) : 0, sounds },
++ mic: micLines.map(m => ({ text: m.text, quality: m.quality, kind: m.kind, aimed_at: m.aimed_at || null, sec: m.sec, vod_id: m.vod_id || null })),
++ love: { tips: Number(tips.n) || 0, tips_total: Number(tips.total) || 0, top_tipper: topTipper ? { username: topTipper.username, display_name: topTipper.display_name || topTipper.username, total: Number(topTipper.total) } : null, follows, followers_now: followersNow },
++ speech: { lines: speech.length, sample: speechSample },
++ vod, clips,
++ };
++}
++
++// ── The write-up ─────────────────────────────────────────────
++const SCHEMA = { name: 'stream_recap', schema: { type: 'object', additionalProperties: false, required: ['headline', 'summary', 'moment', 'tags', 'grade'], properties: {
++ headline: { type: 'string', description: '≤ 70 chars, like a sports-page headline about this stream, no quotes, no emojis' },
++ summary: { type: 'string', description: '2–3 sentences, ≤ 420 chars, what happened and how it went, concrete, warm, second person is fine' },
++ moment: { type: 'string', description: '≤ 160 chars, the single moment of the night (from the mic lines, chat spike, a clip title or the transcript) — or empty string' },
++ tags: { type: 'array', items: { type: 'string' }, description: '3 short vibe tags, 1–2 words each, lowercase' },
++ grade: { type: 'string', enum: ['S', 'A', 'B', 'C'], description: 'S = legendary night, A = great, B = solid, C = quiet' },
++} } };
++
++function templateWriteup(g) {
++ const s = g.stream, name = g.streamer.display_name;
++ const bits = [];
++ bits.push(`${name} streamed "${s.title}" for ${fmtDur(s.duration_seconds)}${s.peak_viewers ? `, peaking at ${s.peak_viewers} viewer${s.peak_viewers === 1 ? '' : 's'}` : ''}.`);
++ if (g.chat.messages) bits.push(`Chat put up ${g.chat.messages} line${g.chat.messages === 1 ? '' : 's'} from ${g.chat.chatters} ${g.chat.chatters === 1 ? 'person' : 'people'}${g.chat.top[0] ? `, led by ${g.chat.top[0].display_name}` : ''}.`);
++ if (g.love.follows || g.love.tips) bits.push([g.love.follows ? `${g.love.follows} new follow${g.love.follows === 1 ? '' : 's'}` : '', g.love.tips ? `${g.love.tips} tip${g.love.tips === 1 ? '' : 's'}` : ''].filter(Boolean).join(' and ') + ' came in.');
++ const grade = s.peak_viewers >= 25 || g.chat.messages >= 400 ? 'S' : s.peak_viewers >= 10 || g.chat.messages >= 120 ? 'A' : s.peak_viewers >= 3 || g.chat.messages >= 25 ? 'B' : 'C';
++ return {
++ headline: `${name} went ${fmtDur(s.duration_seconds)} on "${String(s.title).slice(0, 40)}"`,
++ summary: bits.join(' ').slice(0, 420),
++ moment: g.clips[0] ? `Clip: ${g.clips[0].title}` : (g.chat.busiest_n >= 10 ? `Chat hit ${g.chat.busiest_n} lines in five minutes.` : ''),
++ tags: [s.category || 'stream', g.chat.messages >= 100 ? 'chatty' : 'chill', g.mic.length ? 'mic on fire' : 'good vibes'],
++ grade,
++ };
++}
++
++async function aiWriteup(g) {
++ let llm = null; try { llm = require('../ai/llm'); } catch { return null; }
++ if (!llm || !llm.isEnabled() || !llm.withinBudget()) return null;
++ const facts = {
++ streamer: g.streamer.display_name, title: g.stream.title, category: g.stream.category, duration: fmtDur(g.stream.duration_seconds),
++ viewers: { peak: g.stream.peak_viewers, avg: g.viewers.avg }, chat: { messages: g.chat.messages, chatters: g.chat.chatters, top: g.chat.top.map(t => `${t.display_name} (${t.n})`), sound_commands: g.chat.sounds },
++ mic_lines: g.mic.map(m => m.text), clips: g.clips.map(c => c.title), tips: g.love.tips_total ? `${g.love.tips} tips, ${g.love.tips_total} total` : 'none', new_follows: g.love.follows,
++ what_was_said: g.speech.sample || '(no transcript)',
++ };
++ const system = `You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.`;
++ let r = null;
++ try { r = await llm.complete({ role: 'summary', kind: 'stream_recap', source: 'recap', ownerUserId: g.streamer.id, system, user: JSON.stringify(facts), json: SCHEMA, maxTokens: 500, temperature: 0.8, timeoutMs: 40000 }); } catch (e) { console.warn('[Recap] model call failed:', e.message); return null; }
++ const out = r && (r.json || (r.text ? llm.parseJsonLoose(r.text) : null));
++ if (!out || !out.headline || !out.summary) return null;
++ return { headline: String(out.headline).trim().slice(0, 90), summary: String(out.summary).trim().slice(0, 500), moment: String(out.moment || '').trim().slice(0, 200), tags: (Array.isArray(out.tags) ? out.tags : []).map(t => String(t).trim().toLowerCase()).filter(Boolean).slice(0, 4), grade: ['S', 'A', 'B', 'C'].includes(out.grade) ? out.grade : 'B' };
++}
++
++async function buildRecap(streamId, { ai = true } = {}) {
++ ensureTable();
++ const g = await gather(streamId);
++ if (!g) return null;
++ let write = ai ? await aiWriteup(g) : null;
++ const usedAi = !!write;
++ if (!write) write = templateWriteup(g);
++ const recap = { ...g, write, ai: usedAi, generated_at: new Date().toISOString() };
++ db.run(`INSERT INTO stream_recaps (stream_id, user_id, json, ai, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(stream_id) DO UPDATE SET json = excluded.json, ai = excluded.ai, created_at = CURRENT_TIMESTAMP`, [streamId, g.streamer.id, JSON.stringify(recap), usedAi ? 1 : 0]);
++ return recap;
++}
++
++function getRecap(streamId) {
++ ensureTable();
++ const row = db.get('SELECT json FROM stream_recaps WHERE stream_id = ?', [streamId]);
++ if (!row) return null;
++ try { return JSON.parse(row.json); } catch { return null; }
++}
++
++async function ensureRecap(streamId) { return getRecap(streamId) || buildRecap(streamId); }
++
++/** Latest recaps for a channel (for the channel page / "more nights like this"). */
++function listRecaps(userId, limit = 6) {
++ ensureTable();
++ return (db.all('SELECT stream_id, json, ai, created_at FROM stream_recaps WHERE user_id = ? ORDER BY created_at DESC LIMIT ?', [userId, limit]) || []).map(r => {
++ try { const j = JSON.parse(r.json); return { stream_id: r.stream_id, title: j.stream.title, headline: j.write.headline, grade: j.write.grade, duration_seconds: j.stream.duration_seconds, peak_viewers: j.stream.peak_viewers, ended_at: j.stream.ended_at, thumbnail_url: j.vod ? j.vod.thumbnail_url : null }; } catch { return null; }
++ }).filter(Boolean);
++}
++
++/** Streams that ended recently, ran long enough, and have no recap yet. */
++function pending() {
++ ensureTable();
++ return db.all(`SELECT s.id FROM streams s
++ WHERE s.is_live = 0 AND s.ended_at IS NOT NULL
++ AND s.ended_at >= datetime('now', ?) AND s.ended_at <= datetime('now', ?)
++ AND COALESCE(s.duration_seconds, (julianday(s.ended_at) - julianday(s.started_at)) * 86400) >= ?
++ AND NOT EXISTS (SELECT 1 FROM stream_recaps r WHERE r.stream_id = s.id)
++ ORDER BY s.ended_at ASC LIMIT 6`, [`-${LOOKBACK_HOURS} hours`, `-${SETTLE_SEC} seconds`, MIN_DURATION_SEC]) || [];
++}
++
++function announce(recap) {
++ try {
++ const chat = require('../chat/chat-server');
++ const server = chat.chatServer || chat.default || chat;
++ if (!server || typeof server.broadcastToChannelRoom !== 'function') return false;
++ const g = recap.write.grade;
++ server.broadcastToChannelRoom(recap.streamer.id, recap.stream.id, { type: 'system', message: `📋 After-show report for "${recap.stream.title}" is in — grade ${g}: ${recap.write.headline}. Read it: /recap/${recap.stream.id}` });
++ db.run('UPDATE stream_recaps SET announced_at = CURRENT_TIMESTAMP WHERE stream_id = ?', [recap.stream.id]);
++ return true;
++ } catch (e) { console.warn('[Recap] announce:', e.message); return false; }
++}
++
++let _timer = null, _busy = false;
++async function tick() {
++ if (_busy) return;
++ _busy = true;
++ try {
++ for (const row of pending()) {
++ try {
++ const recap = await buildRecap(row.id);
++ if (recap) { announce(recap); console.log(`[Recap] stream ${row.id} (${recap.streamer.username}): grade ${recap.write.grade}${recap.ai ? '' : ' (template)'} — ${recap.write.headline}`); }
++ } catch (e) { console.warn(`[Recap] stream ${row.id}:`, e.message); }
++ }
++ } finally { _busy = false; }
++}
++function start() {
++ if (_timer) return;
++ ensureTable();
++ setTimeout(() => tick().catch(() => {}), 45 * 1000);
++ _timer = setInterval(() => tick().catch(e => console.warn('[Recap] job:', e.message)), JOB_INTERVAL_MS);
++ if (_timer.unref) _timer.unref();
++ console.log('[Recap] after-show reports started (every 2 min, streams ≥ 8 min)');
++}
++
++module.exports = { buildRecap, getRecap, ensureRecap, listRecaps, pending, announce, start, tick, gather, templateWriteup, MIN_DURATION_SEC, ensureTable };
ddiff --git a/server/recap/routes.js b/server/recap/routes.js
nnew file mode 100644
iindex 00000000..eace86a5
---- /dev/null
++++ b/server/recap/routes.js
@@@ -0,0 +1,55 @@
++/**
++ * /api/recap — the after-show report (server/recap/recap.js).
++ * GET /:streamId the recap (built on first view if the stream is over and long enough)
++ * GET /channel/:username latest recaps for a channel
++ * POST /:streamId/regenerate owner / admin: rebuild it (fresh AI write-up)
++ */
++'use strict';
++const express = require('express');
++const router = express.Router();
++const db = require('../db/database');
++const recap = require('./recap');
++const { requireAuth, optionalAuth } = require('../auth/auth');
++
++router.get('/channel/:username', (req, res) => {
++ const user = db.getUserByUsername(String(req.params.username || ''));
++ if (!user) return res.status(404).json({ error: 'Channel not found' });
++ res.set('Cache-Control', 'public, max-age=60');
++ res.json({ recaps: recap.listRecaps(user.id, Math.min(12, parseInt(req.query.limit || '6', 10) || 6)) });
++});
++
++router.get('/:streamId', optionalAuth, async (req, res) => {
++ try {
++ if (!/^\d+$/.test(String(req.params.streamId))) return res.status(404).json({ error: 'Not found' });
++ const id = parseInt(req.params.streamId, 10);
++ const stream = db.getStreamById(id);
++ if (!stream) return res.status(404).json({ error: 'Stream not found' });
++ if (stream.is_live) return res.status(409).json({ error: 'Still live — the report comes after the stream ends', live: true });
++ let r = recap.getRecap(id);
++ if (!r) {
++ const dur = Number(stream.duration_seconds) || 0;
++ if (dur && dur < recap.MIN_DURATION_SEC) return res.status(404).json({ error: 'Too short for a report', min_seconds: recap.MIN_DURATION_SEC });
++ r = await recap.buildRecap(id);
++ }
++ if (!r) return res.status(404).json({ error: 'No report for this stream' });
++ res.set('Cache-Control', 'public, max-age=120');
++ res.json({ recap: r, more: recap.listRecaps(r.streamer.id, 6).filter(x => x.stream_id !== id) });
++ } catch (err) {
++ console.error('[Recap] route:', err.message);
++ res.status(500).json({ error: 'Failed to build the report' });
++ }
++});
++
++router.post('/:streamId/regenerate', requireAuth, async (req, res) => {
++ try {
++ const id = parseInt(req.params.streamId, 10);
++ const stream = db.getStreamById(id);
++ if (!stream) return res.status(404).json({ error: 'Stream not found' });
++ if (stream.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Not your stream' });
++ const r = await recap.buildRecap(id);
++ if (!r) return res.status(409).json({ error: 'Stream is live or has no data yet' });
++ res.json({ recap: r });
++ } catch (err) { res.status(500).json({ error: err.message }); }
++});
++
++module.exports = router;
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex e1443245..f4866f2a 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -116,6 +116,7 @@ async function _pageMeta(routePath) {
if ((m = p.match(/^\/clip\/(\d+)$/))) return _clipMeta(parseInt(m[1], 10));
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);
if ((m = p.match(/^\/@([A-Za-z0-9_.-]+)$/))) return _channelMeta(m[1]);
++ if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
return null;
void bu;
@@@ -155,6 +156,20 @@ function _channelMeta(username) {
};
}
++// After-show report: /recap/:streamId
++function _recapMeta(streamId) {
++ let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }
++ if (!r) return null;
++ const name = r.streamer.display_name || r.streamer.username;
++ const title = clean(`${r.write.headline || r.stream.title}`, 90);
++ const desc = clean(r.write.summary || `After-show report for ${name}'s stream "${r.stream.title}" on ${SITE_NAME}.`, 200);
++ const image = r.vod && r.vod.thumbnail_url ? media.publicUrl(r.vod.thumbnail_url) : (r.streamer.avatar_url ? abs(r.streamer.avatar_url) : DEFAULT_OG_IMAGE);
++ const canonicalPath = `/recap/${streamId}`;
++ const article = { '@context': 'https://schema.org', '@type': 'Article', headline: title, description: desc, image: [image], datePublished: isoDate(r.stream.ended_at) || undefined, author: _authorLd(name), publisher: { '@type': 'Organization', name: SITE_NAME, url: baseUrl() } };
++ const snapshot = _detailSnapshot({ title: `After-show report: ${r.stream.title}`, byline: `${name} · ${Math.round((r.stream.duration_seconds || 0) / 60)} min · grade ${r.write.grade}`, desc, overview: r.write.summary, transcript: null, canonicalPath, watchLabel: 'Read the report' });
++ return { title: `${title} — ${name}'s stream report | ${SITE_NAME}`, description: desc, canonicalPath, image, ogType: 'article', robots: 'index,follow', jsonLd: [article, _breadcrumb([{ name: 'Home', url: '/' }, { name, url: `/@${r.streamer.username}` }, { name: 'Report', url: canonicalPath }])], snapshot };
++}
++
// A crawlable <section> listing media items with real detail (title, streamer, meta).
function _mediaSection(heading, items) {
if (!items || !items.length) return '';
@@@ -410,7 +425,7 @@ function _cacheSet(key, html) {
_cache.set(key, { html, at: Date.now() });
}
--const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|vod\/\d+$|clip\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
++const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
async function middleware(req, res, next) {
if (req.method !== 'GET') return next();
ddiff --git a/test/recap.test.js b/test/recap.test.js
nnew file mode 100644
iindex 00000000..75ca6a04
---- /dev/null
++++ b/test/recap.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++// After-show report (server/recap/recap.js) on a temp DB with AI off → template write-up.
++const assert = require('assert');
++const fs = require('fs'); const os = require('os'); const path = require('path');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-recap-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++const db = require('../server/db/database'); db.initDb();
++const recap = require('../server/recap/recap');
++try { require('../server/arena/mic').ensureTables(); } catch { /* mic table optional */ }
++
++const mk = (username) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), stream_key: username.padEnd(32, '0') }).lastInsertRowid);
++const host = mk('hostess'), fan = mk('fan1'), fan2 = mk('fan2');
++const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().replace('T', ' ').slice(0, 19);
++db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'Late night tinkering', 'irl', 'webrtc', ts(70), ts(10), 3600, 9]);
++const sid = db.get('SELECT id FROM streams ORDER BY id DESC LIMIT 1').id;
++for (let i = 0; i < 12; i++) db.run('INSERT INTO viewer_snapshots (stream_id, viewer_count, chat_messages_5m, recorded_at) VALUES (?,?,?,?)', [sid, 2 + Math.round(7 * Math.sin(i / 11 * Math.PI)), i === 6 ? 14 : 3, ts(70 - i * 5)]);
++for (let i = 0; i < 9; i++) db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, fan, 'fan1', `msg ${i}`, ts(60 - i)]);
++for (let i = 0; i < 4; i++) db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, fan2, 'fan2', `yo ${i}`, ts(50 - i)]);
++db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, host, 'hostess', 'hi chat', ts(55)]);
++db.run("INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)", [fan2, host, ts(30)]);
++db.run("INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)", [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);
++db.run("INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status) VALUES (?,?,?,?,?,?)", [fan, host, sid, 5, 'donation', 'completed']);
++// Too-short stream: no report from the job.
++db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'oops', 'irl', 'webrtc', ts(9), ts(5), 240, 1]);
++
++(async () => {
++ const pend = recap.pending().map(r => r.id);
++ assert.deepStrictEqual(pend, [sid], 'only the long-enough, settled stream is pending');
++ const r = await recap.buildRecap(sid);
++ assert.ok(r && r.write && r.write.headline, 'has a write-up');
++ assert.strictEqual(r.ai, false, 'AI off → template');
++ assert.strictEqual(r.stream.peak_viewers, 9);
++ assert.strictEqual(r.chat.messages, 14);
++ assert.strictEqual(r.chat.chatters, 3);
++ assert.strictEqual(r.chat.top[0].username, 'fan1', 'host excluded from top chatters, fan1 leads');
++ assert.strictEqual(r.chat.top[0].n, 9);
++ assert.strictEqual(r.love.follows, 1);
++ assert.strictEqual(r.love.tips, 1); assert.strictEqual(r.love.tips_total, 5);
++ assert.strictEqual(r.mic.length, 1);
++ assert.strictEqual(r.viewers.curve.length, 12);
++ assert.ok(r.chat.busiest_n === 14, 'busiest 5-min window from snapshots');
++ assert.ok(['S', 'A', 'B', 'C'].includes(r.write.grade));
++ assert.ok(/hostess/i.test(r.write.headline) || /Hostess/.test(r.write.summary));
++ // Stored + listed + no longer pending.
++ assert.ok(recap.getRecap(sid).write.headline === r.write.headline);
++ assert.strictEqual(recap.listRecaps(host).length, 1);
++ assert.deepStrictEqual(recap.pending(), []);
++ // Live streams never get one.
++ db.run('UPDATE streams SET is_live = 1, ended_at = NULL WHERE id = ?', [sid]);
++ assert.strictEqual(await recap.gather(sid), null);
++ console.log('recap: OK');
++})().catch(e => { console.error(e); process.exit(1); });