openvibers/openvibe.media Commits

Daily stat series per app: GET /api/v1/:app/stats/series/:metric

OP OpenVibers committed 17 Sept 2026 · cc9d26d312 · 3 files, +74 −1

vods, clips, pastes and hours, zero-filled, with the total before the window
and the previous window's total — the history behind Live's hero stats.

Changes

ddiff --git a/server/db/database.js b/server/db/database.js
iindex 83a128c..5f8016a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -279,6 +279,33 @@ function getAppStats(appId) {
};
}
++// Daily values behind one of the stats above, for an app's "over time" charts. Missing days are
++// filled with 0; `before` is the total before the window so a running total can start from it.
++const STAT_SERIES = {
++ vods: { base: "FROM vods WHERE app_id = ? AND is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0", agg: 'COUNT(*)' },
++ clips: { base: 'FROM clips WHERE app_id = ? AND COALESCE(is_public,1) = 1', agg: 'COUNT(*)' },
++ pastes: { base: 'FROM pastes WHERE app_id = ?', agg: 'COUNT(*)' },
++ hours: { base: "FROM vods WHERE app_id = ? AND is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0", agg: 'COALESCE(SUM(duration_seconds),0) / 3600.0' },
++};
++function getAppStatSeries(appId, metric, days = 30) {
++ const def = STAT_SERIES[metric];
++ if (!def) return null;
++ days = Math.max(1, Math.min(365, parseInt(days, 10) || 30));
++ const since = `-${days - 1} days`;
++ const rows = all(`SELECT date(created_at) AS day, ${def.agg} AS value ${def.base} AND created_at >= date('now', ?) GROUP BY day`, [appId, since]);
++ const byDay = new Map(rows.map((r) => [r.day, Number(r.value) || 0]));
++ const points = [];
++ for (let i = days - 1; i >= 0; i--) {
++ const day = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
++ points.push({ day, value: Number((byDay.get(day) || 0).toFixed(2)) });
++ }
++ const one = (sql, p) => Number(get(sql, p)?.value) || 0;
++ const before = one(`SELECT ${def.agg} AS value ${def.base} AND created_at < date('now', ?)`, [appId, since]);
++ const prevTotal = one(`SELECT ${def.agg} AS value ${def.base} AND created_at >= date('now', ?) AND created_at < date('now', ?)`, [appId, `-${2 * days - 1} days`, since]);
++ const total = Number(points.reduce((n, p) => n + p.value, 0).toFixed(2));
++ return { metric, days, points, total, before: Number(before.toFixed(2)), prev_total: Number(prevTotal.toFixed(2)) };
++}
++
function countVods(appId, filters = {}) {
const { conds, params } = _vodConds(appId, filters);
return get(`SELECT COUNT(*) AS count FROM vods WHERE ${conds.join(' AND ')}`, params)?.count || 0;
@@@ -630,7 +657,7 @@ module.exports = {
hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
// vods
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
-- latestVodThumbsByManagedStreams, getAppStats,
++ latestVodThumbsByManagedStreams, getAppStats, getAppStatSeries,
upsertAsset, getAssetById, listAssets, countAssets, deleteAsset,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
// clips
ddiff --git a/server/index.js b/server/index.js
iindex 79238eb..a30a83e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -64,6 +64,18 @@ app.get('/api/v1/:app/stats', auth.tenantAuth(), (req, res) => {
}
});
++// Daily series behind a stat (vods, clips, pastes, hours) — "over time" charts in the owning app.
++app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth(), (req, res) => {
++ try {
++ const series = db.getAppStatSeries(req.appId, String(req.params.metric), req.query.days);
++ if (!series) return res.status(404).json({ error: 'Unknown metric' });
++ res.json(series);
++ } catch (err) {
++ console.error('[Stats] series error:', err.message);
++ res.status(500).json({ error: 'Failed to compute series' });
++ }
++});
++
try { require('./views/service').ensureSchema(); } catch (e) { console.warn('[Views] schema:', e.message); }
setInterval(() => { try { const n = require('./views/service').prune(30); if (n) console.log(`[Views] pruned ${n} stale visit row(s)`); } catch { /* */ } }, 12 * 3600 * 1000);
app.use('/api/v1/:app/views', require('./views/routes'));
ddiff --git a/test/stat-series.test.js b/test/stat-series.test.js
nnew file mode 100644
iindex 0000000..f292940
---- /dev/null
++++ b/test/stat-series.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++// Daily stat series for an app's "over time" charts (GET /api/v1/:app/stats/series/:metric).
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tmp = path.join(os.tmpdir(), `ov-media-series-${process.pid}.db`);
++process.env.DB_PATH = tmp;
++const db = require('../server/db/database');
++const raw = db.getDb();
++
++const vod = raw.prepare(`INSERT INTO vods (app_id, title, is_public, is_recording, duration_seconds, created_at) VALUES (?, 't', ?, 0, ?, datetime('now', ?))`);
++vod.run('live', 1, 7200, '-0 days');
++vod.run('live', 1, 3600, '-2 days');
++vod.run('live', 0, 3600, '-1 days'); // private: not counted
++vod.run('other', 1, 3600, '-1 days'); // another app: not counted
++vod.run('live', 1, 3600, '-10 days'); // before a 7-day window, inside the previous one
++vod.run('live', 1, 3600, '-40 days'); // before both
++
++const s = db.getAppStatSeries('live', 'vods', 7);
++assert.strictEqual(s.points.length, 7);
++assert.strictEqual(s.total, 2);
++assert.strictEqual(s.before, 2);
++assert.strictEqual(s.prev_total, 1);
++assert.strictEqual(s.points[s.points.length - 1].value, 1);
++const h = db.getAppStatSeries('live', 'hours', 7);
++assert.strictEqual(h.total, 3);
++assert.strictEqual(db.getAppStatSeries('live', 'nope', 7), null);
++assert.strictEqual(db.getAppStatSeries('live', 'vods', 9999).points.length, 365);
++
++for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
++console.log('stat series: all checks passed');
++process.exit(0);