openvibers/openvibe.live Commits

Live: recording, clip and stream counts from the one view store (#855)

OP OpenVibers committed 21 h ago · 4c79c81b53 · 9 files, +93 −15

* Live: recordings, clips and streams carry the one view store's counts

* Merge origin/main into views-live; pin contracts 0.416.0 and sdk 0.158.0

* Docs: name contracts 0.416.0 and sdk 0.158.0

Changes

ddiff --git a/README.md b/README.md
iindex 738190e5..13755b6e 100644
---- a/README.md
++++ b/README.md
@@@ -183,6 +183,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
- openvibe-contracts: v0.416.0
--- openvibe-sdk: v0.152.0
--- openvibe-shared: v4.223.1
++- openvibe-sdk: v0.158.0
++- openvibe-shared: v4.223.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 5ea60f7e..13c93703 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,8 +6,8 @@
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.416.0",
-- "sdk": "openvibe-sdk v0.152.0",
-- "shared": "openvibe-shared v4.223.1",
++ "sdk": "openvibe-sdk v0.158.0",
++ "shared": "openvibe-shared v4.223.0",
"capabilities": [
"live.chat_context.read",
"live.chat_effects.write",
ddiff --git a/package-lock.json b/package-lock.json
iindex 7a4a4d67..1d43193b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -25,8 +25,8 @@
"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.416.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
@@@ -3389,9 +3389,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.152.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
-- "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==",
++ "version": "0.158.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0",
++ "integrity": "sha512-jxUORU4Dju8nTo+eU5yAsXxbJq0swJ2A4l9XheIcNSd3OpJkWM+MN6D8cgSGn55x3gw3v54W+kg5XAwkUI9Cmg==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -3417,9 +3417,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.223.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1",
-- "integrity": "sha512-fSkUepZHobap/oX9GbYVWeVwRZteD+FXUsT4+W2F7u3F3annBzS8BGLRM1K4vcl2+GBcwL3TKbE3IeTjbfUPRA==",
++ "version": "4.223.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.0",
++ "integrity": "sha512-l1woGnTQxcgpSr3OdpODwtWC8MNVIF/SbJrudnLIlZEwWreid2tucAi15AaPi1aFLWi8MvYLfCaD81IWRbxRHA==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 30e18076..41c06d59 100644
---- a/package.json
++++ b/package.json
@@@ -45,8 +45,8 @@
"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.416.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 806741b9..52029f09 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -97,6 +97,26 @@ function _qs(query) {
return s ? `?${s}` : '';
}
++/**
++ * The one view store's numbers for a batch of refs ({ service, kind, id }), from Media's public
++ * GET /api/v1/views/counts. No token needed: the counters are public. An unreachable Media answers {} (a card
++ * without a view count), never an error: a page must still render.
++ * → { "media:vod:12": { views, engaged, previews, preview_share, peak_concurrent }, … }
++ */
++async function viewCounts(refs, { timeoutMs = 4000 } = {}) {
++ const list = (Array.isArray(refs) ? refs : [refs]).filter((r) => r && r.service && r.kind && r.id != null).slice(0, 50);
++ if (!list.length) return {};
++ const ctrl = new AbortController();
++ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
++ try {
++ const url = `${MEDIA_URL}/api/v1/views/counts?refs=${encodeURIComponent(JSON.stringify(list))}`;
++ const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: ctrl.signal });
++ if (!res.ok) return {};
++ const body = await res.json();
++ return body && body.counts && typeof body.counts === 'object' ? body.counts : {};
++ } catch { return {}; } finally { clearTimeout(timer); }
++}
++
/**
* Core request helper. `body` may be a plain object (JSON) or FormData (multipart).
* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.
@@@ -477,7 +497,7 @@ module.exports = {
signedMediaUrl,
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MediaApiError,
-- request, proxy, actingUserFrom, _formData, _authHeader, _reset, MEDIA_AUDIENCE,
++ request, proxy, actingUserFrom, _formData, _authHeader, _reset, MEDIA_AUDIENCE, viewCounts,
// vods
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
uploadVodChunk, finalizeVod,
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 7fb892b0..9111884d 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -366,6 +366,8 @@ router.get('/:id', optionalAuth, async (req, res) => {
} catch { /* */ }
clip.comment_count = comments;
++ // The one view store (Media, GET /api/v1/views/counts): views and engaged viewers for this clip page.
++ try { clip.counts = (await media.viewCounts([{ service: 'media', kind: 'clip', id: String(clip.id) }]))[`media:clip:${clip.id}`] || null; } catch { clip.counts = null; }
clip.can_delete = await canActorDeleteClip(req.user, clip);
clip.can_edit = await canActorModerateClip(req.user, clip);
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex e6e03602..ce79cc78 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -684,6 +684,8 @@ router.get('/:id', optionalAuth, async (req, res) => {
} catch { /* */ }
vod.comment_count = comments;
++ // The one view store (Media, POST /api/v1/views): views and engaged viewers for this recording page.
++ try { vod.counts = (await media.viewCounts([{ service: 'media', kind: 'vod', id: String(vod.id) }]))[`media:vod:${vod.id}`] || null; } catch { vod.counts = null; }
res.json({ vod, clips });
} catch {
res.status(500).json({ error: 'Failed to get VOD' });
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 29eb7bda..46b0f476 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -296,6 +296,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
delete liveStream.stream_key;
delete liveStream.managed_stream_key;
}
++ // The one view store's numbers for the live cards (Media, GET /api/v1/views/counts); a watcher sees a
++ // card even when Media is down, so a failure is an absent count, never an error.
++ try {
++ const viewCounts = await require('../media-client').viewCounts(liveStreams.map((x) => ({ service: 'live', kind: 'stream', id: String(x.id) })));
++ for (const liveStream of liveStreams) liveStream.counts = viewCounts[`live:stream:${liveStream.id}`] || null;
++ } catch { /* a card without a count */ }
// Show hidden (private + unlisted) VODs/clips to the channel owner AND admins;
// everyone else sees public only.
@@@ -2156,6 +2162,9 @@ router.get('/:id', optionalAuth, async (req, res) => {
if (req.user) stream.isFollowing = await networkFollows.isFollowing(req.user.id, stream.user_id);
stream.follower_count = await networkFollows.followerCount(stream.user_id);
++ // The one view store's numbers (views, engaged viewers, peak concurrent) for the stream page.
++ try { stream.counts = (await require('../media-client').viewCounts([{ service: 'live', kind: 'stream', id: String(stream.id) }]))[`live:stream:${stream.id}`] || null; } catch { stream.counts = null; }
++
res.json({ stream });
} catch (err) {
console.error('[Streams] Get error:', err.message);
ddiff --git a/test/views-counts.test.js b/test/views-counts.test.js
nnew file mode 100644
iindex 00000000..8ac3fd72
---- /dev/null
++++ b/test/views-counts.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++// The one view store on Live's pages: media-client.viewCounts reads Media's public GET /api/v1/views/counts for the
++// recordings, clips and live streams Live renders. It is batched, needs no token, and an unreachable Media is an
++// absent count ({}) rather than a page that fails.
++const assert = require('assert');
++const http = require('http');
++
++(async () => {
++ let calls = 0;
++ let status = 200;
++ const seen = [];
++ const media = http.createServer((req, res) => {
++ calls += 1;
++ const refs = JSON.parse(new URL(req.url, 'http://x').searchParams.get('refs'));
++ seen.push(...refs);
++ if (status !== 200) { res.statusCode = status; return res.end('down'); }
++ const counts = {};
++ for (const r of refs) counts[`${r.service}:${r.kind}:${r.id}`] = { views: 4, engaged: 1, previews: 2, preview_share: 0.5, peak_concurrent: null };
++ res.setHeader('content-type', 'application/json');
++ res.end(JSON.stringify({ counts }));
++ });
++ await new Promise((r) => media.listen(0, '127.0.0.1', r));
++ process.env.MEDIA_URL = `http://127.0.0.1:${media.address().port}`;
++ const mediaClient = require('../server/media-client');
++
++ let out = await mediaClient.viewCounts([
++ { service: 'media', kind: 'vod', id: '12' },
++ { service: 'live', kind: 'stream', id: '7' },
++ ]);
++ assert.strictEqual(calls, 1, 'one batched read');
++ assert.deepStrictEqual(out['media:vod:12'], { views: 4, engaged: 1, previews: 2, preview_share: 0.5, peak_concurrent: null });
++ assert.deepStrictEqual(out['live:stream:7'], out['media:vod:12']);
++ assert.deepStrictEqual(seen, [{ service: 'media', kind: 'vod', id: '12' }, { service: 'live', kind: 'stream', id: '7' }]);
++
++ out = await mediaClient.viewCounts([]);
++ assert.deepStrictEqual(out, {});
++ assert.strictEqual(calls, 1, 'nothing to ask for makes no call');
++
++ status = 503;
++ out = await mediaClient.viewCounts([{ service: 'media', kind: 'clip', id: '3' }]);
++ assert.deepStrictEqual(out, {}, 'a Media outage is an absent count');
++
++ await new Promise((r) => media.close(r));
++ console.log('views-counts: ok');
++})().catch((error) => { console.error(error); process.exit(1); });