openvibers/openvibe.live Commits

An old live-thumbnail URL shows the stream's current frame, and a stream with none answers 404 so pages fall back (the 1x1 pixel drew empty boxes on the network home) (#310)

OP OpenVibers committed 22 days ago · 4d35821adf · 2 files, +22 −11

Changes

ddiff --git a/server/media-proxy/thumbnails.js b/server/media-proxy/thumbnails.js
iindex 14a400e9..f77b61ba 100644
---- a/server/media-proxy/thumbnails.js
++++ b/server/media-proxy/thumbnails.js
@@@ -24,12 +24,6 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
--// 1x1 JPEG fallback for missing live thumbs (matches old behavior).
--const PIXEL = Buffer.from(
-- '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA=',
-- 'base64'
--);
--
// ── Live stream thumbnail upload (from broadcaster client) ───
router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), async (req, res) => {
try {
@@@ -173,10 +167,21 @@ router.get('/:filename', async (req, res) => {
});
return pipeline(fs.createReadStream(localPath), res, () => {});
}
-- if (filename.startsWith('stream-')) {
-- // Missing live thumbnail → transparent pixel (old behavior; avoids broken cards).
-- res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': PIXEL.length, 'Cache-Control': 'no-cache' });
-- return res.end(PIXEL);
++ const named = /^stream-(\d+)-\d+\.(?:jpg|png)$/.exec(filename);
++ if (named || filename.startsWith('stream-')) {
++ res.set('Cache-Control', 'no-cache');
++ // A live thumbnail is replaced every ~2 minutes and the old file deleted at once (live-thumbs.js _replaceThumb),
++ // but pages still name it: the network home, which keeps its list for a minute and is cached in browsers, share
++ // cards, a channel list loaded a minute ago. The old name answers with the stream's current frame.
++ if (named) {
++ const current = await liveThumbs.getStreamThumbnailState(Number(named[1]));
++ if (current.exists && path.basename(current.thumbUrl) !== filename) return res.redirect(302, current.thumbUrl);
++ if (/^https:\/\//.test(current.thumbUrl || '')) return res.redirect(302, current.thumbUrl);
++ }
++ // No frame at all: a 404, so the page's own fallback shows (the streamer's avatar on openvibe.network, Live's
++ // handleThumbnailError). Until 2026-10-22 this was a 1×1 pixel answered 200, which every card drew as an empty
++ // box ("these thumbnails are broken").
++ return res.status(404).json({ error: 'No live thumbnail' });
}
// TODO(contract): legacy /api/thumbnails/<file> names map onto Media's /t/<file>
// (Media inherits the same vod-/clip-/moment- thumbnail naming).
ddiff --git a/test/live-thumb-cadence.test.js b/test/live-thumb-cadence.test.js
iindex 787fc1ab..7dbce3a7 100644
---- a/test/live-thumb-cadence.test.js
++++ b/test/live-thumb-cadence.test.js
@@@ -98,7 +98,13 @@ const get = (p) => new Promise((resolve, reject) => {
assert.deepStrictEqual([r.status, r.headers.location], [302, 'https://openvibe.media/t/vod-9-1.jpg'], 'a Media fallback frame is followed');
r = await get(`/api/thumbnails/${path.basename((await current()).thumbUrl)}`);
assert.strictEqual(r.body.toString('latin1').slice(4), 'three', 'the real file names are still served');
-- assert.strictEqual((await get('/api/thumbnails/stream-999-123.jpg')).headers['content-type'], 'image/jpeg', 'a missing card thumbnail is still the placeholder pixel');
++ // A card that names an older capture gets the stream's current frame; with no frame at all, a 404 lets the page's
++ // fallback show (the 1×1 pixel it used to answer drew an empty box on every card).
++ const now = (await db.get('SELECT thumbnail_url FROM streams WHERE id = ?', [sid])).thumbnail_url;
++ const old = await get(`/api/thumbnails/stream-${sid}-1000.jpg`);
++ assert.strictEqual(old.status, 302);
++ assert.strictEqual(old.headers.location, now, 'the name of an old capture answers with the current one');
++ assert.strictEqual((await get('/api/thumbnails/stream-999-123.jpg')).status, 404, 'no frame: 404, so the card falls back to the avatar');
console.log('OK /api/thumbnails/stream-<id>-live.jpg serves the current live thumbnail, uncached');
// ── clean ──