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)
Changes
@@@ -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).@@@ -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 ──
@@ -24,12 +24,6 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
@@ -24,12 +24,6 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
const router = express.Router();
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
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) ───
// ── Live stream thumbnail upload (from broadcaster client) ───
router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), async (req, res) => {
router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), async (req, res) => {
try {
try {
@@ -173,10 +167,21 @@ router.get('/:filename', async (req, res) => {
@@ -173,10 +167,21 @@ router.get('/:filename', async (req, res) => {
});
});
return pipeline(fs.createReadStream(localPath), 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>
// TODO(contract): legacy /api/thumbnails/<file> names map onto Media's /t/<file>
// (Media inherits the same vod-/clip-/moment- thumbnail naming).
// (Media inherits the same vod-/clip-/moment- thumbnail naming).
@@ -98,7 +98,13 @@ const get = (p) => new Promise((resolve, reject) => {
@@ -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');
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)}`);
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(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');
console.log('OK /api/thumbnails/stream-<id>-live.jpg serves the current live thumbnail, uncached');
// ── clean ──
// ── clean ──