openvibers/openvibe.live Commits
Live: a recording with no picture answers 200 with no thumbnail, not a 500 on every visit (#103)
Changes
@@@ -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 });@@@ -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';
@@ -49,6 +49,24 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), async (r
@@ -49,6 +49,24 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), async (r
});
});
// ── Regenerate VOD / clip thumbnails (delegated to Media) ────
// ── 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) {
async function generateFor(kind, id, req, res) {
try {
try {
const meta = kind === 'vod' ? await media.getVod(id) : await media.getClip(id);
const meta = kind === 'vod' ? await media.getVod(id) : await media.getClip(id);
@@ -58,9 +76,18 @@ async function generateFor(kind, id, req, res) {
@@ -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 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;
const isPublic = meta.visibility ? meta.visibility === 'public' : !!meta.is_public;
if (!canManage && !isPublic) return res.status(403).json({ error: `Not your ${kind}` });
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);
const url = media.publicUrl(out?.url || meta.thumbnail_url);
if (url) return res.json({ thumbnail_url: 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' });
res.status(500).json({ error: 'Failed to generate thumbnail' });
} catch (err) {
} catch (err) {
if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
@@ -219,6 +219,20 @@ async function check(name, fn) {
@@ -219,6 +219,20 @@ async function check(name, fn) {
await sameAsMissing('POST', '/api/thumbnails/generate/clip/201', '/api/thumbnails/generate/clip/999', 7);
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 () => {
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`;
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';
CLIPS[200].thumbnail_url = '/t/clip-200-7.jpg';