openvibers/openvibe.live Commits

Live: a recording with no picture answers 200 with no thumbnail, not a 500 on every visit (#103)

OP OpenVibers committed 12 Oct 2026 · c5c071eb57 · 2 files, +42 −1

The browser check's only firing alert: Live's home page logged two 500s on every visit, from `POST /api/thumbnails/generate/vod/5718` and `/5721`. Both are audio-only recordings (browser broadcasts with no camera), so there is no frame to take; Media's job failed and Live passed the 500 on. Each visit also queued a new Media job.

- When Media answers 404 to a regeneration (no picture possible: audio only after OpenVibe.Media#93, or the file is gone), Live answers **200 `{ thumbnail_url: null }`** and remembers it for an hour (bounded to 5,000 items), so the page shows its placeholder with no failed request and Media is not asked again.
- Media still working on it (its 202 with a job) is answered 202 with no thumbnail instead of a 500.
- The access checks run first, so a private item still looks missing.
- `test/media-privacy.test.js` covers it (three requests, one Media call; the private item unchanged).

Changes

ddiff --git a/server/media-proxy/thumbnails.js b/server/media-proxy/thumbnails.js
iindex 4bf54bab..821c8140 100644
---- a/server/media-proxy/thumbnails.js
++++ b/server/media-proxy/thumbnails.js
@@@ -49,6 +49,24 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), async (r
});
// ── Regenerate VOD / clip thumbnails (delegated to Media) ────
++// Some recordings have no picture to take (audio-only browser broadcasts, a file that is gone): Media answers
++// 404, and that stays true. Every visitor's page asks again for a missing thumbnail, so the answer is kept for
++// an hour and given as 200 with no thumbnail: the page shows its placeholder, with no failed request in the
++// console and no new Media job (VODs 5718 and 5721 logged a 500 on every home-page visit, 2026-10-12).
++const NO_PICTURE_MS = 60 * 60 * 1000;
++const noPicture = new Map(); // `${kind}:${id}` → until (ms), oldest first
++function noPictureKnown(key) {
++ const until = noPicture.get(key);
++ if (!until) return false;
++ if (until > Date.now()) return true;
++ noPicture.delete(key);
++ return false;
++}
++function rememberNoPicture(key) {
++ if (noPicture.size >= 5000) noPicture.delete(noPicture.keys().next().value);
++ noPicture.set(key, Date.now() + NO_PICTURE_MS);
++}
++
async function generateFor(kind, id, req, res) {
try {
const meta = kind === 'vod' ? await media.getVod(id) : await media.getClip(id);
@@@ -58,9 +76,18 @@ async function generateFor(kind, id, req, res) {
const canManage = !!req.user && (meta.user_id === req.user.id || can(req.user, 'staff.streams.manage'));
const isPublic = meta.visibility ? meta.visibility === 'public' : !!meta.is_public;
if (!canManage && !isPublic) return res.status(403).json({ error: `Not your ${kind}` });
-- const out = await media.generateThumbnail(kind, id);
++ const key = `${kind}:${Number(id)}`;
++ if (noPictureKnown(key)) return res.json({ thumbnail_url: null });
++ let out;
++ try { out = await media.generateThumbnail(kind, id); } catch (err) {
++ if (!(err && err.name === 'MediaApiError' && err.status === 404)) throw err;
++ rememberNoPicture(key);
++ return res.json({ thumbnail_url: null });
++ }
const url = media.publicUrl(out?.url || meta.thumbnail_url);
if (url) return res.json({ thumbnail_url: url });
++ // Media is still working on it (202 with its job): no picture yet, nothing failed.
++ if (out && out.job) return res.status(202).json({ thumbnail_url: null });
res.status(500).json({ error: 'Failed to generate thumbnail' });
} catch (err) {
if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
ddiff --git a/test/media-privacy.test.js b/test/media-privacy.test.js
iindex b47929e1..8e86a5f7 100644
---- a/test/media-privacy.test.js
++++ b/test/media-privacy.test.js
@@@ -219,6 +219,20 @@ async function check(name, fn) {
await sameAsMissing('POST', '/api/thumbnails/generate/clip/201', '/api/thumbnails/generate/clip/999', 7);
});
++ await check('thumbnail regeneration: a recording with no picture answers 200 with none, and Media is asked once an hour', async () => {
++ const realGenerate = media.generateThumbnail;
++ let asked = 0;
++ media.generateThumbnail = async () => { asked++; throw new media.MediaApiError('The recording has no video', 404, { error: 'The recording has no video', code: 'media.no_video' }); };
++ try {
++ for (let i = 0; i < 3; i++) {
++ const r = await call('POST', '/api/thumbnails/generate/vod/100', null);
++ assert.deepStrictEqual([r.status, r.json && r.json.thumbnail_url], [200, null], r.text);
++ }
++ assert.strictEqual(asked, 1, 'the answer is remembered');
++ await sameAsMissing('POST', '/api/thumbnails/generate/vod/101', '/api/thumbnails/generate/vod/999', null);
++ } finally { media.generateThumbnail = realGenerate; }
++ });
++
await check('thumbnail by id (image URLs): the current thumbnail of a visible item, a private one looks missing', async () => {
for (const id of [100, 101, 102, 103]) VODS[id].thumbnail_url = `/t/vod-${id}-7.jpg`;
CLIPS[200].thumbnail_url = '/t/clip-200-7.jpg';