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).

OP OpenVibers committed 24 Sept 2026 · 5099c97298 · 3 files, +78 −4


Changes

ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 6d6137a..13059da 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -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 |
ddiff --git a/server/jobs/thumbnail.js b/server/jobs/thumbnail.js
iindex cf1f3e7..0b08e8f 100644
---- a/server/jobs/thumbnail.js
++++ b/server/jobs/thumbnail.js
@@@ -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);
ddiff --git a/test/thumbnail-unavailable.test.js b/test/thumbnail-unavailable.test.js
nnew file mode 100644
iindex 0000000..ebfff24
---- /dev/null
++++ b/test/thumbnail-unavailable.test.js
@@@ -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); });