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
Changes
@@@ -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_idif (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); }@@@ -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'), asconst 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'), asuser_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) {@@@ -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);nnew file mode 100644@@@ -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);++})();
@@ -320,7 +320,19 @@ const LIST_ORDERS = {
@@ -320,7 +320,19 @@ const LIST_ORDERS = {
};
};
function _listOrder(order) { return LIST_ORDERS[order] || LIST_ORDERS.newest; }
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 conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
const params = [appId];
const params = [appId];
if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');
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
@@ -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 (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_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); }
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 };
return { conds, params };
}
}
@@ -508,10 +524,16 @@ function getClipById(id, appId = null) {
@@ -508,10 +524,16 @@ function getClipById(id, appId = null) {
return get(`SELECT * FROM clips WHERE id = ?${clause}`, params);
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 conds = ['app_id = ?'];
const params = [appId];
const params = [appId];
if (!include_private) conds.push('is_public = 1');
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 (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_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); }
if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
@@ -7,8 +7,8 @@
@@ -7,8 +7,8 @@
* completion. Also accepts a live cut against a still-recording
* completion. Also accepts a live cut against a still-recording
* VOD (clamped to flushed footage, like the predecessor).
* VOD (clamped to flushed footage, like the predecessor).
* GET /:id clip meta (status: processing | ready | failed)
* 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
* DELETE /:id delete file + offloaded objects + row
*/
*/
'use strict';
'use strict';
@@ -112,6 +112,8 @@ function _getClipScoped(req, res) {
@@ -112,6 +112,8 @@ function _getClipScoped(req, res) {
}
}
const VALID_VIS = new Set(['public', 'unlisted', 'private']);
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 ──────────────────────────────────────────────
// ── Create clip ──────────────────────────────────────────────
// JSON body: cut a window out of a VOD (202, background cut + webhook).
// 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
@@ -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 userId = body.user_id != null ? body.user_id : (req.userId ?? null);
const visibility = VALID_VIS.has(body.visibility) ? body.visibility : 'public';
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({
const result = db.createClip({
app_id: req.appId,
app_id: req.appId,
vod_id: vodId,
vod_id: vodId,
@@ -190,6 +196,8 @@ router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), as
@@ -190,6 +196,8 @@ router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), as
user_id: userId,
user_id: userId,
channel_user_id: body.channel_user_id != null ? body.channel_user_id : (vod.user_id ?? null),
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'),
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: '',
file_path: '',
start_time: startTime,
start_time: startTime,
end_time: endTime,
end_time: endTime,
@@ -297,7 +305,10 @@ async function _createUploadedClip(req, res) {
@@ -297,7 +305,10 @@ async function _createUploadedClip(req, res) {
// ── List ─────────────────────────────────────────────────────
// ── List ─────────────────────────────────────────────────────
// Filters follow the inherited query shapes: vod_id, stream_id, user_id
// Filters follow the inherited query shapes: vod_id, stream_id, user_id
// (creator), channel_user_id / source_streamer_id (owner of the clipped
// (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) => {
router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
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) => {
@@ -313,6 +324,9 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
// Only the owning app may list private/unlisted rows (see vods list).
// 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 || '')),
include_private: req.authType === 'app' && ['1', 'true'].includes(String(req.query.include_private || '')),
order: req.query.order || req.query.sort, // `sort` = inherited alias
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 clips = db.listClips(req.appId, filters);
const total = db.countClips(req.appId, filters);
const total = db.countClips(req.appId, filters);
@@ -368,13 +382,18 @@ router.put('/:id', tenantAuth(), (req, res) => {
@@ -368,13 +382,18 @@ router.put('/:id', tenantAuth(), (req, res) => {
try {
try {
const clip = _getClipScoped(req, res);
const clip = _getClipScoped(req, res);
if (!clip) return;
if (!clip) return;
- const { title, visibility } = req.body || {};
+ const { title, visibility, auto_generated: autoGen } = req.body || {};
if (title !== undefined) {
if (title !== undefined) {
const t = sanitizeClipTitle(title, '');
const t = sanitizeClipTitle(title, '');
if (!t) return res.status(400).json({ error: 'Title must be 1-200 characters' });
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]);
db.run('UPDATE clips SET title = ? WHERE id = ?', [t, clip.id]);
if (visibility === undefined) objects.safeSync('clip', 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);
if (visibility !== undefined) db.setClipVisibility(clip.id, visibility);
res.json({ clip: clipPublic(db.getClipById(clip.id, req.appId)) });
res.json({ clip: clipPublic(db.getClipById(clip.id, req.appId)) });
} catch (err) {
} catch (err) {
@@ -269,7 +269,7 @@ router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
@@ -269,7 +269,7 @@ router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
// ── List ─────────────────────────────────────────────────────
// ── List ─────────────────────────────────────────────────────
// Filters follow the inherited query shapes: user_id, stream_id,
// Filters follow the inherited query shapes: user_id, stream_id,
// managed_stream_id, include_private (private+unlisted too), order
// 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) => {
router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
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) => {
@@ -286,6 +286,8 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
// apps surface the live recording via their own dedicated card.
// apps surface the live recording via their own dedicated card.
includeRecording: ['1', 'true'].includes(String(req.query.include_recording || '')),
includeRecording: ['1', 'true'].includes(String(req.query.include_recording || '')),
order: req.query.order || req.query.sort, // `sort` = inherited alias
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 vods = db.listVods(req.appId, filters);
const total = db.countVods(req.appId, filters);
const total = db.countVods(req.appId, filters);
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -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);
+})();