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
Changes
nnew file mode 100644@@@ -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`.nnew file mode 100644@@@ -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; }++}@@@ -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; }@@@ -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>@@@ -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' : ''));nnew file mode 100644@@@ -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);++ };++})();@@@ -319,7 +319,7 @@ router.get('/digest', optionalAuth, (req, res) => {FROM streams s JOIN users u ON u.id = s.user_idWHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0GROUP 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);@@@ -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); }nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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;@@@ -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();nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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; }
+}
@@ -2294,6 +2294,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -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 { 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-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 { 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 { 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 i { color: #f472b6; margin-right: 4px; font-style: normal; }
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- 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/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">
@@ -71,6 +71,7 @@
@@ -71,6 +71,7 @@
<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=4">
<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">
<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 -->
@@ -3520,6 +3521,12 @@ WantedBy=multi-user.target</code>
@@ -3520,6 +3521,12 @@ WantedBy=multi-user.target</code>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ARENA — streamer vs streamer (public/js/arena.js) -->
<!-- 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">
<section id="page-arena" class="page">
<div class="content-container arena-container">
<div class="content-container arena-container">
<div id="arena-root">
<div id="arena-root">
@@ -5046,7 +5053,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5046,7 +5053,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=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-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>
@@ -5119,6 +5126,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/arena.js?v=18" 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/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
@@ -58,7 +58,7 @@ function isStaffUser(user = currentUser) {
@@ -58,7 +58,7 @@ function isStaffUser(user = currentUser) {
}
}
// Reserved paths (not usernames)
// 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}$/;
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
function normalizeChannelUsername(username) {
function normalizeChannelUsername(username) {
@@ -990,6 +990,10 @@ function routeFromURL() {
@@ -990,6 +990,10 @@ function routeFromURL() {
});
});
}).catch(() => {});
}).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') {
} else if (segments[0] === 'arena') {
showPage('arena');
showPage('arena');
if (typeof loadArenaPage === 'function') loadArenaPage(segments);
if (typeof loadArenaPage === 'function') loadArenaPage(segments);
@@ -1882,7 +1886,7 @@ async function loadHomeDigest() {
@@ -1882,7 +1886,7 @@ async function loadHomeDigest() {
<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>' : ''}
${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 = [];
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.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' : ''));
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' : ''));
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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);
+ };
+})();
@@ -319,7 +319,7 @@ router.get('/digest', optionalAuth, (req, res) => {
@@ -319,7 +319,7 @@ router.get('/digest', optionalAuth, (req, res) => {
FROM streams s JOIN users u ON u.id = s.user_id
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
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]) || [])
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);
.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 one = (sql, params) => { try { return get(sql, params); } catch { return null; } };
const get = (sql, params) => db.get(sql, params);
const get = (sql, params) => db.get(sql, params);
@@ -588,6 +588,7 @@ app.use('/api/clips', clipRoutes);
@@ -588,6 +588,7 @@ app.use('/api/clips', clipRoutes);
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-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/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/comments', commentRoutes);
app.use('/api/controls', controlRoutes);
app.use('/api/controls', controlRoutes);
app.use('/api/onvif', onvifRoutes);
app.use('/api/onvif', onvifRoutes);
@@ -1134,6 +1135,7 @@ async function start() {
@@ -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/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('./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('./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
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// (default off), so starting it is a no-op until switched on in admin.
// (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); }
try { require('./ai/timeline-job').start(); } catch (e) { console.warn('[AI] timeline job not started:', e.message); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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;
@@ -116,6 +116,7 @@ async function _pageMeta(routePath) {
@@ -116,6 +116,7 @@ async function _pageMeta(routePath) {
if ((m = p.match(/^\/clip\/(\d+)$/))) return _clipMeta(parseInt(m[1], 10));
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(/^\/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(/^\/@([A-Za-z0-9_.-]+)$/))) return _channelMeta(m[1]);
+ if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
return null;
return null;
void bu;
void bu;
@@ -155,6 +156,20 @@ function _channelMeta(username) {
@@ -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).
// A crawlable <section> listing media items with real detail (title, streamer, meta).
function _mediaSection(heading, items) {
function _mediaSection(heading, items) {
if (!items || !items.length) return '';
if (!items || !items.length) return '';
@@ -410,7 +425,7 @@ function _cacheSet(key, html) {
@@ -410,7 +425,7 @@ function _cacheSet(key, html) {
_cache.set(key, { html, at: Date.now() });
_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) {
async function middleware(req, res, next) {
if (req.method !== 'GET') return next();
if (req.method !== 'GET') return next();
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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); });