openvibers/openvibe.live Commits
Give Rundown stream cards their public VOD or live picture (#522)
Changes
@@@ -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@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -78,7 +78,7 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
@@ -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, PUT | `/opt-out {out}` | signed in |
| GET | `/voice/:user?t=` | a line in the streamer's chat voice |
| 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
## Data
@@ -12,6 +12,7 @@
@@ -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)
* 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 db = require('../db/database');
+const config = require('../config');
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
@@ -20,6 +21,46 @@ const STREAMS_PAGE = 8;
@@ -20,6 +21,46 @@ 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', ' ');
+/** 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) {
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;
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,
@@ -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';
const EPISODE_COLS = 'stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at';
async function onAir() {
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 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.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
(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));
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) {
async function trending(limit = 12, days = 7) {
@@ -152,13 +196,16 @@ async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null,
@@ -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); }
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))
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 }]));
.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 ppl = await people([...streams.values()].map((s) => s.user_id));
+ const thumbs = await thumbnailsFor([...streams.values()]);
const out = groups.map((g) => {
const out = groups.map((g) => {
const s = streams.get(Number(g.stream_id));
const s = streams.get(Number(g.stream_id));
if (!s) return null;
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)) || [] };
episode: eps.get(Number(g.stream_id)) || null, takes: takes.get(Number(g.stream_id)) || [] };
}).filter((x) => x && x.user && x.takes.length);
}).filter((x) => x && x.user && x.takes.length);
return { streams: out, next: groups.length >= limit ? groups[groups.length - 1].last_at : null };
return { streams: out, next: groups.length >= limit ? groups[groups.length - 1].last_at : null };
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -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); });