openvibers/openvibe.report Commits

Report: views and engaged viewers per item, creator and site (#200)

OP OpenVibers committed 23 h ago · a10b28fb10 · 12 files, +250 −27

* Report: per item, per creator and per site views, engaged viewers, Feed share and live peak

* Merge origin/main into views-report; 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/.env.example b/.env.example
iindex da5732b..6842e6a 100644
---- a/.env.example
++++ b/.env.example
@@@ -22,6 +22,8 @@ OV_NETWORK_URL=https://openvibe.network
# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
++# OpenVibe.Media's one view store (GET /api/v1/views/aggregate): views, engaged viewers, Feed share, live peak.
++OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
# Bench's public read API; set to an internal origin if Report and Bench share one.
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
ddiff --git a/README.md b/README.md
iindex cd072d9..875eab2 100644
---- a/README.md
++++ b/README.md
@@@ -261,8 +261,8 @@ HMAC. See [SECURITY.md](SECURITY.md).
<!-- versions:start -->
- openvibe-contracts: v0.416.0
--- openvibe-sdk: v0.150.0
--- openvibe-shared: v4.223.1
++- openvibe-sdk: v0.158.0
++- openvibe-shared: v4.223.0
- openvibe-publishing: v1.7.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex ac7fabb..9688b55 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,13 +9,13 @@
"port": "5080",
"domain": "openvibe.report",
"contracts": "openvibe-contracts v0.416.0",
-- "sdk": "openvibe-sdk v0.150.0",
-- "shared": "openvibe-shared v4.223.1",
++ "sdk": "openvibe-sdk v0.158.0",
++ "shared": "openvibe-shared v4.223.0",
"packages": [
"openvibe-contracts v0.416.0",
"openvibe-publishing v1.7.0",
-- "openvibe-sdk v0.150.0",
-- "openvibe-shared v4.223.1",
++ "openvibe-sdk v0.158.0",
++ "openvibe-shared v4.223.0",
"pg v8.23.1",
"pglite v0.5.13 (dev)"
],
ddiff --git a/package-lock.json b/package-lock.json
iindex 4d5120a..430c8b7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,8 +15,8 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.416.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.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"
},
"devDependencies": {
@@@ -1172,9 +1172,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.150.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",
++ "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"
@@@ -1200,9 +1200,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 87b183e..5589b41 100644
---- a/package.json
++++ b/package.json
@@@ -21,8 +21,8 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.416.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.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"
},
"engines": {
ddiff --git a/server/app.js b/server/app.js
iindex 6681841..5268894 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -48,6 +48,7 @@ const sharing = require('./sharing');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createPageRoutes, createPublicPages } = require('./http/pages');
++const { createViewsClient } = require('./data/views');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createInternalRoutes } = require('./http/internal');
const { createServiceReadiness } = require('./observability');
@@@ -129,7 +130,8 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
});
-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports, meStats, popularity };
++ const viewsData = createViewsClient({ config, fetchImpl, log, now });
++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports, meStats, popularity, viewsData };
const app = express();
if (exchangeConfig) exchangeConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@@ -327,7 +329,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
-- app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, meStats, popularity, config, now, log }));
++ app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, meStats, popularity, viewsData, config, now, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
ddiff --git a/server/config.js b/server/config.js
iindex 4c866eb..af853a2 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -46,6 +46,8 @@ function load(env = process.env) {
networkPublicUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
liveUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
++ // OpenVibe.Media's one view store (GET /api/v1/views/aggregate): every kind's views and engaged viewers.
++ mediaUrl: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
// OpenVibe.Bench's public reads (kinds, boards, models, datasets) — the AI boards under /ai. Public HTML
// boards render here; Bench stays the benchmark authority that owns the numbers.
ddiff --git a/server/data/views.js b/server/data/views.js
nnew file mode 100644
iindex 0000000..9ad8cc4
---- /dev/null
++++ b/server/data/views.js
@@@ -0,0 +1,76 @@
++'use strict';
++
++/**
++ * OpenVibe.Media's view store: the one place every kind's views and engaged viewers are counted (POST /api/v1/views on
++ * Media; Media's GET /api/v1/views/aggregate answers here). Report quotes the numbers, it never derives them.
++ *
++ * → { scope, id, days, from, to, rows: [{ id, service?, kind?, item_id?, site, creator, views, engaged, previews,
++ * preview_share, peak_concurrent }], totals: { views, engaged, previews, preview_share, peak_concurrent } }
++ *
++ * A source that does not answer is null (the page leaves the section out, never a zero); rows are kept 60 seconds.
++ */
++const { trim } = require('./source');
++
++const TTL_MS = 60_000;
++const MAX = 300;
++
++function createViewsClient({ config = {}, fetchImpl = globalThis.fetch, log = console, timeoutMs = 4000, now = () => Date.now() } = {}) {
++ const baseUrl = trim(config.mediaUrl || 'http://127.0.0.1:4100');
++ const cache = new Map();
++ const inflight = new Map();
++
++ async function load(query) {
++ const key = JSON.stringify(query);
++ const kept = cache.get(key);
++ if (kept && now() - kept.at < TTL_MS) return kept.data;
++ if (inflight.has(key)) return await inflight.get(key);
++ const p = (async () => {
++ const ctrl = new AbortController();
++ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
++ try {
++ const u = new URL(`${baseUrl}/api/v1/views/aggregate`);
++ for (const [k, v] of Object.entries(query)) if (v != null && v !== '') u.searchParams.set(k, String(v));
++ const res = await fetchImpl(u.href, { headers: { Accept: 'application/json' }, signal: ctrl.signal });
++ if (!res.ok) return null;
++ const data = await res.json();
++ if (!data || !Array.isArray(data.rows)) return null;
++ if (cache.size >= MAX) cache.clear();
++ cache.set(key, { at: now(), data });
++ return data;
++ } catch (err) {
++ if (log && typeof log.warn === 'function') log.warn('[views] Media did not answer:', err && err.message);
++ return null;
++ } finally { clearTimeout(timer); }
++ })().finally(() => inflight.delete(key));
++ inflight.set(key, p);
++ return await p;
++ }
++
++ return {
++ /** One item's numbers (service and kind name the owner and type). */
++ item({ service, kind, id, days = 30 } = {}) {
++ if (!service || !kind || id == null) return null;
++ return load({ scope: 'item', service, kind, id, days });
++ },
++ creator(id, days = 30) {
++ if (!id) return null;
++ return load({ scope: 'creator', id, days });
++ },
++ site(id, days = 30) {
++ if (!id) return null;
++ return load({ scope: 'site', id, days });
++ },
++ /** The three dimensions at once: items, creators and sites for the room's tables. */
++ async top(days = 30) {
++ const [items, creators, sites] = await Promise.all([
++ load({ scope: 'item', days, limit: 12 }),
++ load({ scope: 'creator', days, limit: 12 }),
++ load({ scope: 'site', days, limit: 24 }),
++ ]);
++ if (!items && !creators && !sites) return null;
++ return { days, items, creators, sites };
++ },
++ };
++}
++
++module.exports = { createViewsClient, TTL_MS };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 048442d..99aade4 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -32,7 +32,7 @@ const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
--function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, meStats = null, popularity = null, config = {}, now = () => Date.now(), log = console }) {
++function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, meStats = null, popularity = null, viewsData = null, config = {}, now = () => Date.now(), log = console }) {
const r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
@@@ -94,6 +94,36 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
return problem(req, res, 404, code, detail);
}
++ // The one view store (OpenVibe.Media): every kind's views and engaged viewers, per item, per creator or per
++ // site, with the Feed-preview share and a live stream's peak concurrent. Report quotes Media's numbers.
++ r.get('/views', async (req, res) => {
++ if (!viewsData) return problem(req, res, 503, 'report.no_views_source', 'The view store is not configured in this process.');
++ const scope = ['item', 'creator', 'site'].includes(String(req.query.scope)) ? String(req.query.scope) : 'item';
++ const days = normaliseDays(req.query.days);
++ let out = null;
++ if (scope === 'item') {
++ const service = String(req.query.service || '');
++ const kind = String(req.query.kind || '');
++ const id = String(req.query.id || '');
++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(service) || !/^[a-z][a-z0-9_]{1,39}$/.test(kind) || !id) return notFound(req, res, 'report.views_ref', 'A view item is service, kind and id.');
++ out = await viewsData.item({ service, kind, id, days });
++ } else {
++ const id = String(req.query.id || '');
++ if (!id) return notFound(req, res, 'report.views_ref', `A view ${scope} is its id.`);
++ out = scope === 'creator' ? await viewsData.creator(id, days) : await viewsData.site(id, days);
++ }
++ if (!out) return notFound(req, res, 'report.views_not_found', 'The view store did not answer for this window.');
++ return res.json(out);
++ });
++
++ // The three dimensions the room's tables use, in one answer (items, creators, sites).
++ r.get('/views/top', async (req, res) => {
++ if (!viewsData) return problem(req, res, 503, 'report.no_views_source', 'The view store is not configured in this process.');
++ const out = await viewsData.top(normaliseDays(req.query.days));
++ if (!out) return notFound(req, res, 'report.views_not_found', 'The view store did not answer for this window.');
++ return res.json(out);
++ });
++
r.get('/stats', async (req, res) => {
const days = normaliseDays(req.query.days);
const stats = analytics ? await analytics.stats(days) : await network.stats(days);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1f828eb..c7658db 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -38,7 +38,7 @@ const aiComparisonViews = require('../views/ai-comparison');
const CACHE = 'public, max-age=120';
/** The pages the agent layer twins: their data, then the view. Each returns { html } or null (nothing to show). */
--function createPublicPages({ sessionReports, network, live, bench, aiCatalogue = null, analytics = null }) {
++function createPublicPages({ sessionReports, network, live, bench, aiCatalogue = null, analytics = null, viewsData = null }) {
const numbers = (days) => (analytics ? analytics.stats(days) : network.stats(days));
async function home() {
@@@ -48,8 +48,8 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
}
async function stats(days) {
-- const data = await numbers(days);
-- return data ? { html: views.stats({ stats: data, days }) } : null;
++ const [data, viewData] = await Promise.all([numbers(days), viewsData ? viewsData.top(days) : null]);
++ return data ? { html: views.stats({ stats: data, days, views: viewData }) } : null;
}
function developers() {
@@@ -274,7 +274,7 @@ function rssFeed({ runs, overview, base }) {
}
function createPageRoutes(ctx) {
-- const { live, bench, sessionReports } = ctx;
++ const { live, bench, sessionReports, viewsData } = ctx;
const r = express.Router();
const publicPages = ctx.publicPages || createPublicPages(ctx);
// The Rundown's pages canonicalise to OpenVibe.Live, and so does its Markdown twin: the generic agent layer
@@@ -503,9 +503,9 @@ function createPageRoutes(ctx) {
r.get('/c/:username', async (req, res) => {
const { username } = req.params;
if (!isUsername(username)) return notFound(req, res);
-- const recaps = await live.channelRecaps(username);
++ const [recaps, viewRows] = await Promise.all([live.channelRecaps(username), viewsData ? viewsData.creator(username, 30) : null]);
if (!recaps) return notFound(req, res);
-- return send(req, res, views.channel({ username, recaps }));
++ return send(req, res, views.channel({ username, recaps, views: viewRows }));
});
return r;
ddiff --git a/server/views/index.js b/server/views/index.js
iindex c730601..f567952 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -305,7 +305,45 @@ function home({ stats, rundown, topics } = {}) {
});
}
--function stats({ stats: d, days = 30 } = {}) {
++// ── Views (OpenVibe.Media's one view store): per item, per creator and per site ──────────────
++// A row is { id, service?, kind?, item_id?, site, creator, views, engaged, previews, preview_share, peak_concurrent }.
++// Every number came from Media's GET /api/v1/views/aggregate; a missing dimension leaves its table out, never a zero.
++const pctShare = (v) => (v == null ? '—' : `${Math.round(Number(v) * 1000) / 10}%`);
++function viewRow(row, label, href) {
++ const name = href ? `<a href="${esc(href)}">${esc(label)}</a>` : esc(label || 'Unknown');
++ return `<div class="rp-tr" role="row"><span role="cell" class="rp-site">${name}<small>${esc(row.site || '')}</small></span>`
++ + `<span role="cell" data-l="Views">${exact(row.views)}</span>`
++ + `<span role="cell" data-l="Engaged">${exact(row.engaged)}</span>`
++ + `<span role="cell" data-l="From previews">${pctShare(row.preview_share)}</span>`
++ + `<span role="cell" data-l="Peak live">${row.peak_concurrent == null ? '—' : exact(row.peak_concurrent)}</span></div>`;
++}
++function viewTable(title, rows, kind) {
++ if (!rows || !rows.length) return '';
++ const body = rows.map((row) => {
++ if (kind === 'item') {
++ const name = `${row.kind} ${row.item_id}`;
++ const href = row.service === 'media' && row.kind === 'vod' ? `https://openvibe.media/v/${encodeURIComponent(row.item_id)}`
++ : row.service === 'media' && row.kind === 'clip' ? `https://openvibe.media/c/${encodeURIComponent(row.item_id)}` : null;
++ return viewRow(row, name, href);
++ }
++ if (kind === 'creator') return viewRow(row, row.creator || row.id || 'Unknown', row.creator ? `/c/${encodeURIComponent(row.creator)}` : null);
++ return viewRow(row, row.site || row.id || 'Unknown', null);
++ }).join('');
++ return `<section aria-labelledby="rp-v-${esc(kind)}"><h2 id="rp-v-${esc(kind)}">${esc(title)}</h2>
++<div class="rp-table" role="table" aria-label="${esc(title)}"><div class="rp-tr rp-th" role="row"><span role="columnheader">${kind === 'item' ? 'Item' : kind === 'creator' ? 'Creator' : 'Site'}</span><span role="columnheader">Views</span><span role="columnheader">Engaged</span><span role="columnheader">From previews</span><span role="columnheader">Peak live</span></div>${body}</div></section>`;
++}
++function viewsSection(v) {
++ if (!v) return '';
++ const items = v.items && v.items.rows || [];
++ const creators = v.creators && v.creators.rows || [];
++ const sites = v.sites && v.sites.rows || [];
++ if (!items.length && !creators.length && !sites.length) return '';
++ return `<section aria-labelledby="rp-views-h"><h2 id="rp-views-h">Views and engaged viewers</h2>
++<p class="rp-fine">Counted by OpenVibe.Media for every kind: a Feed preview counts from one second, once per person per item per 30 minutes, and a viewer is engaged after 30 seconds or a third of the video (60 seconds live). The peak is the most people watching a live stream at once. A preview is the share of views that started in the Feed.</p>
++${viewTable('Top items', items, 'item')}${viewTable('Top creators', creators, 'creator')}${viewTable('By site', sites, 'site')}</section>`;
++}
++
++function stats({ stats: d, days = 30, views: v = null } = {}) {
const T = d.totals || {}, L = d.live, C = d.coins, A = d.accounts;
const tabs = [7, 30, 90].map((x) => `<a href="/stats?days=${x}"${Number(days) === x ? ' aria-current="page"' : ''}>${x} days</a>`).join('');
const sites = (d.sites || []).filter((s) => s && s.name).map((s) => `<div class="rp-tr" role="row"><span role="cell" class="rp-site"><a href="${esc(s.url || '#')}">${esc(s.name || s.short)}</a><small>${esc(s.host || '')}</small></span>`
@@@ -323,6 +361,7 @@ ${T.requests_24h != null ? tile('Requests, 24 h', fmt(T.requests_24h), T.sites_o
${L ? tile('Live right now', exact(L.live_now), L.watching != null ? `${exact(L.watching)} watching` : '') : ''}
${C ? tile('OpenCoins earned', fmt(C.earned), C.earned_window != null ? `+${fmt(C.earned_window)} in ${d.window_days || days} days` : '') : ''}
</section>
++${viewsSection(v)}
${d.pageviews_daily && d.pageviews_daily.length > 1 ? `<section aria-labelledby="rp-pv-h"><h2 id="rp-pv-h">Page views a day</h2>${bars(d.pageviews_daily, 'Page views a day')}</section>` : ''}
${sites ? `<section aria-labelledby="rp-sites-h"><h2 id="rp-sites-h">Every site</h2><p class="rp-fine">Most visited first over ${esc(d.window_days || days)} days; requests and speed (95% of answers were at least this fast) over the last 24 hours.</p>
<div class="rp-table" role="table" aria-label="Every site"><div class="rp-tr rp-th" role="row"><span role="columnheader">Site</span><span role="columnheader">Page views</span><span role="columnheader">Visitors</span><span role="columnheader">Requests, 24 h</span><span role="columnheader">95% answered in</span></div>${sites}</div></section>` : ''}
@@@ -565,10 +604,11 @@ ${(c.top || []).length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Mo
return layout({ title: `${w.headline || s.title}: after-show report`, description: w.summary || `How ${who(u)}'s stream went, in numbers.`, body, active: '/rundown', path: `/after-show/${encodeURIComponent(s.id || '')}` });
}
--function channel({ username, recaps } = {}) {
++function channel({ username, recaps, views: v = null } = {}) {
const list = (recaps && recaps.recaps) || [];
const cards = list.slice(0, 30).map((x) => kit.card({ href: `/after-show/${x.stream_id}`, title: x.headline || x.title || 'Stream', peek: x.headline && x.title ? x.title : '', type: 'stream', image: x.thumbnail_url || '', badge: x.grade ? `Grade ${x.grade}` : '', corner: n(x.duration_seconds) ? mins(x.duration_seconds) : '', count: n(x.peak_viewers) ? [x.peak_viewers, 'peak'] : null, when: x.ended_at ? ts(x.ended_at) : null }));
const body = `<header class="rp-page-h"><p class="rp-kicker">After-show reports</p><h1>${esc(username)}</h1><p class="rp-meta"><a href="https://openvibe.live/@${encodeURIComponent(username)}">Channel on OpenVibe.Live</a> · <a href="/rundown/s/${encodeURIComponent(username)}">In The Rundown</a></p></header>
++${viewsSection(v && v.rows ? { creators: v } : null)}
${cards.length ? `<ul class="rp-grid">${cards.map((c) => `<li>${c}</li>`).join('')}</ul>` : kit.empty({ title: 'No reports yet', text: 'A report is written a few minutes after each stream ends.' })}`;
return layout({ title: `${username}: after-show reports`, description: `How each of ${username}'s streams went.`, body, active: '/rundown', path: `/c/${encodeURIComponent(username)}` });
}
ddiff --git a/test/views.test.js b/test/views.test.js
nnew file mode 100644
iindex 0000000..0e37eaa
---- /dev/null
++++ b/test/views.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++// Views in Report: the room quotes OpenVibe.Media's one view store (GET /api/v1/views/aggregate), never derives a
++// number. Per item, per creator and per site, with the Feed-preview share and a live stream's peak concurrent.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { createViewsClient } = require('../server/data/views');
++const views = require('../server/views');
++
++(async () => {
++ const calls = [];
++ const source = {
++ scope: 'item', days: 7, from: '2026-11-06', to: '2026-11-12',
++ rows: [{ id: 'media:vod:42', service: 'media', kind: 'vod', item_id: '42', site: 'network', creator: 'nadia', views: 120, engaged: 40, previews: 60, preview_share: 0.5, peak_concurrent: null }],
++ totals: { views: 120, engaged: 40, previews: 60, preview_share: 0.5, peak_concurrent: null },
++ };
++ const fetchImpl = async (url) => {
++ calls.push(String(url));
++ if (String(url).includes('offline.test')) return { ok: false };
++ return { ok: true, json: async () => source };
++ };
++ const client = createViewsClient({ config: { mediaUrl: 'http://media.test' }, fetchImpl });
++ const out = await client.item({ service: 'media', kind: 'vod', id: '42', days: 7 });
++ assert.strictEqual(out.rows[0].views, 120);
++ assert.ok(calls[0].startsWith('http://media.test/api/v1/views/aggregate?'));
++ assert.ok(calls[0].includes('scope=item') && calls[0].includes('days=7'));
++ await client.item({ service: 'media', kind: 'vod', id: '42', days: 7 });
++ assert.strictEqual(calls.length, 1, 'a second read is cached');
++ const top = await client.top(7);
++ assert.strictEqual(top.items.rows.length, 1);
++ assert.ok(calls.length >= 2);
++ const down = createViewsClient({ config: { mediaUrl: 'http://offline.test' }, fetchImpl });
++ assert.strictEqual(await down.creator('nadia', 7), null, 'a source that does not answer is null, not a zero');
++
++ // The room's tables show each dimension's numbers and the preview share.
++ const html = views.stats({
++ stats: { totals: { pageviews: 10 }, window_days: 7, sites: [] },
++ days: 7,
++ views: {
++ items: source,
++ creators: { rows: [{ id: 'nadia', creator: 'nadia', views: 120, engaged: 40, previews: 60, preview_share: 0.5, peak_concurrent: null }] },
++ sites: { rows: [{ id: 'network', site: 'network', views: 120, engaged: 40, previews: 60, preview_share: 0.5, peak_concurrent: 3 }] },
++ },
++ });
++ assert.ok(html.includes('Views and engaged viewers'), 'the room has a views section');
++ assert.ok(html.includes('Top items') && html.includes('Top creators') && html.includes('By site'));
++ assert.ok(html.includes('From previews') && html.includes('50%'), 'the Feed share is the number Media gave');
++ assert.ok(html.includes('Peak live'));
++
++ const channel = views.channel({ username: 'nadia', recaps: { recaps: [] }, views: { scope: 'creator', id: 'nadia', rows: source.rows } });
++ assert.ok(channel.includes('Views and engaged viewers'), 'a creator page shows their numbers');
++
++ // The API answers from the same source.
++ const fake = { item: async () => source, creator: async () => source, site: async () => source, top: async () => ({ items: source, creators: source, sites: source }) };
++ const app = express();
++ app.use('/api/v1', require('../server/http/api').createApi({ viewsData: fake, network: null, live: null, config: {} }));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/v1`;
++ let r = await fetch(`${base}/views?scope=item&service=media&kind=vod&id=42&days=7`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual((await r.json()).rows[0].views, 120);
++ r = await fetch(`${base}/views?scope=creator&id=nadia&days=30`);
++ assert.strictEqual(r.status, 200);
++ r = await fetch(`${base}/views/top?days=30`);
++ assert.strictEqual(r.status, 200);
++ r = await fetch(`${base}/views?scope=item&id=42`);
++ assert.strictEqual(r.status, 404);
++ server.close();
++ console.log('report views: ok');
++})().catch((error) => { console.error(error); process.exit(1); });