openvibers/openvibe.media Commits
Daily stat series per app: GET /api/v1/:app/stats/series/:metric
Changes
@@@ -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,// vodscreateVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,-- latestVodThumbsByManagedStreams, getAppStats,++ latestVodThumbsByManagedStreams, getAppStats, getAppStatSeries,upsertAsset, getAssetById, listAssets, countAssets, deleteAsset,updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,// clips@@@ -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'));nnew file mode 100644@@@ -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);
@@ -279,6 +279,33 @@ function getAppStats(appId) {
@@ -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 = {}) {
function countVods(appId, filters = {}) {
const { conds, params } = _vodConds(appId, filters);
const { conds, params } = _vodConds(appId, filters);
return get(`SELECT COUNT(*) AS count FROM vods WHERE ${conds.join(' AND ')}`, params)?.count || 0;
return get(`SELECT COUNT(*) AS count FROM vods WHERE ${conds.join(' AND ')}`, params)?.count || 0;
@@ -630,7 +657,7 @@ module.exports = {
@@ -630,7 +657,7 @@ module.exports = {
hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
// vods
// vods
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
- latestVodThumbsByManagedStreams, getAppStats,
+ latestVodThumbsByManagedStreams, getAppStats, getAppStatSeries,
upsertAsset, getAssetById, listAssets, countAssets, deleteAsset,
upsertAsset, getAssetById, listAssets, countAssets, deleteAsset,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
// clips
// clips
@@ -64,6 +64,18 @@ app.get('/api/v1/:app/stats', auth.tenantAuth(), (req, res) => {
@@ -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); }
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);
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'));
app.use('/api/v1/:app/views', require('./views/routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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);