openvibers/openvibe.live Commits
Live: recording, clip and stream counts from the one view store (#855)
Changes
@@@ -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 -->@@@ -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",@@@ -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",@@@ -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",@@@ -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,// vodscreateVod, ingestRtmp, ingestRtpStart, ingestRtpStop,uploadVodChunk, finalizeVod,@@@ -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);@@@ -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' });@@@ -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);nnew file mode 100644@@@ -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); });
@@ -183,6 +183,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -183,6 +183,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.416.0
- 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 -->
<!-- versions:end -->
@@ -6,8 +6,8 @@
@@ -6,8 +6,8 @@
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.416.0",
"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": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -25,8 +25,8 @@
@@ -25,8 +25,8 @@
"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.416.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",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -3389,9 +3389,9 @@
@@ -3389,9 +3389,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -3417,9 +3417,9 @@
@@ -3417,9 +3417,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -45,8 +45,8 @@
@@ -45,8 +45,8 @@
"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.416.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",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -97,6 +97,26 @@ function _qs(query) {
@@ -97,6 +97,26 @@ function _qs(query) {
return s ? `?${s}` : '';
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).
* 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.
* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.
@@ -477,7 +497,7 @@ module.exports = {
@@ -477,7 +497,7 @@ module.exports = {
signedMediaUrl,
signedMediaUrl,
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MediaApiError,
MediaApiError,
- request, proxy, actingUserFrom, _formData, _authHeader, _reset, MEDIA_AUDIENCE,
+ request, proxy, actingUserFrom, _formData, _authHeader, _reset, MEDIA_AUDIENCE, viewCounts,
// vods
// vods
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
uploadVodChunk, finalizeVod,
uploadVodChunk, finalizeVod,
@@ -366,6 +366,8 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -366,6 +366,8 @@ router.get('/:id', optionalAuth, async (req, res) => {
} catch { /* */ }
} catch { /* */ }
clip.comment_count = comments;
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_delete = await canActorDeleteClip(req.user, clip);
clip.can_edit = await canActorModerateClip(req.user, clip);
clip.can_edit = await canActorModerateClip(req.user, clip);
@@ -684,6 +684,8 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -684,6 +684,8 @@ router.get('/:id', optionalAuth, async (req, res) => {
} catch { /* */ }
} catch { /* */ }
vod.comment_count = comments;
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 });
res.json({ vod, clips });
} catch {
} catch {
res.status(500).json({ error: 'Failed to get VOD' });
res.status(500).json({ error: 'Failed to get VOD' });
@@ -296,6 +296,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -296,6 +296,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
delete liveStream.stream_key;
delete liveStream.stream_key;
delete liveStream.managed_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;
// Show hidden (private + unlisted) VODs/clips to the channel owner AND admins;
// everyone else sees public only.
// everyone else sees public only.
@@ -2156,6 +2162,9 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -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);
if (req.user) stream.isFollowing = await networkFollows.isFollowing(req.user.id, stream.user_id);
stream.follower_count = await networkFollows.followerCount(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 });
res.json({ stream });
} catch (err) {
} catch (err) {
console.error('[Streams] Get error:', err.message);
console.error('[Streams] Get error:', err.message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -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); });