openvibers/openvibe.media Commits
thumbnail.regenerate refuses a recording with nothing to take a frame from. The five failed jobs on production (all at 2026-09-23 21:36:41, created by app:live, generate_failed) were Live asking for thumbnails of VODs 3044, 5537, 5540, 5546 and 5603: zero-byte recordings the health scan had quarantined (health_status zero_byte, 0-byte files on disk). The job ran ffmpeg on the empty files, got no frame, and the v1 route answered 500 to Live. Now a VOD whose health is zero_byte or missing_file, or whose local file is empty, fails at once as media_unavailable (permanent), which the route already answers 404, like a missing file. No production data was changed: the rows stay quarantined and the health job's cleanup removes them after its grace period. test/thumbnail-unavailable.test.js reproduces the production case (it fails with the old job: 500, generate_failed).
Changes
@@@ -465,7 +465,7 @@ requeued (or failed when out of attempts). Handlers checkpoint progress and resu| type | lane | what it does ||---|---|---|--| `thumbnail.regenerate` | light | (Re)generates a vod/clip thumbnail from its media (local file or presigned cloud copy). `params { kind, id }`, or an object that is a projected vod/clip. Result `{ url }` |++| `thumbnail.regenerate` | light | (Re)generates a vod/clip thumbnail from its media (local file or presigned cloud copy). `params { kind, id }`, or an object that is a projected vod/clip. Result `{ url }`. A recording that is empty (0 bytes, or health `zero_byte`) or gone (`missing_file`, no cloud copy) fails at once as `media_unavailable` (permanent; the v1 route answers 404) instead of running ffmpeg || `invariant.scan` | light | The [size-invariant validator](#public-object-size-invariant): records violations, proposes `object.split` / `object.remux`, withdraws moot proposals. Tenant-wide (no object) || `object.split` | heavy | Stream-copies a vod/clip (or a video/audio object) into parts (`params.parts` 2-1000 or `segment_seconds`), each a new **private** object with a `derived_from` relationship (`job_id`, `part`, `start_seconds`, `duration_seconds`). Cuts land on keyframes, so neighbouring parts can overlap slightly. The source is never changed. Checkpointed per part || `object.remux` | heavy | Stream-copy remux of the whole source (seek index, duration, MP4 faststart) into one new private object; also the source's `remux` variant |@@@ -7,8 +7,9 @@** params { kind: 'vod' | 'clip', id: <row id> } (derived from the object's legacy ref when omitted)* result { url, kind, id }-- * Failures: media_unavailable (no local file and no cloud copy; permanent), not_found (permanent),-- * generate_failed (ffmpeg produced no frame; retried).++ * Failures: media_unavailable (no local file and no cloud copy, an empty file, or a recording the++ * health scan found zero-byte / missing; permanent, answered 404 by the v1 route), not_found++ * (permanent), generate_failed (ffmpeg produced no frame from real media; retried).*/'use strict';@@@ -45,10 +46,20 @@ async function run(job) {const row = rowOf(job.app_id, kind, id);if (!row) throw new JobError('not_found', `${kind} ${id} not found`, { permanent: true });const thumbService = require('../thumbnails/thumbnail-service');++ // A recording the health scan found empty or gone has no frame to take (five such VODs failed as++ // generate_failed on 2026-09-23: ffmpeg was run on 0-byte files and Live got a 500).++ if (kind === 'vod' && ['zero_byte', 'missing_file'].includes(row.health_status)) {++ throw new JobError('media_unavailable', `The recording is ${row.health_status === 'zero_byte' ? 'empty (0 bytes)' : 'missing'}`, { permanent: true, status: 404 });++ }let source = null;-- if (kind === 'clip' && row.file_path && require('fs').existsSync(row.file_path)) source = { value: row.file_path };++ if (kind === 'clip' && row.file_path && require('fs').existsSync(row.file_path)) source = { kind: 'file', value: row.file_path };else source = await require('../vod/vod-storage').resolveMediaSource(row);if (!source) throw new JobError('media_unavailable', 'Media file unavailable', { permanent: true, status: 404 });++ if (source.kind === 'file') {++ let size = 0;++ try { size = require('fs').statSync(source.value).size; } catch { size = 0; }++ if (!size) throw new JobError('media_unavailable', 'The media file is empty (0 bytes)', { permanent: true, status: 404 });++ }const url = kind === 'vod'? await thumbService.generateVodThumbnail(id, source.value): await thumbService.generateClipThumbnail(id, source.value);nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++// thumbnail.regenerate on a recording with nothing to take a frame from: the five jobs that failed on++// production (2026-09-23 21:36:41, generate_failed) were Live asking for thumbnails of zero-byte VODs++// (health zero_byte, quarantined). Media ran ffmpeg on the empty files and answered 500. Now such a++// request fails at once as media_unavailable (permanent) and the v1 route answers 404, whether the++// health scan already marked the VOD or the file is simply empty or gone.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-thumbfail-'));++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++Object.assign(process.env, {++ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),++ THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'), MEDIA_INVARIANT_SCAN_HOURS: '0',++});++++const db = require('../server/db/database');++const queue = require('../server/jobs/queue');++db.upsertApp({ app_id: 'live', api_key: 'live-key-thumbfail' });++++const V = process.env.VOD_PATH;++const insVod = (file, health) => Number(db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)++ VALUES ('live', 't', ?, 0, 1, 'public', ?)`, [file, health]).lastInsertRowid);++fs.writeFileSync(path.join(V, 'zero-marked.mp4'), '');++fs.writeFileSync(path.join(V, 'zero-unscanned.mp4'), '');++const marked = insVod(path.join(V, 'zero-marked.mp4'), 'zero_byte'); // as on production++const unscanned = insVod(path.join(V, 'zero-unscanned.mp4'), 'unknown'); // empty, not scanned yet++const missing = insVod(path.join(V, 'gone.mp4'), 'ok'); // no file, not offloaded++++const app = express();++app.use(express.json());++app.use('/api/v1/:app/thumbnails', require('../server/thumbnails/routes'));++const server = app.listen(0, '127.0.0.1');++const post = (p) => new Promise((resolve, reject) => {++ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method: 'POST', headers: { Authorization: 'Bearer live-key-thumbfail', 'Content-Length': 0 } },++ (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b || '{}') })); });++ req.on('error', reject);++ req.end();++});++++(async () => {++ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));++ for (const id of [marked, unscanned, missing]) {++ const t0 = Date.now();++ const r = await post(`/api/v1/live/thumbnails/vod/${id}`);++ assert.strictEqual(r.status, 404, `vod ${id}: ${JSON.stringify(r.body)}`);++ assert.strictEqual(r.body.error, 'Media file unavailable');++ const job = queue.get(r.body.job_id);++ assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'media_unavailable', 1], `vod ${id}`);++ assert.ok(Date.now() - t0 < 5000, 'refused before any ffmpeg run');++ }++ assert.ok(/empty \(0 bytes\)/.test(queue.list('live', { type: 'thumbnail.regenerate' }).jobs.find(j => JSON.parse(j.params).id === marked).error));++ assert.strictEqual(db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'")[0].n, 0, 'none of them is a generate failure any more');++ server.close();++ db.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once, 404 from the v1 route');++ process.exit(0);++})().catch((err) => { console.error(err); server.close(); process.exit(1); });
@@ -465,7 +465,7 @@ requeued (or failed when out of attempts). Handlers checkpoint progress and resu
@@ -465,7 +465,7 @@ requeued (or failed when out of attempts). Handlers checkpoint progress and resu
| type | lane | what it does |
| type | lane | what it does |
|---|---|---|
|---|---|---|
-| `thumbnail.regenerate` | light | (Re)generates a vod/clip thumbnail from its media (local file or presigned cloud copy). `params { kind, id }`, or an object that is a projected vod/clip. Result `{ url }` |
+| `thumbnail.regenerate` | light | (Re)generates a vod/clip thumbnail from its media (local file or presigned cloud copy). `params { kind, id }`, or an object that is a projected vod/clip. Result `{ url }`. A recording that is empty (0 bytes, or health `zero_byte`) or gone (`missing_file`, no cloud copy) fails at once as `media_unavailable` (permanent; the v1 route answers 404) instead of running ffmpeg |
| `invariant.scan` | light | The [size-invariant validator](#public-object-size-invariant): records violations, proposes `object.split` / `object.remux`, withdraws moot proposals. Tenant-wide (no object) |
| `invariant.scan` | light | The [size-invariant validator](#public-object-size-invariant): records violations, proposes `object.split` / `object.remux`, withdraws moot proposals. Tenant-wide (no object) |
| `object.split` | heavy | Stream-copies a vod/clip (or a video/audio object) into parts (`params.parts` 2-1000 or `segment_seconds`), each a new **private** object with a `derived_from` relationship (`job_id`, `part`, `start_seconds`, `duration_seconds`). Cuts land on keyframes, so neighbouring parts can overlap slightly. The source is never changed. Checkpointed per part |
| `object.split` | heavy | Stream-copies a vod/clip (or a video/audio object) into parts (`params.parts` 2-1000 or `segment_seconds`), each a new **private** object with a `derived_from` relationship (`job_id`, `part`, `start_seconds`, `duration_seconds`). Cuts land on keyframes, so neighbouring parts can overlap slightly. The source is never changed. Checkpointed per part |
| `object.remux` | heavy | Stream-copy remux of the whole source (seek index, duration, MP4 faststart) into one new private object; also the source's `remux` variant |
| `object.remux` | heavy | Stream-copy remux of the whole source (seek index, duration, MP4 faststart) into one new private object; also the source's `remux` variant |
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
*
*
* params { kind: 'vod' | 'clip', id: <row id> } (derived from the object's legacy ref when omitted)
* params { kind: 'vod' | 'clip', id: <row id> } (derived from the object's legacy ref when omitted)
* result { url, kind, id }
* result { url, kind, id }
- * Failures: media_unavailable (no local file and no cloud copy; permanent), not_found (permanent),
- * generate_failed (ffmpeg produced no frame; retried).
+ * Failures: media_unavailable (no local file and no cloud copy, an empty file, or a recording the
+ * health scan found zero-byte / missing; permanent, answered 404 by the v1 route), not_found
+ * (permanent), generate_failed (ffmpeg produced no frame from real media; retried).
*/
*/
'use strict';
'use strict';
@@ -45,10 +46,20 @@ async function run(job) {
@@ -45,10 +46,20 @@ async function run(job) {
const row = rowOf(job.app_id, kind, id);
const row = rowOf(job.app_id, kind, id);
if (!row) throw new JobError('not_found', `${kind} ${id} not found`, { permanent: true });
if (!row) throw new JobError('not_found', `${kind} ${id} not found`, { permanent: true });
const thumbService = require('../thumbnails/thumbnail-service');
const thumbService = require('../thumbnails/thumbnail-service');
+ // A recording the health scan found empty or gone has no frame to take (five such VODs failed as
+ // generate_failed on 2026-09-23: ffmpeg was run on 0-byte files and Live got a 500).
+ if (kind === 'vod' && ['zero_byte', 'missing_file'].includes(row.health_status)) {
+ throw new JobError('media_unavailable', `The recording is ${row.health_status === 'zero_byte' ? 'empty (0 bytes)' : 'missing'}`, { permanent: true, status: 404 });
+ }
let source = null;
let source = null;
- if (kind === 'clip' && row.file_path && require('fs').existsSync(row.file_path)) source = { value: row.file_path };
+ if (kind === 'clip' && row.file_path && require('fs').existsSync(row.file_path)) source = { kind: 'file', value: row.file_path };
else source = await require('../vod/vod-storage').resolveMediaSource(row);
else source = await require('../vod/vod-storage').resolveMediaSource(row);
if (!source) throw new JobError('media_unavailable', 'Media file unavailable', { permanent: true, status: 404 });
if (!source) throw new JobError('media_unavailable', 'Media file unavailable', { permanent: true, status: 404 });
+ if (source.kind === 'file') {
+ let size = 0;
+ try { size = require('fs').statSync(source.value).size; } catch { size = 0; }
+ if (!size) throw new JobError('media_unavailable', 'The media file is empty (0 bytes)', { permanent: true, status: 404 });
+ }
const url = kind === 'vod'
const url = kind === 'vod'
? await thumbService.generateVodThumbnail(id, source.value)
? await thumbService.generateVodThumbnail(id, source.value)
: await thumbService.generateClipThumbnail(id, source.value);
: await thumbService.generateClipThumbnail(id, source.value);
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+// thumbnail.regenerate on a recording with nothing to take a frame from: the five jobs that failed on
+// production (2026-09-23 21:36:41, generate_failed) were Live asking for thumbnails of zero-byte VODs
+// (health zero_byte, quarantined). Media ran ffmpeg on the empty files and answered 500. Now such a
+// request fails at once as media_unavailable (permanent) and the v1 route answers 404, whether the
+// health scan already marked the VOD or the file is simply empty or gone.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-thumbfail-'));
+const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+Object.assign(process.env, {
+ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
+ THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'), MEDIA_INVARIANT_SCAN_HOURS: '0',
+});
+
+const db = require('../server/db/database');
+const queue = require('../server/jobs/queue');
+db.upsertApp({ app_id: 'live', api_key: 'live-key-thumbfail' });
+
+const V = process.env.VOD_PATH;
+const insVod = (file, health) => Number(db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)
+ VALUES ('live', 't', ?, 0, 1, 'public', ?)`, [file, health]).lastInsertRowid);
+fs.writeFileSync(path.join(V, 'zero-marked.mp4'), '');
+fs.writeFileSync(path.join(V, 'zero-unscanned.mp4'), '');
+const marked = insVod(path.join(V, 'zero-marked.mp4'), 'zero_byte'); // as on production
+const unscanned = insVod(path.join(V, 'zero-unscanned.mp4'), 'unknown'); // empty, not scanned yet
+const missing = insVod(path.join(V, 'gone.mp4'), 'ok'); // no file, not offloaded
+
+const app = express();
+app.use(express.json());
+app.use('/api/v1/:app/thumbnails', require('../server/thumbnails/routes'));
+const server = app.listen(0, '127.0.0.1');
+const post = (p) => new Promise((resolve, reject) => {
+ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method: 'POST', headers: { Authorization: 'Bearer live-key-thumbfail', 'Content-Length': 0 } },
+ (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b || '{}') })); });
+ req.on('error', reject);
+ req.end();
+});
+
+(async () => {
+ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
+ for (const id of [marked, unscanned, missing]) {
+ const t0 = Date.now();
+ const r = await post(`/api/v1/live/thumbnails/vod/${id}`);
+ assert.strictEqual(r.status, 404, `vod ${id}: ${JSON.stringify(r.body)}`);
+ assert.strictEqual(r.body.error, 'Media file unavailable');
+ const job = queue.get(r.body.job_id);
+ assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'media_unavailable', 1], `vod ${id}`);
+ assert.ok(Date.now() - t0 < 5000, 'refused before any ffmpeg run');
+ }
+ assert.ok(/empty \(0 bytes\)/.test(queue.list('live', { type: 'thumbnail.regenerate' }).jobs.find(j => JSON.parse(j.params).id === marked).error));
+ assert.strictEqual(db.all("SELECT COUNT(*) AS n FROM media_jobs WHERE error_code = 'generate_failed'")[0].n, 0, 'none of them is a generate failure any more');
+ server.close();
+ db.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once, 404 from the v1 route');
+ process.exit(0);
+})().catch((err) => { console.error(err); server.close(); process.exit(1); });