openvibers/openvibe.media Commits
Public clip lists show only clips that play (status=any for all), and scripts/repair-edge-clips.js re-cuts the clips the old edge squeeze broke (#185)
Changes
nnew file mode 100644@@@ -0,0 +1,61 @@++#!/usr/bin/env node++'use strict';++/**++ * Repair the clips the old "past the recorded edge" squeeze broke, for the operator.++ *++ * Before Media#182, a live clip asked for 30 s past a recording whose newest keyframe was older had its start pushed up to++ * the edge: a 1 s window at the edge, whose cut failed (clips 493-498 of VOD 6010, 2026-10-26: "Clip: Seattle", "@Patrick",++ * "Dude don't bah", listed on Live's home as broken cards). Since #182 the window keeps its length and ends at the edge.++ * This gives each such clip that window: the length it was asked for (--seconds, 30) ending at its end, and makes it++ * virtual over its VOD's timeline at once (as POST /clips/:id/recut does), or queues a cut where the VOD has no timeline.++ *++ * node scripts/repair-edge-clips.js [--max-window 2] [--seconds 30] [--ids 493,494] [--apply]++ * Dry run (the default): lists the failed clips whose window is at most --max-window seconds, with the window a++ * repair gives them. --apply repairs them: the row's window and its object in one transaction, then the clip turns++ * virtual (clip.ready) or is queued a cut (clip.cut). The failed cut's leftover file is left to the orphans report.++ * (the database is the service's: DATABASE_URL, and MEDIA_HLS_ENABLED, from its env file)++ *++ * Exit codes: 0 done, 1 error, 2 bad usage.++ */++++(async () => {++ const args = process.argv.slice(2);++ const has = (name) => args.includes(`--${name}`);++ const arg = (name, d = null) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };++ const usage = (msg) => { console.error(`repair-edge-clips: ${msg}`); process.exit(2); };++ for (const flag of ['max-window', 'seconds', 'ids']) if (has(flag) && !arg(flag)) usage(`--${flag} needs a value`);++ const APPLY = has('apply');++ const maxWindow = Number(arg('max-window', 2));++ const seconds = Number(arg('seconds', 30));++ if (!(maxWindow > 0) || !(seconds >= 1)) usage('--max-window and --seconds are positive numbers');++ const ids = arg('ids') ? arg('ids').split(',').map(Number).filter((n) => n > 0) : null;++++ await require('../server/db/database').initDb(); // PostgreSQL (DATABASE_URL), as the service++ const db = require('../server/db/database');++ const model = require('../server/objects/model');++ const clipJobs = require('../server/vod/clip-jobs');++ if (APPLY) await require('../server/events').initWriter();++++ const rows = await db.all(`SELECT c.id, c.vod_id, c.title, c.start_time, c.end_time, v.duration_seconds AS vod_seconds++ FROM clips c JOIN vods v ON v.id = c.vod_id++ WHERE c.status = 'failed' AND (c.end_time - c.start_time) <= ? ${ids ? 'AND c.id = ANY(?)' : ''} ORDER BY c.id`, ids ? [maxWindow, ids] : [maxWindow]);++ if (!rows.length) { console.log('repair-edge-clips: nothing to repair'); process.exit(0); }++ let repaired = 0;++ for (const r of rows) {++ const end = Math.min(Number(r.end_time), Number(r.vod_seconds) || Number(r.end_time));++ const start = Math.max(0, end - seconds);++ console.log(`clip ${r.id} (vod ${r.vod_id}, "${String(r.title || '').slice(0, 40)}"): ${Number(r.start_time).toFixed(1)}-${Number(r.end_time).toFixed(1)} s → ${start.toFixed(1)}-${end.toFixed(1)} s`);++ if (!APPLY) continue;++ await model.withObject('clip', r.id, async () => await db.run(`UPDATE clips SET start_time = ?, end_time = ?, duration_seconds = ?, file_path = NULL,++ cut_error = NULL, cut_attempts = 0, cut_next_at = NULL WHERE id = ?`, [start, end, end - start, r.id]));++ // The same recovery as POST /clips/:id/recut: virtual where its VOD has a timeline, else a cut.++ const win = await clipJobs.timelineWindow(await db.getVodById(r.vod_id), start, end);++ const virtual = !!win;++ if (win) await clipJobs.makeVirtual(r.id, win);++ else await clipJobs.recutClip(r.id, { reason: 'edge repair' });++ console.log(` ${virtual ? 'virtual over its VOD\'s timeline' : 'queued a cut'}`);++ repaired++;++ }++ console.log(APPLY ? `repair-edge-clips: ${repaired} repaired` : `repair-edge-clips: ${rows.length} to repair (dry run; --apply repairs them)`);++ process.exit(0);++})().catch((err) => { console.error('repair-edge-clips:', err.message); process.exit(1); });@@@ -415,7 +415,10 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)include_private: req.authType === 'app' && ['1', 'true'].includes(String(req.query.include_private || '')),order: req.query.order || req.query.sort, // `sort` = inherited aliasauto_generated: flag(req.query.auto_generated),-- ready_only: String(req.query.status || '') === 'ready',++ // A public list (not the owning app's private one) shows only clips that play: a failed or unfinished cut++ // was a broken card on Live's home (clips 493-498, 2026-10-27). status=any asks for every one.++ ready_only: String(req.query.status || '') === 'ready'++ || (String(req.query.status || '') !== 'any' && !(req.authType === 'app' && ['1', 'true'].includes(String(req.query.include_private || '')))),since: req.query.since || null,};const clips = await db.listClips(req.appId, filters);@@@ -77,10 +77,14 @@ const http = require('http');};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=0')), [10, 14, 15], 'a public list shows clips that play (13 failed)');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');++ assert.deepStrictEqual(ids(await call('GET', '/clips')), [10, 11, 14, 15], 'no filter: both kinds, playable ones');++ // A failed cut was a broken card on Live's home (clips 493-498, 2026-10-27): only status=any or the owning++ // app's private list still names it.++ assert.deepStrictEqual(ids(await call('GET', '/clips?status=any')), [10, 11, 13, 14, 15], 'status=any: every public clip');++ assert.ok(ids(await call('GET', '/clips?include_private=1')).includes(13), 'the app\'s own list keeps its failed cuts');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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Repair the clips the old "past the recorded edge" squeeze broke, for the operator.
+ *
+ * Before Media#182, a live clip asked for 30 s past a recording whose newest keyframe was older had its start pushed up to
+ * the edge: a 1 s window at the edge, whose cut failed (clips 493-498 of VOD 6010, 2026-10-26: "Clip: Seattle", "@Patrick",
+ * "Dude don't bah", listed on Live's home as broken cards). Since #182 the window keeps its length and ends at the edge.
+ * This gives each such clip that window: the length it was asked for (--seconds, 30) ending at its end, and makes it
+ * virtual over its VOD's timeline at once (as POST /clips/:id/recut does), or queues a cut where the VOD has no timeline.
+ *
+ * node scripts/repair-edge-clips.js [--max-window 2] [--seconds 30] [--ids 493,494] [--apply]
+ * Dry run (the default): lists the failed clips whose window is at most --max-window seconds, with the window a
+ * repair gives them. --apply repairs them: the row's window and its object in one transaction, then the clip turns
+ * virtual (clip.ready) or is queued a cut (clip.cut). The failed cut's leftover file is left to the orphans report.
+ * (the database is the service's: DATABASE_URL, and MEDIA_HLS_ENABLED, from its env file)
+ *
+ * Exit codes: 0 done, 1 error, 2 bad usage.
+ */
+
+(async () => {
+ const args = process.argv.slice(2);
+ const has = (name) => args.includes(`--${name}`);
+ const arg = (name, d = null) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
+ const usage = (msg) => { console.error(`repair-edge-clips: ${msg}`); process.exit(2); };
+ for (const flag of ['max-window', 'seconds', 'ids']) if (has(flag) && !arg(flag)) usage(`--${flag} needs a value`);
+ const APPLY = has('apply');
+ const maxWindow = Number(arg('max-window', 2));
+ const seconds = Number(arg('seconds', 30));
+ if (!(maxWindow > 0) || !(seconds >= 1)) usage('--max-window and --seconds are positive numbers');
+ const ids = arg('ids') ? arg('ids').split(',').map(Number).filter((n) => n > 0) : null;
+
+ await require('../server/db/database').initDb(); // PostgreSQL (DATABASE_URL), as the service
+ const db = require('../server/db/database');
+ const model = require('../server/objects/model');
+ const clipJobs = require('../server/vod/clip-jobs');
+ if (APPLY) await require('../server/events').initWriter();
+
+ const rows = await db.all(`SELECT c.id, c.vod_id, c.title, c.start_time, c.end_time, v.duration_seconds AS vod_seconds
+ FROM clips c JOIN vods v ON v.id = c.vod_id
+ WHERE c.status = 'failed' AND (c.end_time - c.start_time) <= ? ${ids ? 'AND c.id = ANY(?)' : ''} ORDER BY c.id`, ids ? [maxWindow, ids] : [maxWindow]);
+ if (!rows.length) { console.log('repair-edge-clips: nothing to repair'); process.exit(0); }
+ let repaired = 0;
+ for (const r of rows) {
+ const end = Math.min(Number(r.end_time), Number(r.vod_seconds) || Number(r.end_time));
+ const start = Math.max(0, end - seconds);
+ console.log(`clip ${r.id} (vod ${r.vod_id}, "${String(r.title || '').slice(0, 40)}"): ${Number(r.start_time).toFixed(1)}-${Number(r.end_time).toFixed(1)} s → ${start.toFixed(1)}-${end.toFixed(1)} s`);
+ if (!APPLY) continue;
+ await model.withObject('clip', r.id, async () => await db.run(`UPDATE clips SET start_time = ?, end_time = ?, duration_seconds = ?, file_path = NULL,
+ cut_error = NULL, cut_attempts = 0, cut_next_at = NULL WHERE id = ?`, [start, end, end - start, r.id]));
+ // The same recovery as POST /clips/:id/recut: virtual where its VOD has a timeline, else a cut.
+ const win = await clipJobs.timelineWindow(await db.getVodById(r.vod_id), start, end);
+ const virtual = !!win;
+ if (win) await clipJobs.makeVirtual(r.id, win);
+ else await clipJobs.recutClip(r.id, { reason: 'edge repair' });
+ console.log(` ${virtual ? 'virtual over its VOD\'s timeline' : 'queued a cut'}`);
+ repaired++;
+ }
+ console.log(APPLY ? `repair-edge-clips: ${repaired} repaired` : `repair-edge-clips: ${rows.length} to repair (dry run; --apply repairs them)`);
+ process.exit(0);
+})().catch((err) => { console.error('repair-edge-clips:', err.message); process.exit(1); });
@@ -415,7 +415,10 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)
@@ -415,7 +415,10 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)
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),
auto_generated: flag(req.query.auto_generated),
- ready_only: String(req.query.status || '') === 'ready',
+ // A public list (not the owning app's private one) shows only clips that play: a failed or unfinished cut
+ // was a broken card on Live's home (clips 493-498, 2026-10-27). status=any asks for every one.
+ ready_only: String(req.query.status || '') === 'ready'
+ || (String(req.query.status || '') !== 'any' && !(req.authType === 'app' && ['1', 'true'].includes(String(req.query.include_private || '')))),
since: req.query.since || null,
since: req.query.since || null,
};
};
const clips = await db.listClips(req.appId, filters);
const clips = await db.listClips(req.appId, filters);
@@ -77,10 +77,14 @@ const http = require('http');
@@ -77,10 +77,14 @@ const http = require('http');
};
};
await check('auto_generated=0|1 splits the public list; private rows stay out of both', async () => {
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=0')), [10, 14, 15], 'a public list shows clips that play (13 failed)');
assert.deepStrictEqual(ids(await call('GET', '/clips?auto_generated=1')), [11]);
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?auto_generated=true')), [11]);
- assert.deepStrictEqual(ids(await call('GET', '/clips')), [10, 11, 13, 14, 15], 'no filter: both kinds, as before');
+ assert.deepStrictEqual(ids(await call('GET', '/clips')), [10, 11, 14, 15], 'no filter: both kinds, playable ones');
+ // A failed cut was a broken card on Live's home (clips 493-498, 2026-10-27): only status=any or the owning
+ // app's private list still names it.
+ assert.deepStrictEqual(ids(await call('GET', '/clips?status=any')), [10, 11, 13, 14, 15], 'status=any: every public clip');
+ assert.ok(ids(await call('GET', '/clips?include_private=1')).includes(13), 'the app\'s own list keeps its failed cuts');
const r = await call('GET', '/clips?auto_generated=1');
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.total, 1, 'the total counts the same filter');
assert.strictEqual(r.json.clips[0].auto_generated, true);
assert.strictEqual(r.json.clips[0].auto_generated, true);