openvibers/openvibe.live Commits
Live now rows carry the stream's own thumbnail (Contracts 0.320.0) (#665)
Changes
@@@ -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 -->@@@ -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": [@@@ -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",@@@ -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",@@@ -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 };@@@ -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.livassert.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, []);
@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -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.
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.314.0
+- openvibe-contracts: v0.320.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.129.0
- openvibe-shared: v4.129.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
- "contracts": "openvibe-contracts v0.314.0",
+ "contracts": "openvibe-contracts v0.320.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.129.0",
"shared": "openvibe-shared v4.129.0",
"capabilities": [
"capabilities": [
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -2840,9 +2840,9 @@
@@ -2840,9 +2840,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
//
//
// Only what GET /api/streams already shows anonymously is read: getLiveStreams() rows, with the 18+ channels
// 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
// 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 db = require('./db/database');
const contentLabels = require('./labels');
const contentLabels = require('./labels');
@@ -27,6 +28,24 @@ function plain(value, max) {
@@ -27,6 +28,24 @@ function plain(value, max) {
.slice(0, 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). */
/** 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()) {
function cardFrom(rows, at = Date.now()) {
const items = (Array.isArray(rows) ? rows : [])
const items = (Array.isArray(rows) ? rows : [])
@@ -34,11 +53,14 @@ function cardFrom(rows, at = Date.now()) {
@@ -34,11 +53,14 @@ function cardFrom(rows, at = Date.now()) {
.slice(0, 6)
.slice(0, 6)
.map((s) => {
.map((s) => {
const viewers = Number(s.viewer_count);
const viewers = Number(s.viewer_count);
- return {
+ const item = {
title: plain(s.display_name, 80) || plain(s.username, 80) || 'Live',
title: plain(s.display_name, 80) || plain(s.username, 80) || 'Live',
meta: `${Number.isFinite(viewers) && viewers > 0 ? Math.round(viewers) : 0} watching`,
meta: `${Number.isFinite(viewers) && viewers > 0 ? Math.round(viewers) : 0} watching`,
url: `${BASE}/@${encodeURIComponent(String(s.username))}`,
url: `${BASE}/@${encodeURIComponent(String(s.username))}`,
};
};
+ const image = imageOf(s);
+ if (image) item.image = image;
+ return item;
});
});
return {
return {
id: 'live-now',
id: 'live-now',
@@ -93,4 +115,4 @@ function createMenuCard({ load = () => db.getLiveStreams(), now = Date.now } = {
@@ -93,4 +115,4 @@ function createMenuCard({ load = () => db.getLiveStreams(), now = Date.now } = {
return { card, handler, cardFrom, cacheMs: CACHE_MS };
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 };
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
// rail — the public channels live right now, most watched first — with no items when nobody is live, CORS * and
// 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.
// 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 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 = {}) => ({
const row = (over = {}) => ({
id: 1, user_id: 2, username: 'nova', display_name: 'Nova', viewer_count: 12, is_live: 1,
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
@@ -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);
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/'));
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.
// Nobody live: the card is still published, with no items, and Network skips it.
assert.deepStrictEqual(cardFrom([]).items, []);
assert.deepStrictEqual(cardFrom([]).items, []);
assert.deepStrictEqual(cardFrom(null).items, []);
assert.deepStrictEqual(cardFrom(null).items, []);