openvibers/openvibe.live Commits
The Rundown's front page: a top take with its quote set large, then stream by stream (each stream with its episode title and logline, or Live now, and its 3 best takes on a timeline), in place of a 14,500px river of full cards in which one stream filled the first screens (#262)
Changes
@@@ -46,7 +46,8 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu- `/rundown`, the front page:- the masthead (date, counts) and the "On air now" strip, showing who is live and what they are talking about;-- - **The latest:** episodes and the best takes (score 4 and up, or naming someone), newest first;++ - **Top take:** the best take of the last day (else the week), its quote set large;++ - **Stream by stream:** each stream the desk wrote about, newest first, with its episode title and logline (or, live, a Live now tag) and its 3 best takes on a timeline: the moment's timestamp (▶ to the VOD), the topic, the headline, the quote (else what they talked about), the desk's quip and reactions. "Read the episode" opens the rest. 8 streams a page (`/api/rundown/streams?before=`);- **Trending topics** this week (most streamers, then most takes);- **Who's been talking:** each streamer with their top topics, and how the desk works.- `/rundown/topics` lists every topic, by most recent or most talked about, with a filter by kind.@@@ -66,8 +67,9 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu| Method | Path | Notes ||---|---|---|--| GET | `/` | `on_air`, `latest` (+ `next`), `trending`, `voices`, `counts` |--| GET | `/latest?before=` | the next page of the latest |++| GET | `/` | `on_air`, `lead`, `streams` (+ `streams_next`), `trending`, `voices`, `counts`; `latest` (+ `next`) for the previous release's open tabs |++| GET | `/streams?before=` | the next page of stream by stream |++| GET | `/latest?before=` | the previous release's feed (episodes and takes scored 4 and up, newest first) || GET | `/topics?sort=recent\|most&kind=` | every topic || GET | `/t/:slug` | `topic`, `takes`, `related` || GET | `/e/:streamId` | `episode`, `stream`, `segments`, `more` |@@@ -89,6 +89,45 @@.rd-read { font-weight: 800; text-decoration: none; }.rd-episode { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }++/* The top take */++.rd-lead { margin: 4px 0 8px; padding: 18px 20px; border-radius: 18px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));++ background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 14%, var(--bg-card)), var(--bg-card) 60%); }++.rd-lead .rd-card-top { justify-content: flex-start; flex-wrap: wrap; }++.rd-lead-quote { margin: 4px 0 12px; }++.rd-lead-quote p { margin: 0; font-size: clamp(1.35rem, 3.2vw, 1.9rem); line-height: 1.25; font-weight: 800; letter-spacing: -0.01em; }++.rd-lead-quote .rd-quote-en { font-size: 1rem; font-weight: 500; }++.rd-lead-title { margin: 0 0 6px; font-size: 1.12rem; font-weight: 800; }++.rd-lead-by { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; color: var(--text-secondary); font-size: 0.9rem; }++.rd-lead-by time { color: var(--text-muted); }++++/* Stream by stream */++.rd-issue { margin: 0 0 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); overflow: hidden; }++.rd-issue.is-live { border-color: color-mix(in srgb, var(--live-red, #ef4444) 55%, var(--border)); }++.rd-issue-head { display: flex; gap: 12px; padding: 14px 16px 12px; border-bottom: 1px solid var(--border); }++.rd-issue-id { min-width: 0; }++.rd-issue-meta { margin: 0 0 2px; font-size: 0.86rem; color: var(--text-secondary); }++.rd-issue-meta time { color: var(--text-muted); }++.rd-issue-head .rd-logline { margin: 4px 0 0; font-size: 0.96rem; color: var(--text-secondary); }++.rd-live-tag { display: inline-block; padding: 1px 7px; border-radius: 6px; background: var(--live-red, #ef4444); color: #fff; font-size: 0.7rem; font-weight: 900;++ letter-spacing: 0.08em; text-transform: uppercase; vertical-align: 1px; }++.rd-briefs { padding: 4px 16px; }++.rd-brief { display: grid; grid-template-columns: 4.6em minmax(0, 1fr); gap: 10px; padding: 12px 0; border-top: 1px dashed var(--border); }++.rd-brief:first-child { border-top: 0; }++.rd-brief-when { padding-top: 2px; }++.rd-brief-play { display: inline-flex; align-items: center; gap: 5px; text-decoration: none; font-size: 0.82rem; }++.rd-brief-play i { font-size: 0.66rem; }++.rd-brief-when .rd-stamp { min-width: 0; font-size: 0.84rem; }++.rd-brief-chip { margin: 0 0 6px; }++.rd-brief-title { margin: 0 0 4px; font-size: 1.04rem; line-height: 1.3; font-weight: 800; }++.rd-brief-quote { margin: 0 0 4px; font-size: 1rem; font-style: italic; line-height: 1.45; }++.rd-brief-quote .rd-speak { width: 24px; height: 24px; margin-left: 4px; vertical-align: -3px; font-size: 0.7rem; font-style: normal; }++.rd-brief-what { margin: 0 0 4px; color: var(--text-secondary); line-height: 1.5; }++.rd-brief .rd-quip { margin-top: 4px; font-size: 0.92rem; }++.rd-brief .rd-react { margin-top: 8px; }++.rd-brief .rd-react-btn { min-height: 28px; padding: 2px 8px; font-size: 0.82rem; }++.rd-issue-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--border);++ font-size: 0.88rem; color: var(--text-secondary); }++/* Chapters */.rd-chapters { margin: 6px 0 10px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; font-size: 0.92rem; }.rd-chapters li { display: flex; gap: 10px; align-items: baseline; }@@@ -161,6 +200,11 @@}@media (max-width: 560px) {.rd-card { padding: 12px 13px; }++ .rd-lead { padding: 14px; }++ .rd-issue-head { padding: 12px 13px 10px; }++ .rd-briefs { padding: 2px 13px; }++ .rd-brief { grid-template-columns: minmax(0, 1fr); gap: 4px; }++ .rd-issue-foot { padding: 10px 13px; }.rd-take-who { display: none; }.rd-h3 { font-size: 1.08rem; }.rd-onair-item { min-width: 200px; }@@@ -2,7 +2,7 @@* OpenVibe.Live — The Rundown: what streamers talked about, written up (server/rundown/, docs/rundown.md).** Routes (loadRundownPage(segments) from app.js):-- * /rundown the front page: on air now, the latest episodes and takes, trending topics, who's been talking++ * /rundown the front page: on air now, the top take, stream by stream, trending topics, who's been talking* /rundown/topics every topic* /rundown/t/<slug> one topic and every take on it* /rundown/e/<streamId> one stream written up as an episode@@@ -117,7 +117,53 @@ function _rdEpisodeCard(e, { user = true } = {}) {<div class="rd-card-foot">${_rdReactions('episode', e.stream_id, e.reactions, e.my_reaction)}${_rdA(href, 'Read the episode <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>', 'rd-read')}</div></article>`;}--function _rdItem(x) { return x.type === 'episode' ? _rdEpisodeCard(x) : _rdTake(x); }++// The front page's top take: the quote leads, set large.++function _rdLead(s) {++ const q = (s.quotes || [])[0];++ return `<article class="rd-lead" id="rd-take-${s.id}">++ <div class="rd-card-top"><span class="rd-kicker">Top take</span>${_rdTopicChip(s.topic)}</div>++ ${q ? `<blockquote class="rd-lead-quote"><p>“${_rdEsc(q.text)}”</p>${q.en ? `<p class="rd-quote-en">${_rdEsc(q.en)}</p>` : ''}</blockquote>` : ''}++ <h3 class="rd-lead-title">${_rdEsc(s.title)}</h3>++ <p class="rd-lead-by">${_rdAv(s.user, 28)} ${_rdUser(s.user)} <time datetime="${_rdEsc(s.said_at)}">${_rdEsc(_rdAgo(s.said_at))}</time>${q ? ` ${_rdSpeakBtn(q.text, s.user)}` : ''}</p>++ <p class="rd-what">${_rdEsc(s.what)}</p>++ ${s.quip ? `<p class="rd-quip"><span class="rd-desk">The desk</span> ${_rdEsc(s.quip)}</p>` : ''}++ <div class="rd-card-foot">${_rdReactions('segment', s.id, s.reactions, s.my_reaction)}${_rdWatch(s.vod_id, q ? q.sec : s.sec)}</div>++ </article>`;++}++// One take inside a stream's block: on a timeline by when it was said, its quote (else what they talked about) and the quip.++function _rdBrief(s) {++ const q = (s.quotes || [])[0];++ const mentions = (s.mentions || []).map(_rdUser).join(', ');++ const stamp = `<span class="rd-stamp">${_rdStamp(s.sec)}</span>`;++ return `<article class="rd-brief" id="rd-take-${s.id}">++ <div class="rd-brief-when">${s.vod_id ? _rdA(videoHref('vod', s.vod_id, s.sec), `<i class="fa-solid fa-play" aria-hidden="true"></i>${stamp}`, 'rd-brief-play', `Watch from ${_rdStamp(s.sec)}`) : stamp}</div>++ <div class="rd-brief-body">++ ${s.topic ? `<div class="rd-brief-chip">${_rdTopicChip(s.topic)}</div>` : ''}++ <h4 class="rd-brief-title">${_rdEsc(s.title)}</h4>++ ${q ? `<p class="rd-brief-quote">“${_rdEsc(q.text)}” ${_rdSpeakBtn(q.text, s.user)}</p>${q.en ? `<p class="rd-quote-en">${_rdEsc(q.en)}</p>` : ''}` : `<p class="rd-brief-what">${_rdEsc(s.what)}</p>`}++ ${s.quip ? `<p class="rd-quip"><span class="rd-desk">The desk</span> ${_rdEsc(s.quip)}</p>` : ''}++ ${mentions ? `<p class="rd-cross"><i class="fa-solid fa-arrows-left-right" aria-hidden="true"></i> Brought up ${mentions}</p>` : ''}++ ${_rdReactions('segment', s.id, s.reactions, s.my_reaction)}++ </div>++ </article>`;++}++// A stream on the front page: its episode (or, live, what it is so far) and its best takes.++function _rdIssue(g) {++ const href = `/rundown/e/${g.stream_id}`;++ const e = g.episode;++ const title = (e && e.title) || g.title || `${_rdName(g.user)}'s stream`;++ const when = g.live ? '<span class="rd-live-tag">Live now</span>' : `<time datetime="${_rdEsc(g.started_at)}">${_rdEsc(_rdDay(g.started_at))}</time>${g.minutes ? ` · ${_rdLength(g.minutes)}` : ''}`;++ const more = g.takes_total - g.takes.length;++ return `<section class="rd-issue${g.live ? ' is-live' : ''}" aria-labelledby="rd-issue-${g.stream_id}">++ <header class="rd-issue-head">${_rdAv(g.user, 44)}<div class="rd-issue-id">++ <p class="rd-issue-meta">${_rdUser(g.user)} · ${when}</p>++ <h3 class="rd-h3" id="rd-issue-${g.stream_id}">${_rdA(href, _rdEsc(title))}</h3>++ ${e && e.logline ? `<p class="rd-logline">${_rdEsc(e.logline)}</p>` : ''}++ </div></header>++ <div class="rd-briefs">${g.takes.map(_rdBrief).join('')}</div>++ <footer class="rd-issue-foot">${more > 0 ? `<span>${more} more take${more === 1 ? '' : 's'}</span>` : '<span></span>'}${_rdA(href, `${e ? 'Read the episode' : g.live ? 'Everything so far' : 'Everything they said'} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>`, 'rd-read')}</footer>++ </section>`;++}// ── The front page ───────────────────────────────────────────function _rdMast(counts, active = 'latest') {@@@ -159,14 +205,17 @@ function _rdAbout() {}async function _rdHome(root) {const d = await api('/rundown');-- _rdNext = d.next;-- const feed = d.latest || [];++ _rdNext = d.streams_next;++ const feed = d.streams || [];root.innerHTML = `${_rdMast(d.counts, 'latest')}${_rdOnAir(d.on_air || [])}<div class="rd-grid">-- <section class="rd-main" aria-labelledby="rd-latest-h"><h2 id="rd-latest-h" class="rd-h2">The latest</h2>-- <div id="rd-feed">${feed.length ? feed.map(_rdItem).join('') : _rdEmpty('fa-newspaper', 'The desk is reading this week\'s streams. The first takes and episodes show up here within minutes.')}</div>-- ${_rdNext ? '<button type="button" class="btn btn-secondary rd-loadmore" data-rd-more="1">More</button>' : ''}-- </section>++ <div class="rd-main">++ ${d.lead ? _rdLead(d.lead) : ''}++ <section aria-labelledby="rd-latest-h"><h2 id="rd-latest-h" class="rd-h2">Stream by stream</h2>++ <div id="rd-feed">${feed.length ? feed.map(_rdIssue).join('') : _rdEmpty('fa-newspaper', 'The desk is reading this week\'s streams. The first takes and episodes show up here within minutes.')}</div>++ ${_rdNext ? '<button type="button" class="btn btn-secondary rd-loadmore" data-rd-more="1">More streams</button>' : ''}++ </section>++ </div><aside class="rd-side">${_rdTrending(d.trending || [])}${_rdVoices(d.voices || [])}${_rdAbout()}</aside></div>`;document.title = 'The Rundown · OpenVibe.Live';@@@ -175,9 +224,9 @@ async function _rdMore(btn) {if (!_rdNext) return;btn.disabled = true;try {-- const d = await api(`/rundown/latest?before=${encodeURIComponent(_rdNext)}`);++ const d = await api(`/rundown/streams?before=${encodeURIComponent(_rdNext)}`);_rdNext = d.next;-- document.getElementById('rd-feed')?.insertAdjacentHTML('beforeend', (d.items || []).map(_rdItem).join(''));++ document.getElementById('rd-feed')?.insertAdjacentHTML('beforeend', (d.streams || []).map(_rdIssue).join(''));if (!_rdNext) btn.remove(); else btn.disabled = false;} catch { btn.disabled = false; }}@@@ -3,7 +3,8 @@* What The Rundown's pages read (server/rundown/routes.js, docs/rundown.md). Rows come out through allow-listed shapes:* a person is { id, username, display_name, avatar_url, profile_color }, never a users row.*-- * home({ before, viewer }) on air now, the latest (episodes and the best segments), trending topics, who's been talking++ * home({ viewer }) on air now, the lead take, stream by stream, trending topics, who's been talking++ * streamsFeed({ before, viewer }) stream by stream: each recent stream with its episode and its best takes, newest first* topics({ sort, kind }) every topic* topic(slug) one topic and every take on it* episode(streamId) one stream written up, with its segments and the streamer's other episodes@@@ -14,6 +15,8 @@ const db = require('../db/database');const REACTIONS = ['fire', 'lol', 'dead', 'cap', 'facts'];const FEED_MIN_SCORE = 4; // the front page: a take scored 4 or 5, a crossover or an episode++const STREAM_TAKES = 3; // stream by stream: a stream's best takes on the front page (the episode page has them all)++const STREAMS_PAGE = 8;const parseJson = (s, d) => { try { return JSON.parse(s); } catch { return d; } };const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 19).replace('T', ' ');@@@ -122,11 +125,61 @@ async function latest({ before = null, limit = 30, viewer = null } = {}) {return { items, next: items.length >= limit && last ? (last.said_at || last.aired_at) : null };}--async function home({ before = null, viewer = null } = {}) {-- const [air, feed, topics, talk] = await Promise.all([before ? [] : onAir(), latest({ before, viewer }), before ? [] : trending(), before ? [] : voices()]);-- const counts = before ? null : await db.get(`SELECT (SELECT COUNT(*) FROM rundown_topics WHERE status = 'live') AS topics,++/** The lead: the best take of the last day (else the week), a quoted one first. */++async function lead({ viewer = null } = {}) {++ for (const days of [1, 7]) {++ const rows = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND said_at >= ? AND score >= ?++ ORDER BY score DESC, (quotes_json <> '[]') DESC, said_at DESC LIMIT 1`, [daysAgo(days), FEED_MIN_SCORE]);++ if (rows.length) return (await segmentsOut(rows, viewer))[0];++ }++ return null;++}++++/**++ * Stream by stream: the streams the desk wrote about, newest first, each with its episode (when it has ended and been++ * written up) and its best STREAM_TAKES takes in the order they were said. `skip` leaves out the lead take. Paged by the++ * time of a stream's latest take.++ */++async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null, skip = null } = {}) {++ const cut = before ? String(before).slice(0, 19) : '9999-12-31 00:00:00';++ const groups = await db.all(`SELECT stream_id, MAX(said_at) AS last_at, COUNT(*) AS takes FROM rundown_segments WHERE status = 'live'++ GROUP BY stream_id HAVING MAX(said_at) < ? ORDER BY MAX(said_at) DESC LIMIT ?`, [cut, limit]);++ const ids = groups.map((g) => Number(g.stream_id));++ if (!ids.length) return { streams: [], next: null };++ const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND stream_id = ANY(?)++ ORDER BY score DESC, cardinality(mentions) DESC, (quotes_json <> '[]') DESC, start_sec ASC`, [ids]);++ const best = new Map();++ for (const s of segs) {++ const l = best.get(Number(s.stream_id)) || [];++ if (l.length < STREAM_TAKES && Number(s.id) !== Number(skip)) l.push(s);++ best.set(Number(s.stream_id), l);++ }++ const chosen = [...best.values()].flat().sort((a, b) => Number(a.start_sec) - Number(b.start_sec));++ const takes = new Map();++ for (const t of await segmentsOut(chosen, viewer)) { const l = takes.get(t.stream_id) || []; l.push(t); takes.set(t.stream_id, l); }++ const eps = new Map((await episodesOut(await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE status = 'live' AND stream_id = ANY(?)`, [ids]), viewer))++ .map((e) => [e.stream_id, { title: e.title, logline: e.logline, minutes: e.minutes, aired_at: e.aired_at, topics: e.topics, reactions: e.reactions, my_reaction: e.my_reaction }]));++ const streams = new Map((await db.all('SELECT id, user_id, title, is_live, started_at, ended_at, duration_seconds FROM streams WHERE id = ANY(?)', [ids])).map((s) => [Number(s.id), s]));++ const ppl = await people([...streams.values()].map((s) => s.user_id));++ const out = groups.map((g) => {++ const s = streams.get(Number(g.stream_id));++ if (!s) return null;++ return { stream_id: Number(g.stream_id), user: ppl.get(Number(s.user_id)) || null, title: s.title || null, live: !!Number(s.is_live), started_at: s.started_at,++ minutes: s.duration_seconds ? Math.round(Number(s.duration_seconds) / 60) : null, last_at: g.last_at, takes_total: Number(g.takes),++ episode: eps.get(Number(g.stream_id)) || null, takes: takes.get(Number(g.stream_id)) || [] };++ }).filter((x) => x && x.user && x.takes.length);++ return { streams: out, next: groups.length >= limit ? groups[groups.length - 1].last_at : null };++}++++async function home({ viewer = null } = {}) {++ const top = await lead({ viewer });++ const [air, feed, topics, talk, old] = await Promise.all([onAir(), streamsFeed({ viewer, skip: top ? top.id : null }), trending(), voices(),++ // The previous release's front page reads latest and next; they go once no open tab runs it.++ latest({ viewer })]);++ const counts = await db.get(`SELECT (SELECT COUNT(*) FROM rundown_topics WHERE status = 'live') AS topics,(SELECT COUNT(*) FROM rundown_episodes WHERE status = 'live') AS episodes, (SELECT COUNT(*) FROM rundown_segments WHERE status = 'live') AS takes`);-- return { on_air: air, latest: feed.items, next: feed.next, trending: topics, voices: talk, counts: counts ? { topics: Number(counts.topics), episodes: Number(counts.episodes), takes: Number(counts.takes) } : null };++ return { on_air: air, lead: top, streams: feed.streams, streams_next: feed.next, trending: topics, voices: talk, latest: old.items, next: old.next,++ counts: { topics: Number(counts.topics), episodes: Number(counts.episodes), takes: Number(counts.takes) } };}const KINDS = ['news', 'games', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other'];@@@ -218,4 +271,4 @@ async function setOptOut(userId, out) {return { opted_out: !!out };}--module.exports = { home, latest, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, REACTIONS, KINDS, person };++module.exports = { home, latest, lead, streamsFeed, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, REACTIONS, KINDS, person };@@@ -39,6 +39,9 @@ const fresh = (res) => res.set('Cache-Control', 'no-store');router.get('/', optionalAuth, async (req, res) => {try { fresh(res); res.json(await read.home({ viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'The Rundown is unavailable'); }});++router.get('/streams', optionalAuth, async (req, res) => {++ try { fresh(res); res.json(await read.streamsFeed({ before: req.query.before ? String(req.query.before) : null, viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'Failed to load the streams'); }++});router.get('/latest', optionalAuth, async (req, res) => {try { fresh(res); res.json(await read.latest({ before: req.query.before ? String(req.query.before) : null, viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'Failed to load the latest'); }});@@@ -357,7 +357,7 @@ async function _rundownMeta(p) {let home = null;try { home = await read.home({}); } catch { home = null; }const topics = (home && home.trending || []).map((t) => t.title);-- const eps = (home && home.latest || []).filter((x) => x.type === 'episode');++ const eps = (home && home.streams || []).filter((x) => x.episode).map((x) => ({ title: x.episode.title, stream_id: x.stream_id }));const title = 'The Rundown — What Streamers Talked About';const desc = clean(`Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words.${topics.length ? ` Trending: ${topics.slice(0, 4).join(', ')}.` : ''}`, 200);return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/rundown', robots: 'index,follow',@@@ -148,6 +148,15 @@ assert.ok(!home.latest.some((x) => x.title === 'Ranked is pain'), 'a score-3 takassert.strictEqual(home.trending[0].slug, 'supermarket-fridges-hacked-in-france');assert.strictEqual(home.trending[0].streamers, 2);assert.deepStrictEqual(Object.keys(home.latest[0].user).sort(), ['avatar_url', 'display_name', 'id', 'profile_color', 'username'], 'a person is five fields, never a users row');++assert.ok(home.lead && home.lead.score >= 4, 'the lead is a take scored 4 or 5');++const fridgeNight = home.streams.find((x) => x.stream_id === s1);++assert.ok(fridgeNight && fridgeNight.episode && fridgeNight.episode.title === 'The Fridge Files', 'stream by stream: a stream carries its episode');++assert.ok(home.streams.every((x) => x.takes.length >= 1 && x.takes.length <= 3 && !x.takes.some((k) => k.id === home.lead.id)), 'at most 3 takes a stream, never the lead again');++assert.ok(home.streams.every((x) => x.takes.every((k, i) => i === 0 || x.takes[i - 1].sec <= k.sec)), 'a stream\'s takes in the order they were said');++assert.ok(home.streams.some((x) => x.live && x.user.username === 'iZoR_One' && !x.episode), 'a live stream shows what it has so far');++assert.deepStrictEqual(Object.keys(fridgeNight.user).sort(), ['avatar_url', 'display_name', 'id', 'profile_color', 'username']);++const older = await read.streamsFeed({ before: home.streams[0].last_at });++assert.ok(!older.streams.some((x) => x.stream_id === home.streams[0].stream_id), 'the next page starts after the first stream');assert.ok(home.on_air.some((x) => x.user.username === 'iZoR_One' && x.talking_about), 'on air, with what they are talking about');assert.ok(home.voices.some((v) => v.user.username === 'goosely' && v.top_topics.length));const t = await read.topic('supermarket-fridges-hacked-in-france');@@@ -173,6 +182,8 @@ await read.hide('segment', segs[1].id);await read.setOptOut(goose, true);assert.strictEqual((await read.streamer('goosely')).opted_out, true);assert.ok(!(await read.home({})).latest.some((x) => x.user && x.user.username === 'goosely'), 'out: nothing about them on the front page');++const outHome = await read.home({});++assert.ok(!outHome.streams.some((x) => x.user.username === 'goosely') && !(outHome.lead && outHome.lead.user.username === 'goosely'), 'out: not in stream by stream or the lead');await read.setOptOut(goose, false);assert.strictEqual((await db.get('SELECT status FROM rundown_segments WHERE id = ?', [segs[0].id])).status, 'live', 'back: their take is back');assert.strictEqual((await db.get('SELECT status FROM rundown_segments WHERE id = ?', [segs[1].id])).status, 'hidden', 'what staff took down stays down');@@@ -194,6 +205,9 @@ const base = `http://127.0.0.1:${server.address().port}/api/rundown`;try {const front = await (await fetch(`${base}/`)).json();assert.ok(Array.isArray(front.latest) && front.trending.length >= 1 && front.counts.episodes === 1);++ assert.ok(Array.isArray(front.streams) && front.streams.length >= 1, 'stream by stream');++ const page2 = await (await fetch(`${base}/streams?before=${encodeURIComponent(front.streams[0].last_at)}`)).json();++ assert.ok(Array.isArray(page2.streams) && 'next' in page2);const topic = await fetch(`${base}/t/supermarket-fridges-hacked-in-france`);assert.strictEqual(topic.status, 200);assert.strictEqual((await fetch(`${base}/t/no-such-topic`)).status, 404);
@@ -46,7 +46,8 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
@@ -46,7 +46,8 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
- `/rundown`, the front page:
- `/rundown`, the front page:
- the masthead (date, counts) and the "On air now" strip, showing who is live and what they are talking about;
- the masthead (date, counts) and the "On air now" strip, showing who is live and what they are talking about;
- - **The latest:** episodes and the best takes (score 4 and up, or naming someone), newest first;
+ - **Top take:** the best take of the last day (else the week), its quote set large;
+ - **Stream by stream:** each stream the desk wrote about, newest first, with its episode title and logline (or, live, a Live now tag) and its 3 best takes on a timeline: the moment's timestamp (▶ to the VOD), the topic, the headline, the quote (else what they talked about), the desk's quip and reactions. "Read the episode" opens the rest. 8 streams a page (`/api/rundown/streams?before=`);
- **Trending topics** this week (most streamers, then most takes);
- **Trending topics** this week (most streamers, then most takes);
- **Who's been talking:** each streamer with their top topics, and how the desk works.
- **Who's been talking:** each streamer with their top topics, and how the desk works.
- `/rundown/topics` lists every topic, by most recent or most talked about, with a filter by kind.
- `/rundown/topics` lists every topic, by most recent or most talked about, with a filter by kind.
@@ -66,8 +67,9 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
@@ -66,8 +67,9 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
| Method | Path | Notes |
| Method | Path | Notes |
|---|---|---|
|---|---|---|
-| GET | `/` | `on_air`, `latest` (+ `next`), `trending`, `voices`, `counts` |
-| GET | `/latest?before=` | the next page of the latest |
+| GET | `/` | `on_air`, `lead`, `streams` (+ `streams_next`), `trending`, `voices`, `counts`; `latest` (+ `next`) for the previous release's open tabs |
+| GET | `/streams?before=` | the next page of stream by stream |
+| GET | `/latest?before=` | the previous release's feed (episodes and takes scored 4 and up, newest first) |
| GET | `/topics?sort=recent\|most&kind=` | every topic |
| GET | `/topics?sort=recent\|most&kind=` | every topic |
| GET | `/t/:slug` | `topic`, `takes`, `related` |
| GET | `/t/:slug` | `topic`, `takes`, `related` |
| GET | `/e/:streamId` | `episode`, `stream`, `segments`, `more` |
| GET | `/e/:streamId` | `episode`, `stream`, `segments`, `more` |
@@ -89,6 +89,45 @@
@@ -89,6 +89,45 @@
.rd-read { font-weight: 800; text-decoration: none; }
.rd-read { font-weight: 800; text-decoration: none; }
.rd-episode { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
.rd-episode { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
+/* The top take */
+.rd-lead { margin: 4px 0 8px; padding: 18px 20px; border-radius: 18px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
+ background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 14%, var(--bg-card)), var(--bg-card) 60%); }
+.rd-lead .rd-card-top { justify-content: flex-start; flex-wrap: wrap; }
+.rd-lead-quote { margin: 4px 0 12px; }
+.rd-lead-quote p { margin: 0; font-size: clamp(1.35rem, 3.2vw, 1.9rem); line-height: 1.25; font-weight: 800; letter-spacing: -0.01em; }
+.rd-lead-quote .rd-quote-en { font-size: 1rem; font-weight: 500; }
+.rd-lead-title { margin: 0 0 6px; font-size: 1.12rem; font-weight: 800; }
+.rd-lead-by { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; color: var(--text-secondary); font-size: 0.9rem; }
+.rd-lead-by time { color: var(--text-muted); }
+
+/* Stream by stream */
+.rd-issue { margin: 0 0 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); overflow: hidden; }
+.rd-issue.is-live { border-color: color-mix(in srgb, var(--live-red, #ef4444) 55%, var(--border)); }
+.rd-issue-head { display: flex; gap: 12px; padding: 14px 16px 12px; border-bottom: 1px solid var(--border); }
+.rd-issue-id { min-width: 0; }
+.rd-issue-meta { margin: 0 0 2px; font-size: 0.86rem; color: var(--text-secondary); }
+.rd-issue-meta time { color: var(--text-muted); }
+.rd-issue-head .rd-logline { margin: 4px 0 0; font-size: 0.96rem; color: var(--text-secondary); }
+.rd-live-tag { display: inline-block; padding: 1px 7px; border-radius: 6px; background: var(--live-red, #ef4444); color: #fff; font-size: 0.7rem; font-weight: 900;
+ letter-spacing: 0.08em; text-transform: uppercase; vertical-align: 1px; }
+.rd-briefs { padding: 4px 16px; }
+.rd-brief { display: grid; grid-template-columns: 4.6em minmax(0, 1fr); gap: 10px; padding: 12px 0; border-top: 1px dashed var(--border); }
+.rd-brief:first-child { border-top: 0; }
+.rd-brief-when { padding-top: 2px; }
+.rd-brief-play { display: inline-flex; align-items: center; gap: 5px; text-decoration: none; font-size: 0.82rem; }
+.rd-brief-play i { font-size: 0.66rem; }
+.rd-brief-when .rd-stamp { min-width: 0; font-size: 0.84rem; }
+.rd-brief-chip { margin: 0 0 6px; }
+.rd-brief-title { margin: 0 0 4px; font-size: 1.04rem; line-height: 1.3; font-weight: 800; }
+.rd-brief-quote { margin: 0 0 4px; font-size: 1rem; font-style: italic; line-height: 1.45; }
+.rd-brief-quote .rd-speak { width: 24px; height: 24px; margin-left: 4px; vertical-align: -3px; font-size: 0.7rem; font-style: normal; }
+.rd-brief-what { margin: 0 0 4px; color: var(--text-secondary); line-height: 1.5; }
+.rd-brief .rd-quip { margin-top: 4px; font-size: 0.92rem; }
+.rd-brief .rd-react { margin-top: 8px; }
+.rd-brief .rd-react-btn { min-height: 28px; padding: 2px 8px; font-size: 0.82rem; }
+.rd-issue-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--border);
+ font-size: 0.88rem; color: var(--text-secondary); }
+
/* Chapters */
/* Chapters */
.rd-chapters { margin: 6px 0 10px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; font-size: 0.92rem; }
.rd-chapters { margin: 6px 0 10px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; font-size: 0.92rem; }
.rd-chapters li { display: flex; gap: 10px; align-items: baseline; }
.rd-chapters li { display: flex; gap: 10px; align-items: baseline; }
@@ -161,6 +200,11 @@
@@ -161,6 +200,11 @@
}
}
@media (max-width: 560px) {
@media (max-width: 560px) {
.rd-card { padding: 12px 13px; }
.rd-card { padding: 12px 13px; }
+ .rd-lead { padding: 14px; }
+ .rd-issue-head { padding: 12px 13px 10px; }
+ .rd-briefs { padding: 2px 13px; }
+ .rd-brief { grid-template-columns: minmax(0, 1fr); gap: 4px; }
+ .rd-issue-foot { padding: 10px 13px; }
.rd-take-who { display: none; }
.rd-take-who { display: none; }
.rd-h3 { font-size: 1.08rem; }
.rd-h3 { font-size: 1.08rem; }
.rd-onair-item { min-width: 200px; }
.rd-onair-item { min-width: 200px; }
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
* OpenVibe.Live — The Rundown: what streamers talked about, written up (server/rundown/, docs/rundown.md).
* OpenVibe.Live — The Rundown: what streamers talked about, written up (server/rundown/, docs/rundown.md).
*
*
* Routes (loadRundownPage(segments) from app.js):
* Routes (loadRundownPage(segments) from app.js):
- * /rundown the front page: on air now, the latest episodes and takes, trending topics, who's been talking
+ * /rundown the front page: on air now, the top take, stream by stream, trending topics, who's been talking
* /rundown/topics every topic
* /rundown/topics every topic
* /rundown/t/<slug> one topic and every take on it
* /rundown/t/<slug> one topic and every take on it
* /rundown/e/<streamId> one stream written up as an episode
* /rundown/e/<streamId> one stream written up as an episode
@@ -117,7 +117,53 @@ function _rdEpisodeCard(e, { user = true } = {}) {
@@ -117,7 +117,53 @@ function _rdEpisodeCard(e, { user = true } = {}) {
<div class="rd-card-foot">${_rdReactions('episode', e.stream_id, e.reactions, e.my_reaction)}${_rdA(href, 'Read the episode <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>', 'rd-read')}</div>
<div class="rd-card-foot">${_rdReactions('episode', e.stream_id, e.reactions, e.my_reaction)}${_rdA(href, 'Read the episode <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>', 'rd-read')}</div>
</article>`;
</article>`;
}
}
-function _rdItem(x) { return x.type === 'episode' ? _rdEpisodeCard(x) : _rdTake(x); }
+// The front page's top take: the quote leads, set large.
+function _rdLead(s) {
+ const q = (s.quotes || [])[0];
+ return `<article class="rd-lead" id="rd-take-${s.id}">
+ <div class="rd-card-top"><span class="rd-kicker">Top take</span>${_rdTopicChip(s.topic)}</div>
+ ${q ? `<blockquote class="rd-lead-quote"><p>“${_rdEsc(q.text)}”</p>${q.en ? `<p class="rd-quote-en">${_rdEsc(q.en)}</p>` : ''}</blockquote>` : ''}
+ <h3 class="rd-lead-title">${_rdEsc(s.title)}</h3>
+ <p class="rd-lead-by">${_rdAv(s.user, 28)} ${_rdUser(s.user)} <time datetime="${_rdEsc(s.said_at)}">${_rdEsc(_rdAgo(s.said_at))}</time>${q ? ` ${_rdSpeakBtn(q.text, s.user)}` : ''}</p>
+ <p class="rd-what">${_rdEsc(s.what)}</p>
+ ${s.quip ? `<p class="rd-quip"><span class="rd-desk">The desk</span> ${_rdEsc(s.quip)}</p>` : ''}
+ <div class="rd-card-foot">${_rdReactions('segment', s.id, s.reactions, s.my_reaction)}${_rdWatch(s.vod_id, q ? q.sec : s.sec)}</div>
+ </article>`;
+}
+// One take inside a stream's block: on a timeline by when it was said, its quote (else what they talked about) and the quip.
+function _rdBrief(s) {
+ const q = (s.quotes || [])[0];
+ const mentions = (s.mentions || []).map(_rdUser).join(', ');
+ const stamp = `<span class="rd-stamp">${_rdStamp(s.sec)}</span>`;
+ return `<article class="rd-brief" id="rd-take-${s.id}">
+ <div class="rd-brief-when">${s.vod_id ? _rdA(videoHref('vod', s.vod_id, s.sec), `<i class="fa-solid fa-play" aria-hidden="true"></i>${stamp}`, 'rd-brief-play', `Watch from ${_rdStamp(s.sec)}`) : stamp}</div>
+ <div class="rd-brief-body">
+ ${s.topic ? `<div class="rd-brief-chip">${_rdTopicChip(s.topic)}</div>` : ''}
+ <h4 class="rd-brief-title">${_rdEsc(s.title)}</h4>
+ ${q ? `<p class="rd-brief-quote">“${_rdEsc(q.text)}” ${_rdSpeakBtn(q.text, s.user)}</p>${q.en ? `<p class="rd-quote-en">${_rdEsc(q.en)}</p>` : ''}` : `<p class="rd-brief-what">${_rdEsc(s.what)}</p>`}
+ ${s.quip ? `<p class="rd-quip"><span class="rd-desk">The desk</span> ${_rdEsc(s.quip)}</p>` : ''}
+ ${mentions ? `<p class="rd-cross"><i class="fa-solid fa-arrows-left-right" aria-hidden="true"></i> Brought up ${mentions}</p>` : ''}
+ ${_rdReactions('segment', s.id, s.reactions, s.my_reaction)}
+ </div>
+ </article>`;
+}
+// A stream on the front page: its episode (or, live, what it is so far) and its best takes.
+function _rdIssue(g) {
+ const href = `/rundown/e/${g.stream_id}`;
+ const e = g.episode;
+ const title = (e && e.title) || g.title || `${_rdName(g.user)}'s stream`;
+ const when = g.live ? '<span class="rd-live-tag">Live now</span>' : `<time datetime="${_rdEsc(g.started_at)}">${_rdEsc(_rdDay(g.started_at))}</time>${g.minutes ? ` · ${_rdLength(g.minutes)}` : ''}`;
+ const more = g.takes_total - g.takes.length;
+ return `<section class="rd-issue${g.live ? ' is-live' : ''}" aria-labelledby="rd-issue-${g.stream_id}">
+ <header class="rd-issue-head">${_rdAv(g.user, 44)}<div class="rd-issue-id">
+ <p class="rd-issue-meta">${_rdUser(g.user)} · ${when}</p>
+ <h3 class="rd-h3" id="rd-issue-${g.stream_id}">${_rdA(href, _rdEsc(title))}</h3>
+ ${e && e.logline ? `<p class="rd-logline">${_rdEsc(e.logline)}</p>` : ''}
+ </div></header>
+ <div class="rd-briefs">${g.takes.map(_rdBrief).join('')}</div>
+ <footer class="rd-issue-foot">${more > 0 ? `<span>${more} more take${more === 1 ? '' : 's'}</span>` : '<span></span>'}${_rdA(href, `${e ? 'Read the episode' : g.live ? 'Everything so far' : 'Everything they said'} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>`, 'rd-read')}</footer>
+ </section>`;
+}
// ── The front page ───────────────────────────────────────────
// ── The front page ───────────────────────────────────────────
function _rdMast(counts, active = 'latest') {
function _rdMast(counts, active = 'latest') {
@@ -159,14 +205,17 @@ function _rdAbout() {
@@ -159,14 +205,17 @@ function _rdAbout() {
}
}
async function _rdHome(root) {
async function _rdHome(root) {
const d = await api('/rundown');
const d = await api('/rundown');
- _rdNext = d.next;
- const feed = d.latest || [];
+ _rdNext = d.streams_next;
+ const feed = d.streams || [];
root.innerHTML = `${_rdMast(d.counts, 'latest')}${_rdOnAir(d.on_air || [])}
root.innerHTML = `${_rdMast(d.counts, 'latest')}${_rdOnAir(d.on_air || [])}
<div class="rd-grid">
<div class="rd-grid">
- <section class="rd-main" aria-labelledby="rd-latest-h"><h2 id="rd-latest-h" class="rd-h2">The latest</h2>
- <div id="rd-feed">${feed.length ? feed.map(_rdItem).join('') : _rdEmpty('fa-newspaper', 'The desk is reading this week\'s streams. The first takes and episodes show up here within minutes.')}</div>
- ${_rdNext ? '<button type="button" class="btn btn-secondary rd-loadmore" data-rd-more="1">More</button>' : ''}
- </section>
+ <div class="rd-main">
+ ${d.lead ? _rdLead(d.lead) : ''}
+ <section aria-labelledby="rd-latest-h"><h2 id="rd-latest-h" class="rd-h2">Stream by stream</h2>
+ <div id="rd-feed">${feed.length ? feed.map(_rdIssue).join('') : _rdEmpty('fa-newspaper', 'The desk is reading this week\'s streams. The first takes and episodes show up here within minutes.')}</div>
+ ${_rdNext ? '<button type="button" class="btn btn-secondary rd-loadmore" data-rd-more="1">More streams</button>' : ''}
+ </section>
+ </div>
<aside class="rd-side">${_rdTrending(d.trending || [])}${_rdVoices(d.voices || [])}${_rdAbout()}</aside>
<aside class="rd-side">${_rdTrending(d.trending || [])}${_rdVoices(d.voices || [])}${_rdAbout()}</aside>
</div>`;
</div>`;
document.title = 'The Rundown · OpenVibe.Live';
document.title = 'The Rundown · OpenVibe.Live';
@@ -175,9 +224,9 @@ async function _rdMore(btn) {
@@ -175,9 +224,9 @@ async function _rdMore(btn) {
if (!_rdNext) return;
if (!_rdNext) return;
btn.disabled = true;
btn.disabled = true;
try {
try {
- const d = await api(`/rundown/latest?before=${encodeURIComponent(_rdNext)}`);
+ const d = await api(`/rundown/streams?before=${encodeURIComponent(_rdNext)}`);
_rdNext = d.next;
_rdNext = d.next;
- document.getElementById('rd-feed')?.insertAdjacentHTML('beforeend', (d.items || []).map(_rdItem).join(''));
+ document.getElementById('rd-feed')?.insertAdjacentHTML('beforeend', (d.streams || []).map(_rdIssue).join(''));
if (!_rdNext) btn.remove(); else btn.disabled = false;
if (!_rdNext) btn.remove(); else btn.disabled = false;
} catch { btn.disabled = false; }
} catch { btn.disabled = false; }
}
}
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
* What The Rundown's pages read (server/rundown/routes.js, docs/rundown.md). Rows come out through allow-listed shapes:
* What The Rundown's pages read (server/rundown/routes.js, docs/rundown.md). Rows come out through allow-listed shapes:
* a person is { id, username, display_name, avatar_url, profile_color }, never a users row.
* a person is { id, username, display_name, avatar_url, profile_color }, never a users row.
*
*
- * home({ before, viewer }) on air now, the latest (episodes and the best segments), trending topics, who's been talking
+ * home({ viewer }) on air now, the lead take, stream by stream, trending topics, who's been talking
+ * streamsFeed({ before, viewer }) stream by stream: each recent stream with its episode and its best takes, newest first
* topics({ sort, kind }) every topic
* topics({ sort, kind }) every topic
* topic(slug) one topic and every take on it
* topic(slug) one topic and every take on it
* episode(streamId) one stream written up, with its segments and the streamer's other episodes
* episode(streamId) one stream written up, with its segments and the streamer's other episodes
@@ -14,6 +15,8 @@ const db = require('../db/database');
@@ -14,6 +15,8 @@ const db = require('../db/database');
const REACTIONS = ['fire', 'lol', 'dead', 'cap', 'facts'];
const REACTIONS = ['fire', 'lol', 'dead', 'cap', 'facts'];
const FEED_MIN_SCORE = 4; // the front page: a take scored 4 or 5, a crossover or an episode
const FEED_MIN_SCORE = 4; // the front page: a take scored 4 or 5, a crossover or an episode
+const STREAM_TAKES = 3; // stream by stream: a stream's best takes on the front page (the episode page has them all)
+const STREAMS_PAGE = 8;
const parseJson = (s, d) => { try { return JSON.parse(s); } catch { return d; } };
const parseJson = (s, d) => { try { return JSON.parse(s); } catch { return d; } };
const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 19).replace('T', ' ');
const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 19).replace('T', ' ');
@@ -122,11 +125,61 @@ async function latest({ before = null, limit = 30, viewer = null } = {}) {
@@ -122,11 +125,61 @@ async function latest({ before = null, limit = 30, viewer = null } = {}) {
return { items, next: items.length >= limit && last ? (last.said_at || last.aired_at) : null };
return { items, next: items.length >= limit && last ? (last.said_at || last.aired_at) : null };
}
}
-async function home({ before = null, viewer = null } = {}) {
- const [air, feed, topics, talk] = await Promise.all([before ? [] : onAir(), latest({ before, viewer }), before ? [] : trending(), before ? [] : voices()]);
- const counts = before ? null : await db.get(`SELECT (SELECT COUNT(*) FROM rundown_topics WHERE status = 'live') AS topics,
+/** The lead: the best take of the last day (else the week), a quoted one first. */
+async function lead({ viewer = null } = {}) {
+ for (const days of [1, 7]) {
+ const rows = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND said_at >= ? AND score >= ?
+ ORDER BY score DESC, (quotes_json <> '[]') DESC, said_at DESC LIMIT 1`, [daysAgo(days), FEED_MIN_SCORE]);
+ if (rows.length) return (await segmentsOut(rows, viewer))[0];
+ }
+ return null;
+}
+
+/**
+ * Stream by stream: the streams the desk wrote about, newest first, each with its episode (when it has ended and been
+ * written up) and its best STREAM_TAKES takes in the order they were said. `skip` leaves out the lead take. Paged by the
+ * time of a stream's latest take.
+ */
+async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null, skip = null } = {}) {
+ const cut = before ? String(before).slice(0, 19) : '9999-12-31 00:00:00';
+ const groups = await db.all(`SELECT stream_id, MAX(said_at) AS last_at, COUNT(*) AS takes FROM rundown_segments WHERE status = 'live'
+ GROUP BY stream_id HAVING MAX(said_at) < ? ORDER BY MAX(said_at) DESC LIMIT ?`, [cut, limit]);
+ const ids = groups.map((g) => Number(g.stream_id));
+ if (!ids.length) return { streams: [], next: null };
+ const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND stream_id = ANY(?)
+ ORDER BY score DESC, cardinality(mentions) DESC, (quotes_json <> '[]') DESC, start_sec ASC`, [ids]);
+ const best = new Map();
+ for (const s of segs) {
+ const l = best.get(Number(s.stream_id)) || [];
+ if (l.length < STREAM_TAKES && Number(s.id) !== Number(skip)) l.push(s);
+ best.set(Number(s.stream_id), l);
+ }
+ const chosen = [...best.values()].flat().sort((a, b) => Number(a.start_sec) - Number(b.start_sec));
+ const takes = new Map();
+ for (const t of await segmentsOut(chosen, viewer)) { const l = takes.get(t.stream_id) || []; l.push(t); takes.set(t.stream_id, l); }
+ const eps = new Map((await episodesOut(await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE status = 'live' AND stream_id = ANY(?)`, [ids]), viewer))
+ .map((e) => [e.stream_id, { title: e.title, logline: e.logline, minutes: e.minutes, aired_at: e.aired_at, topics: e.topics, reactions: e.reactions, my_reaction: e.my_reaction }]));
+ const streams = new Map((await db.all('SELECT id, user_id, title, is_live, started_at, ended_at, duration_seconds FROM streams WHERE id = ANY(?)', [ids])).map((s) => [Number(s.id), s]));
+ const ppl = await people([...streams.values()].map((s) => s.user_id));
+ const out = groups.map((g) => {
+ const s = streams.get(Number(g.stream_id));
+ if (!s) return null;
+ return { stream_id: Number(g.stream_id), user: ppl.get(Number(s.user_id)) || null, title: s.title || null, live: !!Number(s.is_live), started_at: s.started_at,
+ minutes: s.duration_seconds ? Math.round(Number(s.duration_seconds) / 60) : null, last_at: g.last_at, takes_total: Number(g.takes),
+ episode: eps.get(Number(g.stream_id)) || null, takes: takes.get(Number(g.stream_id)) || [] };
+ }).filter((x) => x && x.user && x.takes.length);
+ return { streams: out, next: groups.length >= limit ? groups[groups.length - 1].last_at : null };
+}
+
+async function home({ viewer = null } = {}) {
+ const top = await lead({ viewer });
+ const [air, feed, topics, talk, old] = await Promise.all([onAir(), streamsFeed({ viewer, skip: top ? top.id : null }), trending(), voices(),
+ // The previous release's front page reads latest and next; they go once no open tab runs it.
+ latest({ viewer })]);
+ const counts = await db.get(`SELECT (SELECT COUNT(*) FROM rundown_topics WHERE status = 'live') AS topics,
(SELECT COUNT(*) FROM rundown_episodes WHERE status = 'live') AS episodes, (SELECT COUNT(*) FROM rundown_segments WHERE status = 'live') AS takes`);
(SELECT COUNT(*) FROM rundown_episodes WHERE status = 'live') AS episodes, (SELECT COUNT(*) FROM rundown_segments WHERE status = 'live') AS takes`);
- return { on_air: air, latest: feed.items, next: feed.next, trending: topics, voices: talk, counts: counts ? { topics: Number(counts.topics), episodes: Number(counts.episodes), takes: Number(counts.takes) } : null };
+ return { on_air: air, lead: top, streams: feed.streams, streams_next: feed.next, trending: topics, voices: talk, latest: old.items, next: old.next,
+ counts: { topics: Number(counts.topics), episodes: Number(counts.episodes), takes: Number(counts.takes) } };
}
}
const KINDS = ['news', 'games', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other'];
const KINDS = ['news', 'games', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other'];
@@ -218,4 +271,4 @@ async function setOptOut(userId, out) {
@@ -218,4 +271,4 @@ async function setOptOut(userId, out) {
return { opted_out: !!out };
return { opted_out: !!out };
}
}
-module.exports = { home, latest, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, REACTIONS, KINDS, person };
+module.exports = { home, latest, lead, streamsFeed, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, REACTIONS, KINDS, person };
@@ -39,6 +39,9 @@ const fresh = (res) => res.set('Cache-Control', 'no-store');
@@ -39,6 +39,9 @@ const fresh = (res) => res.set('Cache-Control', 'no-store');
router.get('/', optionalAuth, async (req, res) => {
router.get('/', optionalAuth, async (req, res) => {
try { fresh(res); res.json(await read.home({ viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'The Rundown is unavailable'); }
try { fresh(res); res.json(await read.home({ viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'The Rundown is unavailable'); }
});
});
+router.get('/streams', optionalAuth, async (req, res) => {
+ try { fresh(res); res.json(await read.streamsFeed({ before: req.query.before ? String(req.query.before) : null, viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'Failed to load the streams'); }
+});
router.get('/latest', optionalAuth, async (req, res) => {
router.get('/latest', optionalAuth, async (req, res) => {
try { fresh(res); res.json(await read.latest({ before: req.query.before ? String(req.query.before) : null, viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'Failed to load the latest'); }
try { fresh(res); res.json(await read.latest({ before: req.query.before ? String(req.query.before) : null, viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'Failed to load the latest'); }
});
});
@@ -357,7 +357,7 @@ async function _rundownMeta(p) {
@@ -357,7 +357,7 @@ async function _rundownMeta(p) {
let home = null;
let home = null;
try { home = await read.home({}); } catch { home = null; }
try { home = await read.home({}); } catch { home = null; }
const topics = (home && home.trending || []).map((t) => t.title);
const topics = (home && home.trending || []).map((t) => t.title);
- const eps = (home && home.latest || []).filter((x) => x.type === 'episode');
+ const eps = (home && home.streams || []).filter((x) => x.episode).map((x) => ({ title: x.episode.title, stream_id: x.stream_id }));
const title = 'The Rundown — What Streamers Talked About';
const title = 'The Rundown — What Streamers Talked About';
const desc = clean(`Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words.${topics.length ? ` Trending: ${topics.slice(0, 4).join(', ')}.` : ''}`, 200);
const desc = clean(`Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words.${topics.length ? ` Trending: ${topics.slice(0, 4).join(', ')}.` : ''}`, 200);
return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/rundown', robots: 'index,follow',
return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/rundown', robots: 'index,follow',
@@ -148,6 +148,15 @@ assert.ok(!home.latest.some((x) => x.title === 'Ranked is pain'), 'a score-3 tak
@@ -148,6 +148,15 @@ assert.ok(!home.latest.some((x) => x.title === 'Ranked is pain'), 'a score-3 tak
assert.strictEqual(home.trending[0].slug, 'supermarket-fridges-hacked-in-france');
assert.strictEqual(home.trending[0].slug, 'supermarket-fridges-hacked-in-france');
assert.strictEqual(home.trending[0].streamers, 2);
assert.strictEqual(home.trending[0].streamers, 2);
assert.deepStrictEqual(Object.keys(home.latest[0].user).sort(), ['avatar_url', 'display_name', 'id', 'profile_color', 'username'], 'a person is five fields, never a users row');
assert.deepStrictEqual(Object.keys(home.latest[0].user).sort(), ['avatar_url', 'display_name', 'id', 'profile_color', 'username'], 'a person is five fields, never a users row');
+assert.ok(home.lead && home.lead.score >= 4, 'the lead is a take scored 4 or 5');
+const fridgeNight = home.streams.find((x) => x.stream_id === s1);
+assert.ok(fridgeNight && fridgeNight.episode && fridgeNight.episode.title === 'The Fridge Files', 'stream by stream: a stream carries its episode');
+assert.ok(home.streams.every((x) => x.takes.length >= 1 && x.takes.length <= 3 && !x.takes.some((k) => k.id === home.lead.id)), 'at most 3 takes a stream, never the lead again');
+assert.ok(home.streams.every((x) => x.takes.every((k, i) => i === 0 || x.takes[i - 1].sec <= k.sec)), 'a stream\'s takes in the order they were said');
+assert.ok(home.streams.some((x) => x.live && x.user.username === 'iZoR_One' && !x.episode), 'a live stream shows what it has so far');
+assert.deepStrictEqual(Object.keys(fridgeNight.user).sort(), ['avatar_url', 'display_name', 'id', 'profile_color', 'username']);
+const older = await read.streamsFeed({ before: home.streams[0].last_at });
+assert.ok(!older.streams.some((x) => x.stream_id === home.streams[0].stream_id), 'the next page starts after the first stream');
assert.ok(home.on_air.some((x) => x.user.username === 'iZoR_One' && x.talking_about), 'on air, with what they are talking about');
assert.ok(home.on_air.some((x) => x.user.username === 'iZoR_One' && x.talking_about), 'on air, with what they are talking about');
assert.ok(home.voices.some((v) => v.user.username === 'goosely' && v.top_topics.length));
assert.ok(home.voices.some((v) => v.user.username === 'goosely' && v.top_topics.length));
const t = await read.topic('supermarket-fridges-hacked-in-france');
const t = await read.topic('supermarket-fridges-hacked-in-france');
@@ -173,6 +182,8 @@ await read.hide('segment', segs[1].id);
@@ -173,6 +182,8 @@ await read.hide('segment', segs[1].id);
await read.setOptOut(goose, true);
await read.setOptOut(goose, true);
assert.strictEqual((await read.streamer('goosely')).opted_out, true);
assert.strictEqual((await read.streamer('goosely')).opted_out, true);
assert.ok(!(await read.home({})).latest.some((x) => x.user && x.user.username === 'goosely'), 'out: nothing about them on the front page');
assert.ok(!(await read.home({})).latest.some((x) => x.user && x.user.username === 'goosely'), 'out: nothing about them on the front page');
+const outHome = await read.home({});
+assert.ok(!outHome.streams.some((x) => x.user.username === 'goosely') && !(outHome.lead && outHome.lead.user.username === 'goosely'), 'out: not in stream by stream or the lead');
await read.setOptOut(goose, false);
await read.setOptOut(goose, false);
assert.strictEqual((await db.get('SELECT status FROM rundown_segments WHERE id = ?', [segs[0].id])).status, 'live', 'back: their take is back');
assert.strictEqual((await db.get('SELECT status FROM rundown_segments WHERE id = ?', [segs[0].id])).status, 'live', 'back: their take is back');
assert.strictEqual((await db.get('SELECT status FROM rundown_segments WHERE id = ?', [segs[1].id])).status, 'hidden', 'what staff took down stays down');
assert.strictEqual((await db.get('SELECT status FROM rundown_segments WHERE id = ?', [segs[1].id])).status, 'hidden', 'what staff took down stays down');
@@ -194,6 +205,9 @@ const base = `http://127.0.0.1:${server.address().port}/api/rundown`;
@@ -194,6 +205,9 @@ const base = `http://127.0.0.1:${server.address().port}/api/rundown`;
try {
try {
const front = await (await fetch(`${base}/`)).json();
const front = await (await fetch(`${base}/`)).json();
assert.ok(Array.isArray(front.latest) && front.trending.length >= 1 && front.counts.episodes === 1);
assert.ok(Array.isArray(front.latest) && front.trending.length >= 1 && front.counts.episodes === 1);
+ assert.ok(Array.isArray(front.streams) && front.streams.length >= 1, 'stream by stream');
+ const page2 = await (await fetch(`${base}/streams?before=${encodeURIComponent(front.streams[0].last_at)}`)).json();
+ assert.ok(Array.isArray(page2.streams) && 'next' in page2);
const topic = await fetch(`${base}/t/supermarket-fridges-hacked-in-france`);
const topic = await fetch(`${base}/t/supermarket-fridges-hacked-in-france`);
assert.strictEqual(topic.status, 200);
assert.strictEqual(topic.status, 200);
assert.strictEqual((await fetch(`${base}/t/no-such-topic`)).status, 404);
assert.strictEqual((await fetch(`${base}/t/no-such-topic`)).status, 404);