openvibers/openvibe.media Commits

Live frame API: slug + @username selectors, styled OFFLINE card

OP OpenVibers committed 18 Aug 2026 · 1598ae78a2 · 3 files, +104 −13

/live/:sel/frame.jpg now accepts a slot id, a slot slug ('whip'), or
'@username' (resolves to that streamer's top-viewed live slot) — resolved via
the app's public /api/streams listing over loopback, cached 5s. When the
target isn't live, a styled OFFLINE SVG card is served with the 404 so <img>
embeds degrade nicely; ?format=json keeps JSON errors for API consumers.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/README.md b/README.md
iindex bdf1113..557c3ea 100644
---- a/README.md
++++ b/README.md
@@@ -200,7 +200,7 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
| `GET /f/screenshots/:name` | paste screenshot by filename — serves straight from `PASTES_PATH/screenshots` (migrated legacy files have no files-table rows), `noindex` |
--| `GET /live/:msid/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot (`msid` = managed stream id), extracted from its in-progress recording. Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`). Cached **5s per slot** (that cache is the rate limit), CORS-open (`Access-Control-Allow-Origin: *`) for external APIs/bots/dashboards. `404` when the slot isn't live, `503` when a frame can't be cut. |
++| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (SVG) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
Private items respond 403/404 unless the request bears the owning app's API
key or the owning user's JWT.
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 7081c95..3bcf5db 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -194,23 +194,39 @@ router.get('/t/:id', (req, res) => {
});
// ── Live frame API (public, dev-facing) ──────────────────────
--// GET /live/:msid/frame.jpg[?w=640][&app=live] — a near-realtime JPEG frame of
--// an actively-live stream slot, extracted from its in-progress recording.
--// Cached 5s per slot (that cache IS the rate limit); CORS-open so external
--// APIs, bots, and dashboards can poll it directly.
--router.get('/live/:msid/frame.jpg', async (req, res) => {
++// GET /live/:sel/frame.jpg[?w=640][&app=live][&format=json]
++// :sel = slot id ("1"), slot slug ("whip"), or "@username" (that streamer's
++// top-viewed live slot). Returns a near-realtime JPEG frame extracted from the
++// slot's in-progress recording; when the target isn't live, a styled OFFLINE
++// card (SVG) is served with a 404 so <img> embeds still look good while API
++// consumers can key off the status code (or pass ?format=json).
++// Cached 5s per slot (that cache IS the rate limit); CORS-open.
++router.get('/live/:sel/frame.jpg', async (req, res) => {
++ const frames = require('../thumbnails/live-frame-service');
++ const wantJson = String(req.query.format || '') === 'json';
++ const sendCard = (status, label, subtitle) => {
++ if (wantJson) return res.status(status).json({ error: subtitle ? `${label} ${subtitle}` : `${label} is offline` });
++ const svg = frames.offlineCardSvg(label, subtitle);
++ res.status(status).set({
++ 'Content-Type': 'image/svg+xml; charset=utf-8',
++ 'Cache-Control': 'public, max-age=5',
++ 'X-Robots-Tag': 'noindex',
++ });
++ res.end(svg);
++ };
try {
-- const msid = parseInt(req.params.msid, 10);
-- if (!Number.isFinite(msid) || msid <= 0) return res.status(400).json({ error: 'Bad slot id' });
++ res.set('Access-Control-Allow-Origin', '*');
const appId = String(req.query.app || 'live');
let w = parseInt(req.query.w, 10);
w = Number.isFinite(w) ? Math.min(1920, Math.max(64, w)) : null;
-- const out = await require('../thumbnails/live-frame-service').getLiveFrame(appId, msid, w);
-- res.set('Access-Control-Allow-Origin', '*');
++ const resolved = await frames.resolveSelector(appId, req.params.sel);
++ if (!resolved.msid) return sendCard(404, resolved.label, 'is offline right now');
++
++ const out = await frames.getLiveFrame(appId, resolved.msid, w);
if (!out.ok) {
-- return res.status(out.reason === 'not_live' ? 404 : 503)
-- .json({ error: out.reason === 'not_live' ? 'Slot is not live' : 'Frame unavailable' });
++ if (out.reason === 'not_live') return sendCard(404, resolved.label, 'is offline right now');
++ return sendCard(503, resolved.label, 'is live — frame unavailable, retry shortly');
}
res.set({
'Content-Type': 'image/jpeg',
ddiff --git a/server/thumbnails/live-frame-service.js b/server/thumbnails/live-frame-service.js
iindex 5cc3cb6..38896f3 100644
---- a/server/thumbnails/live-frame-service.js
++++ b/server/thumbnails/live-frame-service.js
@@@ -76,6 +76,81 @@ async function _grab(appId, managedStreamId, width) {
return buf ? { ok: true, buf } : { ok: false, reason: 'unavailable' };
}
++// ── Selector resolution (slot id / slot slug / @username) ────
++// Slugs, usernames, and viewer counts are app-side data; the app's public
++// /api/streams listing has all three, fetched over loopback and cached 5s.
++const APP_INTERNAL_URLS = (() => {
++ try { const m = JSON.parse(process.env.APP_INTERNAL_URLS || ''); if (m && typeof m === 'object') return m; } catch { /* */ }
++ return { live: process.env.LIVE_APP_INTERNAL_URL || 'http://127.0.0.1:3000' };
++})();
++
++const _liveListCache = new Map(); // appId → { at, streams }
++async function _appLiveStreams(appId) {
++ const hit = _liveListCache.get(appId);
++ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.streams;
++ const base = APP_INTERNAL_URLS[appId];
++ if (!base) return hit?.streams || [];
++ try {
++ const res = await fetch(`${String(base).replace(/\/+$/, '')}/api/streams`, { signal: AbortSignal.timeout(4000) });
++ if (!res.ok) throw new Error(`streams list ${res.status}`);
++ const streams = (await res.json())?.streams || [];
++ _liveListCache.set(appId, { at: Date.now(), streams });
++ return streams;
++ } catch (err) {
++ console.warn('[LiveFrame] app streams lookup failed:', err.message);
++ return hit?.streams || [];
++ }
++}
++
++/**
++ * Resolve a URL selector to a managed stream slot:
++ * "12" → slot id 12
++ * "whip" → the live slot whose slug is "whip"
++ * "@Goosely" → that streamer's top-viewed currently-live slot
++ * @returns {Promise<{msid:number, label:string} | {msid:null, label:string}>}
++ */
++async function resolveSelector(appId, selector) {
++ const sel = String(selector || '').trim();
++ if (/^\d+$/.test(sel)) return { msid: parseInt(sel, 10), label: `Slot ${sel}` };
++ const streams = await _appLiveStreams(appId);
++ if (sel.startsWith('@')) {
++ const name = sel.slice(1).toLowerCase();
++ const mine = streams.filter(s => String(s.username || '').toLowerCase() === name && s.managed_stream_id);
++ mine.sort((a, b) => (b.total_viewer_count ?? b.viewer_count ?? 0) - (a.total_viewer_count ?? a.viewer_count ?? 0));
++ if (mine.length) return { msid: mine[0].managed_stream_id, label: `@${mine[0].username}` };
++ return { msid: null, label: sel };
++ }
++ const slug = sel.toLowerCase();
++ const match = streams.find(s => String(s.managed_stream_slug || '').toLowerCase() === slug && s.managed_stream_id);
++ if (match) return { msid: match.managed_stream_id, label: match.managed_stream_slug };
++ return { msid: null, label: sel };
++}
++
++// ── Offline / unavailable placeholder card (SVG, 640×360) ────
++function _esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
++function offlineCardSvg(label, subtitle = 'is offline right now') {
++ const name = _esc(String(label || 'stream').slice(0, 40));
++ return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360">
++ <defs>
++ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
++ <stop offset="0" stop-color="#0f1420"/><stop offset="1" stop-color="#1a2035"/>
++ </linearGradient>
++ <radialGradient id="glow" cx="0.5" cy="0.42" r="0.55">
++ <stop offset="0" stop-color="#8b5cf6" stop-opacity="0.22"/><stop offset="1" stop-color="#8b5cf6" stop-opacity="0"/>
++ </radialGradient>
++ </defs>
++ <rect width="640" height="360" fill="url(#bg)"/>
++ <rect width="640" height="360" fill="url(#glow)"/>
++ <g transform="translate(320,128)" stroke="#8b5cf6" stroke-width="5" fill="none" opacity="0.9">
++ <circle cx="-34" cy="22" r="13"/><circle cx="34" cy="22" r="13"/><circle cx="0" cy="-30" r="13"/>
++ <line x1="-24" y1="14" x2="-8" y2="-20"/><line x1="24" y1="14" x2="8" y2="-20"/><line x1="-21" y1="22" x2="21" y2="22"/>
++ </g>
++ <text x="320" y="216" text-anchor="middle" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="34" font-weight="700" fill="#e6e9f2" letter-spacing="6">OFFLINE</text>
++ <text x="320" y="252" text-anchor="middle" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" fill="#9aa3b8">${name} ${_esc(subtitle)}</text>
++ <text x="320" y="330" text-anchor="middle" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="13" fill="#5b6478" letter-spacing="2">OPENVIBE.LIVE</text>
++</svg>`;
++}
++
/**
* Get a current frame for an actively-live slot.
* @returns {Promise<{ok:true, buf:Buffer} | {ok:false, reason:'not_live'|'unavailable'}>}
@@@ -105,4 +180,4 @@ function getLiveFrame(appId, managedStreamId, width = null) {
return p;
}
--module.exports = { getLiveFrame, CACHE_TTL_MS };
++module.exports = { getLiveFrame, resolveSelector, offlineCardSvg, CACHE_TTL_MS };