openvibers/openvibe.live Commits

Live now rows carry the stream's own thumbnail (Contracts 0.320.0) (#665)

OP OpenVibers committed 6 days ago · 6eb5b36fdf · 6 files, +51 −11

An entry keeps the live stream's picture: a local /api/thumbnails path becomes an absolute openvibe.live URL, an openvibe.* https picture is used as it is, and a missing, plain-http or third-party picture sends no image. The test validates the published file against frame.menu-cards@1 0.320.0.

Changes

ddiff --git a/README.md b/README.md
iindex 3c80f955..75be57e7 100644
---- a/README.md
++++ b/README.md
@@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
<!-- versions:start -->
--- openvibe-contracts: v0.314.0
++- openvibe-contracts: v0.320.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.129.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex ced60189..b0cddda8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
-- "contracts": "openvibe-contracts v0.314.0",
++ "contracts": "openvibe-contracts v0.320.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.129.0",
"capabilities": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 58bec309..ae10d594 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.314.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.1",
@@@ -2840,9 +2840,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.314.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.314.0",
-- "integrity": "sha512-sq5idLBEw+fWc4J63QvmZocf6dxq91qrtsUFzU362uKJ2hpXFQFtos2gGx1oeAyqmKL/5YfEikc/uKNY0v/x5w==",
++ "version": "0.320.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
++ "integrity": "sha512-ECShhtflTLicQosxVSEPlBFQn9eSnOUyO//Z2oS2NPscvb5RtZusrVq3xYWcJIs9XiJ7ws2Q9pAWPaRZIpEvwQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 06996f38..0f6ecfe9 100644
---- a/package.json
++++ b/package.json
@@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.314.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.1",
ddiff --git a/server/menu-card.js b/server/menu-card.js
iindex 8c2841ec..240e43dd 100644
---- a/server/menu-card.js
++++ b/server/menu-card.js
@@@ -8,7 +8,8 @@
//
// Only what GET /api/streams already shows anonymously is read: getLiveStreams() rows, with the 18+ channels
// the home page leaves out, capped at the schema's six items. No ingest key, channel ZIP or owner field is
--// copied into the card — the items are built field by field.
++// copied into the card — the items are built field by field. An entry carries the stream's own thumbnail when
++// there is one (https on openvibe.live or an openvibe.* host); a stream without a picture gets no image.
const db = require('./db/database');
const contentLabels = require('./labels');
@@@ -27,6 +28,24 @@ function plain(value, max) {
.slice(0, max);
}
++/**
++ * The entry's own live picture ({ src }) or null. Live's stream thumbnail is usually the ephemeral local
++ * `/api/thumbnails/stream-…jpg` (made absolute here) or an absolute Media URL, and only an https picture on
++ * openvibe.live or an openvibe.* host is sent. A stream with no picture keeps no image; no placeholder is made.
++ */
++function imageOf(stream) {
++ let raw = String((stream && stream.thumbnail_url) || '').trim();
++ if (!raw) return null;
++ if (raw.startsWith('/api/thumbnails/')) raw = `${BASE}${raw}`;
++ if (raw.length > 400) return null;
++ let u;
++ try { u = new URL(raw); } catch { return null; }
++ if (u.protocol !== 'https:' || u.username || u.password) return null;
++ const host = u.hostname.toLowerCase();
++ if (host !== 'openvibe.live' && !host.startsWith('openvibe.')) return null;
++ return { src: raw };
++}
++
/** The card itself. `rows` are Live's live rows in their own order (viewer_count DESC, started_at DESC). */
function cardFrom(rows, at = Date.now()) {
const items = (Array.isArray(rows) ? rows : [])
@@@ -34,11 +53,14 @@ function cardFrom(rows, at = Date.now()) {
.slice(0, 6)
.map((s) => {
const viewers = Number(s.viewer_count);
-- return {
++ const item = {
title: plain(s.display_name, 80) || plain(s.username, 80) || 'Live',
meta: `${Number.isFinite(viewers) && viewers > 0 ? Math.round(viewers) : 0} watching`,
url: `${BASE}/@${encodeURIComponent(String(s.username))}`,
};
++ const image = imageOf(s);
++ if (image) item.image = image;
++ return item;
});
return {
id: 'live-now',
@@@ -93,4 +115,4 @@ function createMenuCard({ load = () => db.getLiveStreams(), now = Date.now } = {
return { card, handler, cardFrom, cacheMs: CACHE_MS };
}
--module.exports = { ...createMenuCard(), createMenuCard, cardFrom, plain, SITE, CARD_TTL_S };
++module.exports = { ...createMenuCard(), createMenuCard, cardFrom, imageOf, plain, SITE, CARD_TTL_S };
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
iindex 97faf220..32260c01 100644
---- a/test/menu-card.test.js
++++ b/test/menu-card.test.js
@@@ -3,7 +3,8 @@
// rail — the public channels live right now, most watched first — with no items when nobody is live, CORS * and
// a 30 s cache. The card never carries a row's ingest key or owner-only fields, and 18+ channels stay off it.
const assert = require('assert');
--const { createMenuCard, cardFrom, plain, CARD_TTL_S } = require('../server/menu-card');
++const contracts = require('openvibe-contracts');
++const { createMenuCard, cardFrom, imageOf, plain, CARD_TTL_S } = require('../server/menu-card');
const row = (over = {}) => ({
id: 1, user_id: 2, username: 'nova', display_name: 'Nova', viewer_count: 12, is_live: 1,
@@@ -27,6 +28,23 @@ assert.deepStrictEqual(card.more, { label: 'See all', url: 'https://openvibe.liv
assert.ok(card.items.length <= 6 && card.title.length <= 60);
for (const item of card.items) assert.ok(item.title.length <= 80 && item.meta.length <= 120 && item.url.startsWith('https://openvibe.live/'));
++assert.ok(contracts.validate('frame.menu-cards@1', [card]).valid, 'the file validates against frame.menu-cards@1');
++
++// Pictures: each stream's own live thumbnail, made absolute for a local /api/thumbnails path; real pictures only.
++const pictured = cardFrom([
++ row({ id: 1, username: 'nova', viewer_count: 30, thumbnail_url: '/api/thumbnails/stream-1-123.jpg' }),
++ row({ id: 2, username: 'kitchen', viewer_count: 20, thumbnail_url: 'https://openvibe.media/t/vod-2.jpg' }),
++ row({ id: 3, username: 'elsewhere', viewer_count: 10, thumbnail_url: 'https://example.com/x.jpg' }),
++ row({ id: 4, username: 'plainhttp', viewer_count: 5, thumbnail_url: 'http://openvibe.live/api/thumbnails/x.jpg' }),
++ row({ id: 5, username: 'quiet', viewer_count: 1 }),
++]);
++assert.deepStrictEqual(pictured.items[0].image, { src: 'https://openvibe.live/api/thumbnails/stream-1-123.jpg' }, 'a local live thumb becomes an absolute own-origin URL');
++assert.deepStrictEqual(pictured.items[1].image, { src: 'https://openvibe.media/t/vod-2.jpg' }, 'a Media picture is kept as it is');
++assert.ok(!pictured.items[2].image && !pictured.items[3].image && !pictured.items[4].image, 'a third-party, plain-http or missing picture sends no image');
++assert.ok(contracts.validate('frame.menu-cards@1', [pictured]).valid, 'a pictured file validates against frame.menu-cards@1');
++assert.strictEqual(imageOf({ thumbnail_url: 'javascript:alert(1)' }), null);
++assert.strictEqual(imageOf({ thumbnail_url: 'https://openvibe.live/x.jpg'.padEnd(401, 'x') }), null, 'a src over 400 characters is dropped');
++
// Nobody live: the card is still published, with no items, and Network skips it.
assert.deepStrictEqual(cardFrom([]).items, []);
assert.deepStrictEqual(cardFrom(null).items, []);