openvibers/openvibe.media Commits
Live frame API: slug + @username selectors, styled OFFLINE card
Changes
@@@ -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 APIkey or the owning user's JWT.@@@ -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',@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }++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 };
@@ -200,7 +200,7 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
@@ -200,7 +200,7 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `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 /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
Private items respond 403/404 unless the request bears the owning app's API
key or the owning user's JWT.
key or the owning user's JWT.
@@ -194,23 +194,39 @@ router.get('/t/:id', (req, res) => {
@@ -194,23 +194,39 @@ router.get('/t/:id', (req, res) => {
});
});
// ── Live frame API (public, dev-facing) ──────────────────────
// ── 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 {
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');
const appId = String(req.query.app || 'live');
let w = parseInt(req.query.w, 10);
let w = parseInt(req.query.w, 10);
w = Number.isFinite(w) ? Math.min(1920, Math.max(64, w)) : null;
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) {
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({
res.set({
'Content-Type': 'image/jpeg',
'Content-Type': 'image/jpeg',
@@ -76,6 +76,81 @@ async function _grab(appId, managedStreamId, width) {
@@ -76,6 +76,81 @@ async function _grab(appId, managedStreamId, width) {
return buf ? { ok: true, buf } : { ok: false, reason: 'unavailable' };
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
+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.
* Get a current frame for an actively-live slot.
* @returns {Promise<{ok:true, buf:Buffer} | {ok:false, reason:'not_live'|'unavailable'}>}
* @returns {Promise<{ok:true, buf:Buffer} | {ok:false, reason:'not_live'|'unavailable'}>}
@@ -105,4 +180,4 @@ function getLiveFrame(appId, managedStreamId, width = null) {
@@ -105,4 +180,4 @@ function getLiveFrame(appId, managedStreamId, width = null) {
return p;
return p;
}
}
-module.exports = { getLiveFrame, CACHE_TTL_MS };
+module.exports = { getLiveFrame, resolveSelector, offlineCardSvg, CACHE_TTL_MS };