openvibers/openvibe.media Commits

Dev data APIs: transcripts, AI timelines, chat insight

OP OpenVibers committed 18 Aug 2026 · 934dba36fa · 4 files, +206 −1

Public JSON companions to the frame API (same selector grammar, 30s cache as
rate limit, CORS-open):
- GET /live/:sel/transcript.json — audio transcription log + AI overview
  timeline; slot-scoped by id/slug, user-scoped by @username (works offline)
- GET /v/:id/transcript.json — same data for one existing VOD
- GET /live/:sel/chat-insight.json — a user's chat AI insight/timeline
  (@username or user id), proxied from the app's public chat-AI API

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 36db911..2b74ad0 100644
---- a/README.md
++++ b/README.md
@@@ -200,6 +200,9 @@ 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/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. |
++| `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. |
++| `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. |
| `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** (real JPEG bytes — dev pipelines decode the body as image/jpeg) 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
ddiff --git a/server/public/dev-data.js b/server/public/dev-data.js
nnew file mode 100644
iindex 0000000..a496881
---- /dev/null
++++ b/server/public/dev-data.js
@@@ -0,0 +1,167 @@
++/**
++ * OpenVibe.Media — public dev-data API (transcripts, AI overview timelines,
++ * chat insight). Companion to the live frame API: same selector grammar
++ * (slot id / slot slug / @username), same cache-as-rate-limit philosophy,
++ * JSON out, CORS-open.
++ *
++ * Media owns per-VOD AI data (ai_overview / ai_transcript); streamer-level
++ * extras (chat insight, streamer overview, stream memories) are app-side and
++ * fetched over loopback from the app's public API, cached here.
++ */
++'use strict';
++
++const db = require('../db/database');
++const frames = require('../thumbnails/live-frame-service');
++
++const CACHE_TTL_MS = 30_000;
++const _cache = new Map(); // key → { at, out }
++const _inFlight = new Map(); // key → Promise
++
++function cached(key, fn) {
++ const hit = _cache.get(key);
++ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return Promise.resolve(hit.out);
++ if (_inFlight.has(key)) return _inFlight.get(key);
++ const p = Promise.resolve().then(fn)
++ .then((out) => {
++ if (_cache.size > 500) {
++ const cutoff = Date.now() - CACHE_TTL_MS;
++ for (const [k, v] of _cache) { if (v.at < cutoff) _cache.delete(k); }
++ }
++ _cache.set(key, { at: Date.now(), out });
++ _inFlight.delete(key);
++ return out;
++ })
++ .catch((err) => { _inFlight.delete(key); throw err; });
++ _inFlight.set(key, p);
++ return p;
++}
++
++async function _appJson(appId, apiPath) {
++ const base = frames.APP_INTERNAL_URLS[appId];
++ if (!base) return null;
++ try {
++ const res = await fetch(`${String(base).replace(/\/+$/, '')}${apiPath}`, { signal: AbortSignal.timeout(5000) });
++ if (!res.ok) return null;
++ return await res.json();
++ } catch (err) {
++ console.warn('[DevData] app fetch failed:', apiPath, err.message);
++ return null;
++ }
++}
++
++/** Resolve @username → app user id (live list first, channel lookup as fallback). */
++async function _resolveUser(appId, name) {
++ const lower = String(name).toLowerCase();
++ const streams = await frames.appLiveStreams(appId);
++ const live = streams.find(s => String(s.username || '').toLowerCase() === lower);
++ if (live && live.user_id) return { uid: live.user_id, username: live.username };
++ const ch = await _appJson(appId, `/api/streams/channel/${encodeURIComponent(name)}`);
++ const uid = ch?.channel?.user_id ?? ch?.user?.id ?? null;
++ if (uid) return { uid, username: ch?.user?.username || name };
++ return null;
++}
++
++function _vodEntry(v, { withTranscript = true } = {}) {
++ const out = {
++ vod_id: v.id,
++ title: v.title || null,
++ created_at: v.created_at,
++ duration_seconds: v.duration_seconds || 0,
++ visibility: v.visibility || 'public',
++ is_recording: !!v.is_recording,
++ managed_stream_id: v.managed_stream_id || null,
++ ai_overview: v.ai_overview || null,
++ ai_analyzed_at: v.ai_analyzed_at || null,
++ };
++ if (withTranscript) out.transcript = v.ai_transcript || null;
++ return out;
++}
++
++const VOD_COLS = 'id, title, created_at, duration_seconds, visibility, is_recording, managed_stream_id, user_id, ai_overview, ai_transcript, ai_analyzed_at';
++
++/** Streamer-level extras from the app: chat insight + streamer overview + memories. */
++async function _streamerExtras(appId, uid) {
++ if (!uid) return null;
++ const data = await _appJson(appId, `/api/chat-ai/user/${uid}`);
++ if (!data) return null;
++ return { user: data.user || null, streamer: data.streamer || null, chat_insight: data.insight || null };
++}
++
++/**
++ * Transcript + AI overview timeline for a slot or streamer.
++ * sel: slot id / slot slug → slot-scoped; @username → user-scoped (works offline).
++ */
++function getTranscriptTimeline(appId, sel, limit) {
++ const n = Math.min(50, Math.max(1, parseInt(limit, 10) || 10));
++ return cached(`tt:${appId}:${sel}:${n}`, async () => {
++ let rows = [], scope, label, uid = null;
++ if (String(sel).startsWith('@')) {
++ const user = await _resolveUser(appId, String(sel).slice(1));
++ if (!user) return { status: 404, body: { error: `Streamer ${sel} not found` } };
++ uid = user.uid; scope = 'streamer'; label = `@${user.username}`;
++ rows = db.all(
++ `SELECT ${VOD_COLS} FROM vods WHERE app_id = ? AND user_id = ? AND is_public = 1 AND COALESCE(clips_only,0) = 0
++ ORDER BY COALESCE(is_recording,0) DESC, created_at DESC LIMIT ?`, [appId, uid, n]);
++ } else {
++ const resolved = await frames.resolveSelector(appId, sel);
++ if (!resolved.msid) return { status: 404, body: { error: `Slot '${sel}' not found or not live (slugs resolve while live; slot ids always work)` } };
++ scope = 'slot'; label = resolved.label;
++ rows = db.all(
++ `SELECT ${VOD_COLS} FROM vods WHERE app_id = ? AND managed_stream_id = ? AND is_public = 1 AND COALESCE(clips_only,0) = 0
++ ORDER BY COALESCE(is_recording,0) DESC, created_at DESC LIMIT ?`, [appId, resolved.msid, n]);
++ uid = rows[0]?.user_id ?? null;
++ }
++ const current = rows.find(v => v.is_recording) || null;
++ const sessions = rows.filter(v => !v.is_recording);
++ const extras = await _streamerExtras(appId, uid);
++ return {
++ status: 200,
++ body: {
++ scope, selector: String(sel), label,
++ live: !!current,
++ current: current ? _vodEntry(current) : null,
++ sessions: sessions.map(v => _vodEntry(v)),
++ streamer: extras?.streamer || null, // overview + stream memories timeline
++ user: extras?.user || null,
++ generated_at: new Date().toISOString(),
++ },
++ };
++ });
++}
++
++/** Transcript + AI overview for one existing VOD id. */
++function getVodTranscript(vodId) {
++ return cached(`vt:${vodId}`, async () => {
++ const v = db.get(`SELECT ${VOD_COLS}, app_id, is_public FROM vods WHERE id = ?`, [vodId]);
++ if (!v || v.visibility === 'private') return { status: 404, body: { error: 'VOD not found' } };
++ return { status: 200, body: { ..._vodEntry(v), app_id: v.app_id, generated_at: new Date().toISOString() } };
++ });
++}
++
++/** A user's chat-related AI insight/timeline. sel: @username or numeric app user id. */
++function getChatInsight(appId, sel) {
++ return cached(`ci:${appId}:${sel}`, async () => {
++ let uid = null, username = null;
++ if (/^\d+$/.test(String(sel))) uid = parseInt(sel, 10);
++ else if (String(sel).startsWith('@')) {
++ const user = await _resolveUser(appId, String(sel).slice(1));
++ if (!user) return { status: 404, body: { error: `User ${sel} not found` } };
++ uid = user.uid; username = user.username;
++ } else return { status: 400, body: { error: 'Selector must be @username or a numeric user id' } };
++ const data = await _appJson(appId, `/api/chat-ai/user/${uid}`);
++ if (!data || (!data.insight && !data.streamer && !data.user)) {
++ return { status: 404, body: { error: `No chat insight for ${username ? '@' + username : 'user ' + uid}` } };
++ }
++ return {
++ status: 200,
++ body: {
++ user: data.user || (username ? { id: uid, username } : { id: uid }),
++ chat_insight: data.insight || null, // today-vs-alltime overviews + timeline + memory
++ streamer: data.streamer || null, // streamer overview + stream memories (when they stream)
++ generated_at: new Date().toISOString(),
++ },
++ };
++ });
++}
++
++module.exports = { getTranscriptTimeline, getVodTranscript, getChatInsight, CACHE_TTL_MS };
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 8cb8ffa..d271732 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -249,6 +249,37 @@ router.get('/live/:sel/frame.jpg', async (req, res) => {
}
});
++// ── Dev data APIs: transcripts / AI timelines / chat insight ─
++// JSON companions to the frame API — same selector grammar, 30s cache as the
++// rate limit, CORS-open. See README "Public serving".
++function _devDataRoute(handler) {
++ return async (req, res) => {
++ try {
++ res.set('Access-Control-Allow-Origin', '*');
++ const { status, body } = await handler(req);
++ res.status(status).set('Cache-Control', 'public, max-age=15').json(body);
++ } catch (err) {
++ console.error('[Public] dev-data error:', err.message);
++ if (!res.headersSent) res.status(500).json({ error: 'Failed to load data' });
++ }
++ };
++}
++
++// Full transcript log + AI overview timeline for a slot (id/slug) or streamer (@username).
++router.get('/live/:sel/transcript.json', _devDataRoute((req) =>
++ require('./dev-data').getTranscriptTimeline(String(req.query.app || 'live'), req.params.sel, req.query.limit)));
++
++// A user's chat-related AI insight + timeline (@username or numeric user id).
++router.get('/live/:sel/chat-insight.json', _devDataRoute((req) =>
++ require('./dev-data').getChatInsight(String(req.query.app || 'live'), req.params.sel)));
++
++// Transcript + AI overview for one existing VOD id.
++router.get('/v/:id/transcript.json', _devDataRoute((req) => {
++ const id = parseInt(req.params.id, 10);
++ if (!Number.isFinite(id)) return { status: 400, body: { error: 'Bad VOD id' } };
++ return require('./dev-data').getVodTranscript(id);
++}));
++
// ── Legacy thumbnail URLs ────────────────────────────────────
// Old-stack thumbnails lived at /api/thumbnails/<basename>, and migrated Live
// rows still carry those absolute URLs. Basenames change when a thumbnail is
ddiff --git a/server/thumbnails/live-frame-service.js b/server/thumbnails/live-frame-service.js
iindex f472fe0..643d686 100644
---- a/server/thumbnails/live-frame-service.js
++++ b/server/thumbnails/live-frame-service.js
@@@ -196,4 +196,8 @@ async function offlineCardJpeg(label, subtitle) {
return buf;
}
--module.exports = { getLiveFrame, resolveSelector, offlineCardSvg, offlineCardJpeg, CACHE_TTL_MS };
++module.exports = {
++ getLiveFrame, resolveSelector, offlineCardSvg, offlineCardJpeg, CACHE_TTL_MS,
++ // shared with the dev-data API (transcripts / chat insight)
++ APP_INTERNAL_URLS, appLiveStreams: _appLiveStreams,
++};