openvibers/openvibe.live Commits

Give Rundown stream cards their public VOD or live picture (#522)

OP OpenVibers committed 12 days ago · e28b4c31a8 · 3 files, +163 −7

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex 5c4db20f..6818b168 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -78,7 +78,7 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
| GET, PUT | `/opt-out {out}` | signed in |
| GET | `/voice/:user?t=` | a line in the streamer's chat voice |
--A person in any answer is `{ id, username, display_name, avatar_url, profile_color }`.
++A person in any answer is `{ id, username, display_name, avatar_url, profile_color }`. A stream in `on_air` or `streams` also carries `thumbnail_url`: its public VOD's thumbnail once it has ended, its live frame while it is on air, and `null` for an NSFW stream or when Media has no picture.
## Data
ddiff --git a/server/rundown/read.js b/server/rundown/read.js
iindex a25af1c3..32c52b7d 100644
---- a/server/rundown/read.js
++++ b/server/rundown/read.js
@@@ -12,6 +12,7 @@
* react(type, id, voter, r) one reaction per reader per item (a reader is their account, else a salted address hash)
*/
const db = require('../db/database');
++const config = require('../config');
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
@@@ -20,6 +21,46 @@ 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', ' ');
++/** Live's public base, never localhost in a URL a browser would get. */
++function siteBase() {
++ let b = config.baseUrl || 'https://openvibe.live';
++ if (/localhost|127\.0\.0\.1/.test(b)) b = 'https://openvibe.live';
++ return b.replace(/\/+$/, '');
++}
++function absolute(url) {
++ if (!url) return null;
++ if (/^https?:\/\//i.test(url)) return url;
++ return `${siteBase()}${String(url).startsWith('/') ? '' : '/'}${url}`;
++}
++const isNsfw = (row) => !!(Number(row && row.is_nsfw) || Number(row && row.channel_force_nsfw));
++
++/**
++ * stream id → card picture. A live stream shows its live frame; an ended one asks Media for its public VOD's
++ * thumbnail (one batched, five-minute-cached lookup per call, media-proxy/lookups.js). NSFW channels and streams
++ * never get a picture, and a Media failure leaves the ended ones null instead of failing the page.
++ */
++async function thumbnailsFor(rows) {
++ const out = new Map();
++ const vodIds = [];
++ for (const r of rows || []) {
++ if (!r) continue;
++ const id = Number(r.id ?? r.stream_id);
++ if (!Number.isSafeInteger(id) || id <= 0) continue;
++ if (isNsfw(r)) { out.set(id, null); continue; }
++ if (Number(r.is_live)) { out.set(id, absolute(r.thumbnail_url)); continue; }
++ vodIds.push(id);
++ }
++ if (!vodIds.length) return out;
++ try {
++ const vods = await require('../media-proxy/lookups').publicVodsForStreams(vodIds);
++ const media = require('../media-client');
++ for (const id of vodIds) out.set(id, media.publicUrl(vods.get(id) && vods.get(id).thumbnail_url));
++ } catch {
++ for (const id of vodIds) out.set(id, null);
++ }
++ return out;
++}
++
function person(u) {
return u ? { id: Number(u.id), username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null } : null;
}
@@@ -88,13 +129,16 @@ const SEGMENT_COLS = 'id, stream_id, user_id, topic_id, start_sec, title, what,
const EPISODE_COLS = 'stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at';
async function onAir() {
-- const rows = await db.all(`SELECT s.id AS stream_id, s.user_id, s.title AS stream_title,
++ const rows = await db.all(`SELECT s.id AS stream_id, s.user_id, s.title AS stream_title, s.is_nsfw, s.thumbnail_url,
++ COALESCE(c.force_nsfw, 0) AS channel_force_nsfw,
(SELECT g.title FROM rundown_segments g WHERE g.stream_id = s.id AND g.status = 'live' ORDER BY g.start_sec DESC LIMIT 1) AS talking_about,
(SELECT t.slug FROM rundown_segments g JOIN rundown_topics t ON t.id = g.topic_id WHERE g.stream_id = s.id AND g.status = 'live' ORDER BY g.start_sec DESC LIMIT 1) AS topic_slug,
(SELECT t.title FROM rundown_segments g JOIN rundown_topics t ON t.id = g.topic_id WHERE g.stream_id = s.id AND g.status = 'live' ORDER BY g.start_sec DESC LIMIT 1) AS topic_title
-- FROM streams s WHERE s.is_live = 1 AND s.user_id NOT IN (SELECT user_id FROM arena_opt_outs) ORDER BY s.viewer_count DESC NULLS LAST LIMIT 12`);
++ FROM streams s LEFT JOIN channels c ON c.user_id = s.user_id
++ WHERE s.is_live = 1 AND s.user_id NOT IN (SELECT user_id FROM arena_opt_outs) ORDER BY s.viewer_count DESC NULLS LAST LIMIT 12`);
const ppl = await people(rows.map((r) => r.user_id));
-- return rows.map((r) => ({ stream_id: Number(r.stream_id), user: ppl.get(Number(r.user_id)) || null, stream_title: r.stream_title || null, talking_about: r.talking_about || null, topic: r.topic_slug ? { slug: r.topic_slug, title: r.topic_title } : null })).filter((r) => r.user);
++ const thumbs = await thumbnailsFor(rows.map((r) => ({ stream_id: r.stream_id, is_live: 1, is_nsfw: r.is_nsfw, channel_force_nsfw: r.channel_force_nsfw, thumbnail_url: r.thumbnail_url })));
++ return rows.map((r) => ({ stream_id: Number(r.stream_id), user: ppl.get(Number(r.user_id)) || null, stream_title: r.stream_title || null, thumbnail_url: thumbs.get(Number(r.stream_id)) || null, talking_about: r.talking_about || null, topic: r.topic_slug ? { slug: r.topic_slug, title: r.topic_title } : null })).filter((r) => r.user);
}
async function trending(limit = 12, days = 7) {
@@@ -152,13 +196,16 @@ async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null,
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 streams = new Map((await db.all(`SELECT s.id, s.user_id, s.title, s.is_live, s.started_at, s.ended_at, s.duration_seconds,
++ s.is_nsfw, s.thumbnail_url, COALESCE(c.force_nsfw, 0) AS channel_force_nsfw
++ FROM streams s LEFT JOIN channels c ON c.user_id = s.user_id WHERE s.id = ANY(?)`, [ids])).map((s) => [Number(s.id), s]));
const ppl = await people([...streams.values()].map((s) => s.user_id));
++ const thumbs = await thumbnailsFor([...streams.values()]);
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),
++ return { stream_id: Number(g.stream_id), user: ppl.get(Number(s.user_id)) || null, title: s.title || null, thumbnail_url: thumbs.get(Number(g.stream_id)) || 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 };
ddiff --git a/test/rundown-thumbnails.test.js b/test/rundown-thumbnails.test.js
nnew file mode 100644
iindex 00000000..8fc21a20
---- /dev/null
++++ b/test/rundown-thumbnails.test.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * The Rundown's stream cards carry a picture (server/rundown/read.js):
++ * a public VOD's thumbnail for an ended stream, the live frame for one on air,
++ * null for NSFW (the stream's own flag or the channel's force flag), and null
++ * when Media is down — the feed still answers 200.
++ *
++ * node test/rundown-thumbnails.test.js
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++
++process.env.NODE_ENV = 'test';
++const db = require('../server/db/database');
++const media = require('../server/media-client');
++const lookups = require('../server/media-proxy/lookups');
++const read = require('../server/rundown/read');
++
++(async () => {
++await db.initDb();
++
++const mk = async (username) => {
++ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') });
++ const id = Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);
++ await db.ensureChannel(id);
++ return id;
++};
++const stream = async (uid, { live = false, nsfw = false, title = 'Tech news' } = {}) => {
++ const id = Number((await db.createStream({ user_id: uid, title, category: 'Science & Technology', protocol: 'rtmp', is_nsfw: nsfw })).lastInsertRowid);
++ await db.run(`UPDATE streams SET is_live = ?, started_at = datetime('now', '-90 minutes'), ended_at = ${live ? 'NULL' : "datetime('now', '-30 minutes')"}, duration_seconds = 3600 WHERE id = ?`, [live ? 1 : 0, id]);
++ return id;
++};
++const take = (streamId, uid, saidAt) => db.run(
++ `INSERT INTO rundown_segments (stream_id, user_id, start_sec, end_sec, title, what, score, said_at) VALUES (?, ?, 0, 10, 'A take', 'they talked about it', 5, ?)`,
++ [streamId, uid, saidAt]);
++
++const alice = await mk('alice'), bob = await mk('bob'), carol = await mk('carol'), dave = await mk('dave');
++await db.run('UPDATE channels SET force_nsfw = 1 WHERE user_id = ?', [carol]);
++
++const publicEnded = await stream(alice, { title: 'An ended public stream' });
++const live = await stream(bob, { live: true, title: 'Live now' });
++const nsfwEnded = await stream(dave, { nsfw: true, title: 'An ended NSFW stream' });
++const forcedEnded = await stream(carol, { title: 'An ended force-NSFW stream' });
++const plainEnded = await stream(alice, { title: 'An ended stream without a VOD' });
++const nsfwLive = await stream(dave, { live: true, nsfw: true, title: 'NSFW live now' });
++await db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [`/api/thumbnails/stream-${live}-123.jpg`, live]);
++await db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [`/api/thumbnails/stream-${nsfwLive}-456.jpg`, nsfwLive]);
++for (const [i, sid] of [publicEnded, live, nsfwEnded, forcedEnded, plainEnded, nsfwLive].entries()) await take(sid, [alice, bob, dave, carol, alice, dave][i], `2026-11-01 12:0${i}:00`);
++
++// ── OpenVibe.Media stand-in: a public, finished VOD for every stream but plainEnded ──
++const VODS = new Map();
++for (const sid of [publicEnded, nsfwEnded, forcedEnded]) {
++ VODS.set(sid, { id: 7000 + sid, stream_id: sid, visibility: 'public', is_recording: 0, clips_only: 0, thumbnail_url: `/t/vod-${sid}.jpg`, duration_seconds: 3600 });
++}
++let fail = false;
++const asked = [];
++media.listVods = async (q = {}) => {
++ asked.push(Number(q.stream_id));
++ if (fail) throw new media.MediaApiError('Media is down', 0);
++ const v = VODS.get(Number(q.stream_id));
++ return { vods: v ? [{ ...v }] : [], total: v ? 1 : 0 };
++};
++
++// ── The feed: a public VOD's picture, the live frame, and null where it must be ──
++lookups._resetCaches();
++asked.length = 0;
++const feed = await read.streamsFeed({});
++const byId = new Map(feed.streams.map((s) => [s.stream_id, s]));
++assert.strictEqual(byId.get(publicEnded).thumbnail_url, media.publicUrl(`/t/vod-${publicEnded}.jpg`), 'a public VOD gives the card its picture');
++assert.strictEqual(byId.get(live).thumbnail_url, `https://openvibe.live/api/thumbnails/stream-${live}-123.jpg`, 'a live stream shows its live frame, absolute');
++assert.strictEqual(byId.get(nsfwEnded).thumbnail_url, null, 'streams.is_nsfw: no picture');
++assert.strictEqual(byId.get(forcedEnded).thumbnail_url, null, 'the channel force flag: no picture');
++assert.strictEqual(byId.get(plainEnded).thumbnail_url, null, 'no public VOD: no picture');
++assert.strictEqual(byId.get(nsfwLive).thumbnail_url, null, 'a live NSFW stream: no picture');
++assert.deepStrictEqual([...asked].sort((a, b) => a - b), [publicEnded, plainEnded].sort((a, b) => a - b), 'only the ended, non-NSFW streams ask Media');
++console.log('✅ feed: public VOD, live frame, NSFW and no-VOD null');
++
++// ── on air: the live frame, and null for an NSFW stream ──
++const air = await read.onAir();
++const airById = new Map(air.map((s) => [s.stream_id, s]));
++assert.strictEqual(airById.get(live).thumbnail_url, `https://openvibe.live/api/thumbnails/stream-${live}-123.jpg`, 'on air: the live frame');
++assert.strictEqual(airById.get(nsfwLive).thumbnail_url, null, 'on air: NSFW has no picture');
++console.log('✅ on air: live frame, NSFW null');
++
++// ── Media down: nulls, and the route still answers 200 ──
++const app = express();
++app.use(express.json());
++app.use('/api/rundown', require('../server/rundown/routes'));
++const server = http.createServer(app);
++await new Promise((res) => server.listen(0, '127.0.0.1', res));
++try {
++ fail = true;
++ lookups._resetCaches();
++ asked.length = 0;
++ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/rundown/streams`);
++ assert.strictEqual(res.status, 200, 'a Media failure never fails the feed');
++ const body = await res.json();
++ const down = new Map(body.streams.map((s) => [s.stream_id, s]));
++ assert.strictEqual(down.get(publicEnded).thumbnail_url, null, 'Media down: the ended stream has no picture');
++ assert.strictEqual(down.get(plainEnded).thumbnail_url, null);
++ assert.strictEqual(down.get(live).thumbnail_url, `https://openvibe.live/api/thumbnails/stream-${live}-123.jpg`, 'Media down: live cards keep their own frame');
++} finally {
++ fail = false;
++ lookups._resetCaches();
++ server.close();
++}
++console.log('✅ Media down: null pictures, 200');
++})().catch((err) => { console.error(err); process.exit(1); });