openvibers/openvibe.report Commits
Report: views and engaged viewers per item, creator and site (#200)
Changes
@@@ -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@@@ -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 -->@@@ -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)"],@@@ -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",@@@ -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": {@@@ -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 ─────────────────────@@@ -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.nnew file mode 100644@@@ -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 };@@@ -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, analyticsreturn 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);@@@ -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;@@@ -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">Moreturn 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)}` });}nnew file mode 100644@@@ -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); });
@@ -22,6 +22,8 @@ OV_NETWORK_URL=https://openvibe.network
@@ -22,6 +22,8 @@ OV_NETWORK_URL=https://openvibe.network
# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).
# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
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.
# Bench's public read API; set to an internal origin if Report and Bench share one.
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
@@ -261,8 +261,8 @@ HMAC. See [SECURITY.md](SECURITY.md).
@@ -261,8 +261,8 @@ HMAC. See [SECURITY.md](SECURITY.md).
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.416.0
- 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
- openvibe-publishing: v1.7.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,13 +9,13 @@
@@ -9,13 +9,13 @@
"port": "5080",
"port": "5080",
"domain": "openvibe.report",
"domain": "openvibe.report",
"contracts": "openvibe-contracts v0.416.0",
"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": [
"packages": [
"openvibe-contracts v0.416.0",
"openvibe-contracts v0.416.0",
"openvibe-publishing v1.7.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",
"pg v8.23.1",
"pglite v0.5.13 (dev)"
"pglite v0.5.13 (dev)"
],
],
@@ -15,8 +15,8 @@
@@ -15,8 +15,8 @@
"helmet": "^8.1.0",
"helmet": "^8.1.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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.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"
"pg": "^8.23.1"
},
},
"devDependencies": {
"devDependencies": {
@@ -1172,9 +1172,9 @@
@@ -1172,9 +1172,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1200,9 +1200,9 @@
@@ -1200,9 +1200,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",
@@ -21,8 +21,8 @@
@@ -21,8 +21,8 @@
"helmet": "^8.1.0",
"helmet": "^8.1.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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.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"
"pg": "^8.23.1"
},
},
"engines": {
"engines": {
@@ -48,6 +48,7 @@ const sharing = require('./sharing');
@@ -48,6 +48,7 @@ const sharing = require('./sharing');
const { createShadow } = require('./analytics/shadow');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes, createPublicPages } = require('./http/pages');
const { createPageRoutes, createPublicPages } = require('./http/pages');
+const { createViewsClient } = require('./data/views');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createInternalRoutes } = require('./http/internal');
const { createInternalRoutes } = require('./http/internal');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
@@ -129,7 +130,8 @@ async function createApp(opts = {}) {
@@ -129,7 +130,8 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
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();
const app = express();
if (exchangeConfig) exchangeConfig.mount(app);
if (exchangeConfig) exchangeConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// 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 = {}) {
@@ -327,7 +329,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
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 }));
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 ─────────────────────
// ── Crawl artifacts, then the pages ─────────────────────
@@ -46,6 +46,8 @@ function load(env = process.env) {
@@ -46,6 +46,8 @@ function load(env = process.env) {
networkPublicUrl,
networkPublicUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
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'),
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
// 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.
// boards render here; Bench stays the benchmark authority that owns the numbers.
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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 };
@@ -32,7 +32,7 @@ const sharing = require('../sharing');
@@ -32,7 +32,7 @@ const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
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 r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
const serviceAuth = createServiceAuth({ config, now });
@@ -94,6 +94,36 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -94,6 +94,36 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
return problem(req, res, 404, code, detail);
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) => {
r.get('/stats', async (req, res) => {
const days = normaliseDays(req.query.days);
const days = normaliseDays(req.query.days);
const stats = analytics ? await analytics.stats(days) : await network.stats(days);
const stats = analytics ? await analytics.stats(days) : await network.stats(days);
@@ -38,7 +38,7 @@ const aiComparisonViews = require('../views/ai-comparison');
@@ -38,7 +38,7 @@ const aiComparisonViews = require('../views/ai-comparison');
const CACHE = 'public, max-age=120';
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). */
/** 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));
const numbers = (days) => (analytics ? analytics.stats(days) : network.stats(days));
async function home() {
async function home() {
@@ -48,8 +48,8 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
@@ -48,8 +48,8 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
}
}
async function stats(days) {
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() {
function developers() {
@@ -274,7 +274,7 @@ function rssFeed({ runs, overview, base }) {
@@ -274,7 +274,7 @@ function rssFeed({ runs, overview, base }) {
}
}
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { live, bench, sessionReports } = ctx;
+ const { live, bench, sessionReports, viewsData } = ctx;
const r = express.Router();
const r = express.Router();
const publicPages = ctx.publicPages || createPublicPages(ctx);
const publicPages = ctx.publicPages || createPublicPages(ctx);
// The Rundown's pages canonicalise to OpenVibe.Live, and so does its Markdown twin: the generic agent layer
// 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) {
@@ -503,9 +503,9 @@ function createPageRoutes(ctx) {
r.get('/c/:username', async (req, res) => {
r.get('/c/:username', async (req, res) => {
const { username } = req.params;
const { username } = req.params;
if (!isUsername(username)) return notFound(req, res);
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);
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;
return r;
@@ -305,7 +305,45 @@ function home({ stats, rundown, topics } = {}) {
@@ -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 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 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>`
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
@@ -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` : '') : ''}
${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` : '') : ''}
${C ? tile('OpenCoins earned', fmt(C.earned), C.earned_window != null ? `+${fmt(C.earned_window)} in ${d.window_days || days} days` : '') : ''}
</section>
</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>` : ''}
${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>
${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>` : ''}
<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
@@ -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 || '')}` });
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 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 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>
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.' })}`;
${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)}` });
return layout({ title: `${username}: after-show reports`, description: `How each of ${username}'s streams went.`, body, active: '/rundown', path: `/c/${encodeURIComponent(username)}` });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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); });