openvibers/openvibe.media Commits

Media: an audio-only recording has no thumbnail (no_video, 404), not a 500 (#93)

OP OpenVibers committed 12 Oct 2026 · 99cc7e9ea5 · 4 files, +41 −3

Live's home page logged two 500s on every visit (the browser check's only firing alert): it asked Media for thumbnails of VODs 5718 and 5721, and both are **audio-only** recordings (browser broadcasts with no camera: a WebM with one Opus stream and an MP4 with one AAC stream). ffmpeg has no frame to take, the job failed as `generate_failed` (retryable) and the v1 route answered 500.

- `thumbnail-service.hasVideoStream(path)` asks ffprobe whether there is a video stream (true, false, or null when it cannot tell).
- The `thumbnail.regenerate` job refuses an audio-only recording as `no_video` (permanent) before running ffmpeg; the v1 route answers 404 `media.no_video`.
- `test/thumbnail-unavailable.test.js` covers it with a generated Opus file (skipped when ffmpeg is missing, like the clip tests).

Live answers these without a failed request in a companion PR.

Changes

ddiff --git a/server/jobs/thumbnail.js b/server/jobs/thumbnail.js
iindex 737881e..a56a1a3 100644
---- a/server/jobs/thumbnail.js
++++ b/server/jobs/thumbnail.js
@@@ -8,8 +8,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, 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).
++ * health scan found zero-byte / missing; permanent, answered 404 by the v1 route), no_video (an
++ * audio-only recording: no frame exists; permanent, 404), not_found (permanent), generate_failed
++ * (ffmpeg produced no frame from real media; retried).
*/
'use strict';
@@@ -60,6 +61,9 @@ async function run(job) {
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 });
}
++ if (await thumbService.hasVideoStream(source.value) === false) {
++ throw new JobError('no_video', 'The recording has no video (audio only), so there is no frame to take', { permanent: true, status: 404 });
++ }
const url = kind === 'vod'
? await thumbService.generateVodThumbnail(id, source.value)
: await thumbService.generateClipThumbnail(id, source.value);
ddiff --git a/server/thumbnails/routes.js b/server/thumbnails/routes.js
iindex 6516c52..1f95a9a 100644
---- a/server/thumbnails/routes.js
++++ b/server/thumbnails/routes.js
@@@ -100,6 +100,7 @@ router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload
if (!done || done.status === 'queued' || done.status === 'running') return res.status(202).json({ job: queue.jobPublic(done || r.job) });
if (done.status === 'succeeded') return res.json({ url: queue.parseJson(done.result, {}).url, job_id: done.id });
if (done.error_code === 'media_unavailable') return res.status(404).json({ error: 'Media file unavailable', job_id: done.id });
++ if (done.error_code === 'no_video') return res.status(404).json({ error: 'The recording has no video', code: 'media.no_video', job_id: done.id });
res.status(500).json({ error: 'Failed to generate thumbnail', job_id: done.id });
} catch (err) {
if (err && err.code === 'media.job.too_many') {
ddiff --git a/server/thumbnails/thumbnail-service.js b/server/thumbnails/thumbnail-service.js
iindex 6a05f24..5abbae1 100644
---- a/server/thumbnails/thumbnail-service.js
++++ b/server/thumbnails/thumbnail-service.js
@@@ -84,6 +84,25 @@ function _probeLastPts(videoPath) {
});
}
++/**
++ * Whether a file (or presigned URL) has a video stream: true, false, or null when ffprobe could not tell.
++ * An audio-only recording (a browser broadcast with no camera) has no frame to take; asking ffmpeg for one
++ * fails every time, so the job refuses it as permanent instead (two such VODs answered 500 on 2026-10-12).
++ */
++function hasVideoStream(videoPath) {
++ return new Promise((resolve) => {
++ let out = '';
++ let p;
++ try {
++ p = spawn('ffprobe', ['-v', 'error', '-select_streams', 'v', '-show_entries', 'stream=index', '-of', 'csv=p=0', videoPath]);
++ } catch { return resolve(null); }
++ const to = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* */ } resolve(null); }, 15000);
++ p.stdout.on('data', (d) => { out += d; });
++ p.on('close', (code) => { clearTimeout(to); resolve(code === 0 ? String(out).trim() !== '' : null); });
++ p.on('error', () => { clearTimeout(to); resolve(null); });
++ });
++}
++
/**
* Extract a thumbnail frame from a video file using ffmpeg.
* @param {string} videoPath – Absolute path (or presigned URL) of the video
@@@ -371,6 +390,7 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {
module.exports = {
generateFromVideo,
++ hasVideoStream,
generateVodThumbnail,
generateClipThumbnail,
saveLiveThumbnail,
ddiff --git a/test/thumbnail-unavailable.test.js b/test/thumbnail-unavailable.test.js
iindex 769efc6..1e76eda 100644
---- a/test/thumbnail-unavailable.test.js
++++ b/test/thumbnail-unavailable.test.js
@@@ -54,12 +54,25 @@ const express = require('express');
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');
}
++ // An audio-only recording (a browser broadcast with no camera; VODs 5718 and 5721 on 2026-10-12): no_video, 404.
++ if (require('child_process').spawnSync('ffmpeg', ['-version']).status === 0) {
++ const audio = path.join(V, 'audio-only.webm');
++ const mk = require('child_process').spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', '-c:a', 'libopus', audio]);
++ assert.strictEqual(mk.status, 0, String(mk.stderr));
++ const id = Number((await db.run(`INSERT INTO vods (app_id, title, file_path, file_size, is_public, visibility, health_status)
++ VALUES ('live', 't', ?, ?, 1, 'public', 'ok') RETURNING id`, [audio, fs.statSync(audio).size])).lastInsertRowid);
++ const r = await post(`/api/v1/live/thumbnails/vod/${id}`);
++ assert.strictEqual(r.status, 404, JSON.stringify(r.body));
++ assert.strictEqual(r.body.code, 'media.no_video');
++ const job = await queue.get(r.body.job_id);
++ assert.deepStrictEqual([job.status, job.error_code, job.attempts], ['failed', 'no_video', 1]);
++ } else console.log('audio-only case skipped (ffmpeg not found)');
assert.ok(/empty \(0 bytes\)/.test((await queue.list('live', { type: 'thumbnail.regenerate' })).jobs.find(j => JSON.parse(j.params).id === marked).error));
assert.strictEqual((await 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();
await 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');
++ console.log('✅ thumbnail.regenerate on an empty or missing recording: media_unavailable at once; on an audio-only one: no_video; 404 from the v1 route');
process.exit(0);
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });