openvibers/openvibe.media Commits

Clips say who made them: POST /clips keeps auto_generated and the description when the app itself asks (an app-key call with no X-OV-User-Id), and never when the call acts for one of the app's users. Both fields were dropped, so every AI auto-clip Live cut since the split was stored as a person's clip. PUT /clips/:id lets the app mark an existing clip machine-made or not (403 when acting for a user), so Live can correct the ones it already made from its own log

OP OpenVibers committed 23 Sept 2026 · ee39be1ce0 · 4 files, +188 −7

Lists: GET /clips?auto_generated=1|0 (automation's clips or people's), ?status=ready (playable only: no clip still cutting or failed; imported rows without a status count as ready) and ?since= on /clips and /vods (created at or after; ISO 8601 or 'YYYY-MM-DD HH:MM:SS' UTC, compared with datetime() so imported ISO timestamps work; an unreadable value is ignored). The total counts the same filters. Private rows stay out of public lists as before. For Live's Content (people's work) and Moments (AI) feeds and their "top this week/month" windows

test/clip-origin-filters.test.js covers the split, privacy, status, since on both lists with views order, the create path for the app and for an acting user, and the PUT permission. The README rows for these routes landed with ad2217b

Changes

ddiff --git a/server/db/database.js b/server/db/database.js
iindex a09bf2c..9b0daf0 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -320,7 +320,19 @@ const LIST_ORDERS = {
};
function _listOrder(order) { return LIST_ORDERS[order] || LIST_ORDERS.newest; }
--function _vodConds(appId, { user_id = null, stream_id = null, managed_stream_id = null, include_private = false, includeRecording = true } = {}) {
++/**
++ * A list's `since` filter as an SQLite datetime ('YYYY-MM-DD HH:MM:SS', UTC), or null. Accepts that
++ * form or anything Date.parse reads (ISO 8601); anything else is ignored rather than refused.
++ */
++function sinceParam(value) {
++ if (value == null || value === '') return null;
++ const s = String(value).trim();
++ if (/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/.test(s)) return s.length === 10 ? `${s} 00:00:00` : s;
++ const t = Date.parse(s);
++ return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
++}
++
++function _vodConds(appId, { user_id = null, stream_id = null, managed_stream_id = null, include_private = false, includeRecording = true, since = null } = {}) {
const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
const params = [appId];
if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');
@@@ -328,6 +340,10 @@ function _vodConds(appId, { user_id = null, stream_id = null, managed_stream_id
if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
if (managed_stream_id != null) { conds.push('managed_stream_id = ?'); params.push(managed_stream_id); }
++ // Created at or after this moment (the "top this week" windows). datetime() on both sides so
++ // an imported row stored as ISO text compares by time, not by string.
++ const after = sinceParam(since);
++ if (after) { conds.push('datetime(created_at) >= datetime(?)'); params.push(after); }
return { conds, params };
}
@@@ -508,10 +524,16 @@ function getClipById(id, appId = null) {
return get(`SELECT * FROM clips WHERE id = ?${clause}`, params);
}
--function _clipConds(appId, { vod_id = null, stream_id = null, user_id = null, channel_user_id = null, include_private = false, hide_self = false } = {}) {
++function _clipConds(appId, { vod_id = null, stream_id = null, user_id = null, channel_user_id = null, include_private = false, hide_self = false, auto_generated = null, ready_only = false, since = null } = {}) {
const conds = ['app_id = ?'];
const params = [appId];
if (!include_private) conds.push('is_public = 1');
++ // Made by the app's own automation (1) or by a person (0); null = both.
++ if (auto_generated === 0 || auto_generated === 1) { conds.push('COALESCE(auto_generated, 0) = ?'); params.push(auto_generated); }
++ // Only clips that can be played: not still cutting, not failed. Imported rows have no status.
++ if (ready_only) conds.push("COALESCE(status, 'ready') = 'ready'");
++ const after = sinceParam(since);
++ if (after) { conds.push('datetime(created_at) >= datetime(?)'); params.push(after); }
if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex bcf46e4..539a546 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -7,8 +7,8 @@
* completion. Also accepts a live cut against a still-recording
* VOD (clamped to flushed footage, like the predecessor).
* GET /:id clip meta (status: processing | ready | failed)
-- * GET / list (?limit&offset&vod_id&stream_id&user_id)
-- * PUT /:id update title/visibility
++ * GET / list (?limit&offset&vod_id&stream_id&user_id&auto_generated&status&since)
++ * PUT /:id update title/visibility (and auto_generated, app key only)
* DELETE /:id delete file + offloaded objects + row
*/
'use strict';
@@@ -112,6 +112,8 @@ function _getClipScoped(req, res) {
}
const VALID_VIS = new Set(['public', 'unlisted', 'private']);
++/** A boolean query/body field: 1, 0, or null when absent or unreadable. */
++const flag = (v) => (['1', 'true'].includes(String(v)) ? 1 : ['0', 'false'].includes(String(v)) ? 0 : null);
// ── Create clip ──────────────────────────────────────────────
// JSON body: cut a window out of a VOD (202, background cut + webhook).
@@@ -183,6 +185,10 @@ router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), as
const userId = body.user_id != null ? body.user_id : (req.userId ?? null);
const visibility = VALID_VIS.has(body.visibility) ? body.visibility : 'public';
++ // The app's own automation (an AI auto-clip) says so with auto_generated; a call acting for
++ // one of its users is a person's clip whatever it claims. The flag and the description used
++ // to be dropped here, so every auto-clip was stored as if a person had made it.
++ const byApp = req.authType === 'app';
const result = db.createClip({
app_id: req.appId,
vod_id: vodId,
@@@ -190,6 +196,8 @@ router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), as
user_id: userId,
channel_user_id: body.channel_user_id != null ? body.channel_user_id : (vod.user_id ?? null),
title: sanitizeClipTitle(body.title, vod.title ? `Clip: ${vod.title}`.slice(0, 200) : 'Untitled Clip'),
++ description: typeof body.description === 'string' ? body.description.replace(/<[^>]*>/g, '').trim().slice(0, 1000) : '',
++ auto_generated: byApp && flag(body.auto_generated) === 1,
file_path: '',
start_time: startTime,
end_time: endTime,
@@@ -297,7 +305,10 @@ async function _createUploadedClip(req, res) {
// ── List ─────────────────────────────────────────────────────
// Filters follow the inherited query shapes: vod_id, stream_id, user_id
// (creator), channel_user_id / source_streamer_id (owner of the clipped
--// channel), hide_self, include_private, order, limit/offset.
++// channel), hide_self, include_private, order, limit/offset. Also:
++// auto_generated=1|0 clips the app's automation cut (1) or a person made (0)
++// status=ready playable clips only (no processing / failed rows)
++// since=<datetime> created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS', UTC)
router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
@@@ -313,6 +324,9 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
// Only the owning app may list private/unlisted rows (see vods list).
include_private: req.authType === 'app' && ['1', 'true'].includes(String(req.query.include_private || '')),
order: req.query.order || req.query.sort, // `sort` = inherited alias
++ auto_generated: flag(req.query.auto_generated),
++ ready_only: String(req.query.status || '') === 'ready',
++ since: req.query.since || null,
};
const clips = db.listClips(req.appId, filters);
const total = db.countClips(req.appId, filters);
@@@ -368,13 +382,18 @@ router.put('/:id', tenantAuth(), (req, res) => {
try {
const clip = _getClipScoped(req, res);
if (!clip) return;
-- const { title, visibility } = req.body || {};
++ const { title, visibility, auto_generated: autoGen } = req.body || {};
if (title !== undefined) {
const t = sanitizeClipTitle(title, '');
if (!t) return res.status(400).json({ error: 'Title must be 1-200 characters' });
db.run('UPDATE clips SET title = ? WHERE id = ?', [t, clip.id]);
if (visibility === undefined) objects.safeSync('clip', clip.id);
}
++ // Only the app itself (its automation's own records) may say a clip was machine-made.
++ if (autoGen !== undefined && flag(autoGen) !== null) {
++ if (req.authType !== 'app') return res.status(403).json({ error: 'auto_generated is set by the app only' });
++ db.run('UPDATE clips SET auto_generated = ? WHERE id = ?', [flag(autoGen), clip.id]);
++ }
if (visibility !== undefined) db.setClipVisibility(clip.id, visibility);
res.json({ clip: clipPublic(db.getClipById(clip.id, req.appId)) });
} catch (err) {
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex 0a9e39a..9539133 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -269,7 +269,7 @@ router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
// ── List ─────────────────────────────────────────────────────
// Filters follow the inherited query shapes: user_id, stream_id,
// managed_stream_id, include_private (private+unlisted too), order
--// (newest|oldest|views|peak_viewers), limit/offset.
++// (newest|oldest|views|peak_viewers), since (created at or after), limit/offset.
router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
@@@ -286,6 +286,8 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
// apps surface the live recording via their own dedicated card.
includeRecording: ['1', 'true'].includes(String(req.query.include_recording || '')),
order: req.query.order || req.query.sort, // `sort` = inherited alias
++ // Created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS', UTC): "top this week".
++ since: req.query.since || null,
};
const vods = db.listVods(req.appId, filters);
const total = db.countVods(req.appId, filters);
ddiff --git a/test/clip-origin-filters.test.js b/test/clip-origin-filters.test.js
nnew file mode 100644
iindex 0000000..bcb2b01
---- /dev/null
++++ b/test/clip-origin-filters.test.js
@@@ -0,0 +1,138 @@
++'use strict';
++// Clip origin and list windows (Live's Content and Moments feeds).
++// - POST /clips keeps auto_generated (and the description) when the app itself asks, and never
++// when the call acts for one of the app's users; it used to drop both, so every AI auto-clip
++// was stored as a person's clip.
++// - PUT /clips/:id lets the app mark an existing clip machine-made (its own records), not a user.
++// - GET /clips?auto_generated=0|1 splits people's clips from automation's, ?status=ready leaves
++// out clips still cutting or failed, and ?since= (clips and VODs) bounds a "top this week" list.
++// - None of it opens a private clip to a public list.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-clip-origin-'));
++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++process.env.DB_PATH = path.join(tmp, 'media.db');
++process.env.VOD_PATH = dir('vods');
++process.env.CLIPS_PATH = dir('clips');
++process.env.FILES_PATH = dir('files');
++process.env.OBJECTS_PATH = dir('objects');
++process.env.THUMBNAILS_PATH = dir('thumbnails');
++process.env.PASTES_PATH = dir('pastes');
++process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++delete process.env.EVENTS_URL;
++console.log = () => {};
++console.warn = () => {};
++
++const db = require('../server/db/database');
++db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++const raw = db.getDb();
++// The background cut is not what this checks: fail it at once, without ffmpeg.
++require('../server/vod/clip-cutter').cutClipFile = async () => ({ ok: false, error: 'not in this test' });
++
++const vodFile = path.join(process.env.VOD_PATH, 'vod-1.webm');
++fs.writeFileSync(vodFile, Buffer.alloc(64, 1));
++const daysAgo = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);
++raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds, view_count, created_at)
++ VALUES (1, 'live', 5, 'Fresh', ?, 1, 'public', 600, 3, ?)`).run(vodFile, daysAgo(1));
++raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds, view_count, created_at)
++ VALUES (2, 'live', 5, 'Old', ?, 1, 'public', 600, 90, ?)`).run(vodFile, daysAgo(40));
++// An imported row keeps its ISO timestamp: `since` must still compare it by time.
++raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds, view_count, created_at)
++ VALUES (3, 'live', 5, 'Imported', ?, 1, 'public', 600, 1, ?)`).run(vodFile, new Date(Date.now() - 2 * 86400_000).toISOString());
++const insClip = raw.prepare(`INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, is_public, visibility, status, auto_generated, view_count, created_at)
++ VALUES (?, 'live', 1, 5, ?, 'x.webm', ?, ?, ?, ?, ?, ?)`);
++insClip.run(10, 'Person, public', 1, 'public', 'ready', 0, 5, daysAgo(1));
++insClip.run(11, 'AI, public', 1, 'public', 'ready', 1, 9, daysAgo(2));
++insClip.run(12, 'AI, private', 0, 'private', 'ready', 1, 0, daysAgo(1));
++insClip.run(13, 'Person, failed cut', 1, 'public', 'failed', 0, 0, daysAgo(1));
++insClip.run(14, 'Person, old', 1, 'public', 'ready', 0, 50, daysAgo(40));
++insClip.run(15, 'Imported, no status', 1, 'public', null, 0, 1, daysAgo(3));
++
++const express = require('express');
++const app = express();
++app.use(express.json());
++app.use('/api/v1/:app/vods', require('../server/vod/routes'));
++app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
++const server = http.createServer(app);
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/v1/live`;
++ const call = async (method, p, { body, user } = {}) => {
++ const headers = { authorization: 'Bearer live-key' };
++ if (user) headers['x-ov-user-id'] = String(user);
++ if (body !== undefined) headers['content-type'] = 'application/json';
++ const res = await fetch(base + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ };
++ const ids = (r) => r.json.clips.map((c) => c.id).sort((a, b) => a - b);
++ let failures = 0;
++ const check = async (name, fn) => {
++ try { await fn(); process.stdout.write(` ok - ${name}\n`); }
++ catch (e) { failures++; process.stdout.write(` FAIL - ${name}: ${e.message}\n`); }
++ };
++
++ await check('auto_generated=0|1 splits the public list; private rows stay out of both', async () => {
++ assert.deepStrictEqual(ids(await call('GET', '/clips?auto_generated=0')), [10, 13, 14, 15]);
++ assert.deepStrictEqual(ids(await call('GET', '/clips?auto_generated=1')), [11]);
++ assert.deepStrictEqual(ids(await call('GET', '/clips?auto_generated=true')), [11]);
++ assert.deepStrictEqual(ids(await call('GET', '/clips')), [10, 11, 13, 14, 15], 'no filter: both kinds, as before');
++ const r = await call('GET', '/clips?auto_generated=1');
++ assert.strictEqual(r.json.total, 1, 'the total counts the same filter');
++ assert.strictEqual(r.json.clips[0].auto_generated, true);
++ });
++
++ await check('status=ready leaves out failed cuts and keeps imported rows without a status', async () => {
++ assert.deepStrictEqual(ids(await call('GET', '/clips?status=ready&auto_generated=0')), [10, 14, 15]);
++ });
++
++ await check('since bounds clips and VODs; views order holds inside the window', async () => {
++ const week = new Date(Date.now() - 7 * 86400_000).toISOString();
++ assert.deepStrictEqual(ids(await call('GET', `/clips?since=${encodeURIComponent(week)}&status=ready`)), [10, 11, 15]);
++ const top = await call('GET', `/clips?since=${encodeURIComponent(week)}&order=views&status=ready`);
++ assert.deepStrictEqual(top.json.clips.map((c) => c.id), [11, 10, 15]);
++ const vods = await call('GET', `/vods?since=${encodeURIComponent(week)}&order=views`);
++ assert.deepStrictEqual(vods.json.vods.map((v) => v.id), [1, 3], 'the 40-day-old VOD is out, the ISO-dated one is in');
++ assert.strictEqual(vods.json.total, 2);
++ const sqlForm = await call('GET', `/vods?since=${encodeURIComponent(daysAgo(7))}`);
++ assert.strictEqual(sqlForm.json.total, 2, "'YYYY-MM-DD HH:MM:SS' works too");
++ const junk = await call('GET', '/vods?since=not-a-date');
++ assert.strictEqual(junk.json.total, 3, 'an unreadable since is ignored, not a 500');
++ });
++
++ await check('POST /clips keeps auto_generated and the description for the app itself', async () => {
++ const r = await call('POST', '/clips', { body: { vod_id: 1, start_s: 10, end_s: 30, title: 'AI pick', auto_generated: true, description: 'A <b>big</b> moment' } });
++ assert.strictEqual(r.status, 202, JSON.stringify(r.json));
++ const row = db.getClipById(r.json.id);
++ assert.strictEqual(row.auto_generated, 1);
++ assert.strictEqual(row.description, 'A big moment');
++ });
++
++ await check('POST /clips acting for a user is a person\'s clip, whatever it claims', async () => {
++ const r = await call('POST', '/clips', { user: 7, body: { vod_id: 1, start_s: 100, end_s: 120, title: 'Mine', auto_generated: true } });
++ assert.strictEqual(r.status, 202, JSON.stringify(r.json));
++ assert.strictEqual(db.getClipById(r.json.id).auto_generated, 0);
++ });
++
++ await check('PUT /clips/:id: the app may mark a clip machine-made; a user may not', async () => {
++ const denied = await call('PUT', '/clips/10', { user: 5, body: { auto_generated: true } });
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(db.getClipById(10).auto_generated, 0);
++ const ok = await call('PUT', '/clips/10', { body: { auto_generated: true } });
++ assert.strictEqual(ok.status, 200);
++ assert.strictEqual(ok.json.clip.auto_generated, true);
++ const back = await call('PUT', '/clips/10', { body: { auto_generated: 0 } });
++ assert.strictEqual(back.json.clip.auto_generated, false);
++ const title = await call('PUT', '/clips/10', { user: 5, body: { title: 'Renamed' } });
++ assert.strictEqual(title.status, 200, 'other fields still work for users');
++ });
++
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ process.stdout.write(failures ? `\n${failures} check(s) failed\n` : '\nclip origin filters: all checks passed\n');
++ process.exit(failures ? 1 : 0);
++})();