openvibers/openvibe.media Commits

Media: an audio-only recording's thumbnail answers no_video at once (no new job each time) (#127)

OP OpenVibers committed 29 days ago · 9dcb4f957a · 2 files, +18 −0

An audio-only recording has no frame for a thumbnail, but each time a page asked for one Media ran the same doomed `thumbnail.regenerate` job again: 56 `no_video` failures in two days. VOD 5747, a five-hour audio-only stream, alone failed 3 times in 2 hours. Live's home page asks for missing thumbnails from every visitor's browser.

The v1 thumbnail route now answers 404 `media.no_video` at once when the object already has a failed `no_video` job and is not still recording. The file never changes after it is finished. The check uses `idx_media_jobs_object`. Live already caches that answer for an hour.

`test/jobs.test.js` adds the case: a recorded `no_video` failure means 404 `media.no_video` and no new job.

Changes

ddiff --git a/server/thumbnails/routes.js b/server/thumbnails/routes.js
iindex 1f95a9a..6d2479a 100644
---- a/server/thumbnails/routes.js
++++ b/server/thumbnails/routes.js
@@@ -83,6 +83,13 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
return res.json({ url });
}
++ // A recording already found to have no video (audio only) has no frame to take, and its file never changes after
++ // it is finished: answer that at once instead of running the same failing job again (every visitor's page asked,
++ // 56 failed jobs in two days, 2026-10-15). Uses idx_media_jobs_object.
++ if (row.object_id && !row.is_recording && await db.get("SELECT 1 AS x FROM media_jobs WHERE object_id = ? AND job_type = 'thumbnail.regenerate' AND status = 'failed' AND error_code = 'no_video' LIMIT 1", [row.object_id])) {
++ return res.status(404).json({ error: 'The recording has no video', code: 'media.no_video' });
++ }
++
// Generate from the media file (local or presigned remote) as a thumbnail.regenerate job.
const queue = require('../jobs/queue');
const worker = require('../jobs/worker');
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
iindex 5ac3c88..3519829 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -263,6 +263,17 @@ const http = require('http');
r = await call('POST', '/api/v1/live/thumbnails/vod/2');
assert.deepStrictEqual([r.status, r.body.error], [404, 'Media file unavailable'], 'no media is still a 404');
assert.strictEqual((await queue.get(r.body.job_id)).error_code, 'media_unavailable');
++ // An audio-only recording failed once with no_video: later asks answer at once, with no new job (every visitor's
++ // page asked again before, 2026-10-15).
++ await conn.prepare("INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, duration_seconds) OVERRIDING SYSTEM VALUE VALUES (3, 'live', 5, 'C', ?, 1, 60) RETURNING id").run(path.join(process.env.VOD_PATH, 'rec-1.webm'));
++ await require('../server/objects/model').sync('vod', 3);
++ const audioObj = (await db.get('SELECT object_id FROM vods WHERE id = 3')).object_id;
++ const failed = await queue.enqueue({ appId: 'live', type: 'thumbnail.regenerate', objectId: audioObj, params: { kind: 'vod', id: 3 }, maxAttempts: 1, createdBy: 'app:live' });
++ await db.run("UPDATE media_jobs SET status = 'failed', error_code = 'no_video', error = 'The recording has no video (audio only)' WHERE id = ?", [failed.job.id]);
++ const jobsBefore = (await db.get("SELECT COUNT(*) AS n FROM media_jobs WHERE object_id = ?", [audioObj])).n;
++ r = await call('POST', '/api/v1/live/thumbnails/vod/3');
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'media.no_video'], 'a known audio-only recording answers no_video');
++ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM media_jobs WHERE object_id = ?", [audioObj])).n, jobsBefore, 'and runs no new job');
r = await call('POST', '/api/v1/live/thumbnails/vod/1?async=1');
assert.strictEqual(r.status, 202);
assert.ok(await waitFor(() => false, 50) || true);