openvibers/openvibe.mediahub Commits

Trending rows carry their real thumbnails (Contracts 0.320.0) (#308)

OP OpenVibers committed 6 days ago · fd2d746bb3 · 6 files, +57 −13

An entry keeps the item's own thumbnail: https on an openvibe.* host or openvibe.video, asked for at 640 px (w/h 640x360) when it is a Media /t/ picture. A null, third-party or plain-http picture is left out, never a placeholder. The test validates the published file against frame.menu-cards@1 0.320.0 and pins the new Contracts.

Changes

ddiff --git a/README.md b/README.md
iindex e078047..5a22d0b 100644
---- a/README.md
++++ b/README.md
@@@ -529,7 +529,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.312.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 589f10e..70e5004 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": "4990",
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
"packages": [
-- "openvibe-contracts v0.312.0",
++ "openvibe-contracts v0.320.0",
"openvibe-sdk v0.94.0",
"openvibe-shared v4.129.0"
],
@@@ -42,7 +42,7 @@
"limits and readiness",
"deploy files"
],
-- "contracts": "openvibe-contracts v0.312.0",
++ "contracts": "openvibe-contracts v0.320.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.129.0",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08, https://openvibe.video since 2026-10-12 and https://openvibe.pics since 2026-10-15.",
ddiff --git a/package-lock.json b/package-lock.json
iindex 7d1f8b1..4ebd18a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.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.0",
@@@ -1610,9 +1610,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.312.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
++ "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 d955473..b10e7c3 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.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.0",
ddiff --git a/server/video/menu-card.js b/server/video/menu-card.js
iindex b6d48ab..3716ce9 100644
---- a/server/video/menu-card.js
++++ b/server/video/menu-card.js
@@@ -6,7 +6,9 @@
* Video publishes "Trending": the most viewed public recordings and clips of the last seven days, each linking
* to its openvibe.video watch page. The rows come from Live's public Content feed (GET /api/content/feed with
* sort=top&window=week), which merges Media's public VODs and clips and already drops private, unlisted and
-- * banned-account items; this module only picks the top six, merges the two lists and builds the card.
++ * banned-account items; this module only picks the top six, merges the two lists and builds the card. An entry
++ * carries the item's own thumbnail (https on openvibe.media, asked for at 640 px and declared 640×360) when the
++ * feed has one; no picture is ever a placeholder.
*
* The file is public and CORS *, cached for two minutes. A failed feed keeps the last good card; with none yet it
* answers 503, and Network keeps the site's previous card for its own 30-minute window.
@@@ -30,7 +32,29 @@ function plain(value, max) {
const views = (item) => (Number.isFinite(Number(item && item.views)) ? Number(item.views) : 0);
--/** The card itself, most viewed first. `items` are feed items ({ kind, id, title, views }). */
++// A card's picture: the 16:9 shape the menu draws, at the 640 px step of Media's thumbnail ladder.
++const THUMB_W = 640;
++const THUMB_H = 360;
++const MEDIA_THUMB = /^https:\/\/openvibe\.media\/t\/[^?#\s]+$/;
++
++/**
++ * The entry's own picture ({ src, w?, h? }) or null. Only a real https thumbnail on an openvibe.* host (or this
++ * site's own origin) is sent: the feed's null or a third-party picture is left out, never a placeholder. Media's
++ * /t/ thumbnails come in sizes, so a card asks for the 640 px WebP it draws and says it is 640×360.
++ */
++function imageOf(item) {
++ const raw = String((item && item.thumbnail_url) || '').trim();
++ if (!raw || 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.video' && !host.startsWith('openvibe.')) return null;
++ if (MEDIA_THUMB.test(raw)) return { src: `${raw}?w=${THUMB_W}&fmt=webp`, w: THUMB_W, h: THUMB_H };
++ return { src: raw };
++}
++
++/** The card itself, most viewed first. `items` are feed items ({ kind, id, title, views, thumbnail_url? }). */
function cardFrom(items, at = Date.now()) {
const seen = new Set();
const rows = (Array.isArray(items) ? items : [])
@@@ -45,11 +69,14 @@ function cardFrom(items, at = Date.now()) {
.slice(0, LIMIT)
.map((it) => {
const n = views(it);
-- return {
++ const item = {
title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),
meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'view' : 'views'} this week`,
url: `${BASE}/${it.kind === 'clip' ? 'c' : 'v'}/${Number(it.id)}`,
};
++ const image = imageOf(it);
++ if (image) item.image = image;
++ return item;
});
return {
id: CARD_ID,
@@@ -107,4 +134,4 @@ function createMenuCardHandler({ feed, now = Date.now }) {
};
}
--module.exports = { createMenuCardHandler, cardFrom, plain, SITE, CARD_ID, CARD_TTL_S };
++module.exports = { createMenuCardHandler, cardFrom, imageOf, plain, SITE, CARD_ID, CARD_TTL_S };
ddiff --git a/test/video-menu-card.test.js b/test/video-menu-card.test.js
iindex 998fa01..623a361 100644
---- a/test/video-menu-card.test.js
++++ b/test/video-menu-card.test.js
@@@ -2,7 +2,8 @@
// The menu's network card for OpenVibe.Video (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the
// week's most viewed public recordings and clips from Live's Content feed, CORS *, cached two minutes.
const assert = require('assert');
--const { createMenuCardHandler, cardFrom, plain, CARD_TTL_S } = require('../server/video/menu-card');
++const contracts = require('openvibe-contracts');
++const { createMenuCardHandler, cardFrom, imageOf, plain, CARD_TTL_S } = require('../server/video/menu-card');
const vod = (id, views, title = `Recording ${id}`) => ({ kind: 'vod', id, title, views });
const clip = (id, views, title = `Clip ${id}`) => ({ kind: 'clip', id, title, views });
@@@ -22,11 +23,27 @@ assert.deepStrictEqual(card.items, [
{ title: 'Recording 3', meta: '30 views this week', url: 'https://openvibe.video/v/3' },
]);
assert.deepStrictEqual(card.more, { label: 'Watch more', url: 'https://openvibe.video/' });
++assert.ok(contracts.validate('frame.menu-cards@1', [card]).valid, 'the file validates against frame.menu-cards@1');
for (const item of card.items) {
assert.ok(item.title.length <= 80 && item.meta.length <= 120);
assert.ok(item.url.startsWith('https://openvibe.video/'));
}
++// Pictures: the item's own Media thumbnail at 640 px and 640x360; a real picture only, never a placeholder.
++const pictured = cardFrom([
++ vod(1, 30),
++ { kind: 'clip', id: 2, title: 'Clip 2', views: 20, thumbnail_url: 'https://openvibe.media/t/clip-2-1.jpg' },
++ { kind: 'vod', id: 3, title: 'Elsewhere', views: 10, thumbnail_url: 'https://www.youtube.com/i/x.jpg' },
++ { kind: 'vod', id: 4, title: 'Plain http', views: 5, thumbnail_url: 'http://openvibe.media/t/vod-4-1.jpg' },
++ { kind: 'vod', id: 5, title: 'Own origin', views: 1, thumbnail_url: 'https://openvibe.video/live/nova/frame.jpg' },
++]);
++assert.deepStrictEqual(pictured.items[0], { title: 'Recording 1', meta: '30 views this week', url: 'https://openvibe.video/v/1' }, 'no picture: no image key');
++assert.deepStrictEqual(pictured.items[1].image, { src: 'https://openvibe.media/t/clip-2-1.jpg?w=640&fmt=webp', w: 640, h: 360 });
++assert.ok(!pictured.items[2].image && !pictured.items[3].image, 'a third-party or plain-http picture is left out');
++assert.deepStrictEqual(pictured.items[4].image, { src: 'https://openvibe.video/live/nova/frame.jpg' }, 'another allowed host is used as it is');
++assert.ok(contracts.validate('frame.menu-cards@1', [pictured]).valid, 'a pictured file validates against frame.menu-cards@1');
++assert.strictEqual(imageOf({ thumbnail_url: 'https://openvibe.media/t/x.jpg?w=480' }).src, 'https://openvibe.media/t/x.jpg?w=480', 'an already-sized thumbnail is not re-queried');
++
// At most six, one view is singular, a repeated item appears once, and anything that is not a public VOD/clip is dropped.
assert.strictEqual(cardFrom(Array.from({ length: 8 }, (_, i) => vod(i + 1, 100 - i))).items.length, 6);
assert.strictEqual(cardFrom([clip(1, 1)]).items[0].meta, '1 view this week');