openvibers/openvibe.media Commits
Media index home page; absolute media URLs; immutable thumbnail caching
Changes
@@@ -28,10 +28,27 @@ function getDb() {const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');database.exec(schema);migrateColumns();++ normalizeThumbnailUrls();seedSettings();return database;}++// One-time normalization: migrated rows stored old-stack thumbnail URLs++// (/api/thumbnails/<name> or absolute variants). Canonical form is /t/<name>++// — the legacy redirect route covers stragglers, but direct URLs cache better.++function normalizeThumbnailUrls() {++ try {++ for (const table of ['vods', 'clips']) {++ const info = database.prepare(++ `UPDATE ${table}++ SET thumbnail_url = '/t/' || replace(thumbnail_url, rtrim(thumbnail_url, replace(thumbnail_url, '/', '')), '')++ WHERE thumbnail_url LIKE '%/api/thumbnails/%'`++ ).run();++ if (info.changes) console.log(`[DB] Normalized ${info.changes} legacy thumbnail URLs in ${table}`);++ }++ } catch (e) { console.warn('[DB] thumbnail URL normalization:', e.message); }++}++// Additive column migrations for DBs created before the column existed// (CREATE TABLE IF NOT EXISTS won't add columns to an existing table).function migrateColumns() {nnew file mode 100644@@@ -0,0 +1,260 @@++/**++ * OpenVibe.Media — public media index ("home page").++ *++ * Server-rendered browse UI at GET / — a mass index of every public piece of++ * media this service owns, tabbed by kind (Videos / Clips / Images / Text /++ * Thumbnails / Files), each card carrying its description / AI overview and a++ * link back to the SOURCE page in the owning app (the VOD / clip / stream /++ * paste it came from). Dependency-free: inline CSS, no client JS required.++ */++'use strict';++++const fs = require('fs');++const path = require('path');++const config = require('../config');++const db = require('../db/database');++++const PAGE_SIZE = 48;++const THUMB_DIR = path.resolve(config.thumbnails.path);++++// Public base URL per app for "source" links (env-overridable JSON map).++const APP_PUBLIC_URLS = (() => {++ try { const m = JSON.parse(process.env.APP_PUBLIC_URLS || ''); if (m && typeof m === 'object') return m; } catch { /* */ }++ return {++ live: 'https://openvibe.live',++ games: 'https://openvibe.games',++ tools: 'https://openvibe.tools',++ network: 'https://openvibe.network',++ };++})();++const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;++++const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));++const snip = (s, n = 180) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };++const fmtDur = (s) => { s = Math.round(s || 0); const m = Math.floor(s / 60), r = s % 60; return m >= 60 ? `${Math.floor(m / 60)}h${m % 60}m` : `${m}:${String(r).padStart(2, '0')}`; };++++// ── Row → card model per kind ────────────────────────────────++function vodCard(v) {++ return {++ kind: 'video', title: v.title || `VOD #${v.id}`, date: v.created_at, app: v.app_id,++ thumb: abs(v.thumbnail_url), duration: v.duration_seconds,++ text: snip(v.description || v.ai_overview),++ view: `${config.publicUrl}/v/${v.id}`,++ source: `${appUrl(v.app_id)}/vod/${v.id}`, sourceLabel: 'VOD page',++ };++}++function clipCard(c) {++ return {++ kind: 'clip', title: c.title || `Clip #${c.id}`, date: c.created_at, app: c.app_id,++ thumb: abs(c.thumbnail_url), duration: c.duration_seconds,++ text: snip(c.description || c.ai_overview),++ view: `${config.publicUrl}/c/${c.id}`,++ source: `${appUrl(c.app_id)}/clip/${c.id}`, sourceLabel: 'Clip page',++ };++}++function pasteCard(p) {++ const isImage = p.type === 'screenshot';++ return {++ kind: isImage ? 'image' : 'text', title: p.title || p.slug, date: p.created_at, app: p.app_id,++ thumb: isImage ? `${config.publicUrl}/p/${encodeURIComponent(p.slug)}/screenshot` : null,++ text: snip(isImage ? (p.content || p.description) : p.content),++ view: `${config.publicUrl}/p/${encodeURIComponent(p.slug)}`,++ source: `${appUrl(p.app_id)}/p/${encodeURIComponent(p.slug)}`, sourceLabel: 'Paste page',++ lang: p.language && p.language !== 'text' ? p.language : null,++ };++}++function fileCard(f) {++ return {++ kind: 'file', title: f.original_name || f.key, date: f.created_at, app: f.app_id,++ thumb: (f.mime || '').startsWith('image/') ? `${config.publicUrl}/f/${encodeURIComponent(f.key)}` : null,++ text: snip(`${f.mime || 'file'}${f.size > 0 ? ` · ${Math.round(f.size / 1024)} KB` : ''}`),++ view: `${config.publicUrl}/f/${encodeURIComponent(f.key)}`,++ source: null,++ };++}++function thumbCard(name, mtimeMs) {++ const m = /^(vod|clip|stream)-(\d+)/.exec(name);++ let source = null, sourceLabel = null;++ if (m) {++ const base = appUrl('live');++ if (m[1] === 'vod') { source = `${base}/vod/${m[2]}`; sourceLabel = 'VOD page'; }++ else if (m[1] === 'clip') { source = `${base}/clip/${m[2]}`; sourceLabel = 'Clip page'; }++ else { source = `${base}/stream/${m[2]}`; sourceLabel = 'Stream'; }++ }++ return {++ kind: 'thumbnail', title: name, date: new Date(mtimeMs).toISOString().slice(0, 19).replace('T', ' '),++ thumb: `${config.publicUrl}/t/${encodeURIComponent(name)}`, text: '',++ view: `${config.publicUrl}/t/${encodeURIComponent(name)}`, source, sourceLabel,++ };++}++++// ── Data per tab ─────────────────────────────────────────────++const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0";++const C_WHERE = "COALESCE(is_public,1) = 1";++const P_WHERE = "visibility = 'public'";++++let _thumbListCache = { at: 0, list: [] };++function thumbList() {++ if (Date.now() - _thumbListCache.at < 60_000) return _thumbListCache.list;++ let list = [];++ try {++ list = fs.readdirSync(THUMB_DIR)++ .filter(f => /\.(jpe?g|png|webp)$/i.test(f))++ .map(f => { try { return [f, fs.statSync(path.join(THUMB_DIR, f)).mtimeMs]; } catch { return null; } })++ .filter(Boolean)++ .sort((a, b) => b[1] - a[1]);++ } catch { /* */ }++ _thumbListCache = { at: Date.now(), list };++ return list;++}++++function fetchTab(tab, page) {++ const off = (page - 1) * PAGE_SIZE;++ const L = `ORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`;++ switch (tab) {++ case 'videos': return {++ total: db.get(`SELECT COUNT(*) c FROM vods WHERE ${V_WHERE}`).c,++ cards: db.all(`SELECT * FROM vods WHERE ${V_WHERE} ${L}`).map(vodCard),++ };++ case 'clips': return {++ total: db.get(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`).c,++ cards: db.all(`SELECT * FROM clips WHERE ${C_WHERE} ${L}`).map(clipCard),++ };++ case 'images': return {++ total: db.get(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`).c,++ cards: db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND type = 'screenshot' ${L}`).map(pasteCard),++ };++ case 'text': return {++ total: db.get(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`).c,++ cards: db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot' ${L}`).map(pasteCard),++ };++ case 'files': return {++ total: db.get('SELECT COUNT(*) c FROM files').c,++ cards: db.all(`SELECT * FROM files ${L}`).map(fileCard),++ };++ case 'thumbnails': {++ const list = thumbList();++ return { total: list.length, cards: list.slice(off, off + PAGE_SIZE).map(([f, t]) => thumbCard(f, t)) };++ }++ default: { // all — one unified reverse-chronological index across DB-backed media++ const rows = db.all(`++ SELECT 'vod' k, id ref, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL slug, NULL type, NULL content, NULL language FROM vods WHERE ${V_WHERE}++ UNION ALL++ SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE ${C_WHERE}++ UNION ALL++ SELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE ${P_WHERE}++ ORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`);++ const total = db.get(`SELECT++ (SELECT COUNT(*) FROM vods WHERE ${V_WHERE}) +++ (SELECT COUNT(*) FROM clips WHERE ${C_WHERE}) +++ (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) c`).c;++ const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref })++ : r.k === 'clip' ? clipCard({ ...r, id: r.ref })++ : pasteCard({ ...r, id: r.ref }));++ return { total, cards };++ }++ }++}++++let _countCache = { at: 0, counts: null };++function tabCounts() {++ if (_countCache.counts && Date.now() - _countCache.at < 60_000) return _countCache.counts;++ const c = (sql) => { try { return db.get(sql).c; } catch { return 0; } };++ const counts = {++ videos: c(`SELECT COUNT(*) c FROM vods WHERE ${V_WHERE}`),++ clips: c(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`),++ images: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),++ text: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),++ files: c('SELECT COUNT(*) c FROM files'),++ thumbnails: thumbList().length,++ };++ counts.all = counts.videos + counts.clips + counts.images + counts.text;++ _countCache = { at: Date.now(), counts };++ return counts;++}++++// ── Rendering ────────────────────────────────────────────────++const KIND_ICON = { video: '🎬', clip: '✂️', image: '🖼️', text: '📄', thumbnail: '🏞️', file: '📦' };++++function renderCard(c) {++ const preview = c.thumb++ ? `<img src="${esc(c.thumb)}" alt="" loading="lazy">`++ : `<div class="ph">${KIND_ICON[c.kind] || '📦'}</div>`;++ return `<div class="card">++ <a class="thumb" href="${esc(c.view)}">${preview}++ ${c.duration ? `<span class="dur">${fmtDur(c.duration)}</span>` : ''}++ <span class="kind">${esc(c.kind)}</span></a>++ <div class="meta">++ <div class="title" title="${esc(c.title)}">${esc(c.title)}</div>++ ${c.text ? `<div class="desc">${esc(c.text)}</div>` : ''}++ <div class="foot">++ <span>${esc(String(c.date || '').slice(0, 16))}${c.lang ? ` · ${esc(c.lang)}` : ''}${c.app ? ` · ${esc(c.app)}` : ''}</span>++ <span class="links"><a href="${esc(c.view)}">view</a>${c.source ? ` · <a href="${esc(c.source)}" title="${esc(c.sourceLabel || 'source')}">source ↗</a>` : ''}</span>++ </div>++ </div>++</div>`;++}++++const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];++++function renderPage(tab, page, data, counts) {++ const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));++ const nav = (p, label, dis) => dis ? `<span class="pg dis">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;++ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>OpenVibe.Media — Media Index</title>++<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, images, pastes, and thumbnails.">++<style>++:root{--bg:#0f1420;--panel:#161c2c;--border:#232b40;--text:#e6e9f2;--muted:#9aa3b8;--accent:#8b5cf6}++*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}++header{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);position:sticky;top:0;background:rgba(15,20,32,.92);backdrop-filter:blur(8px);z-index:2}++header .logo{width:30px;height:30px}++header h1{font-size:1.15rem}header h1 b{color:var(--accent)}++header .sub{color:var(--muted);font-size:.82rem;margin-left:auto}++nav{display:flex;gap:.25rem;padding:.7rem 1.4rem;flex-wrap:wrap}++nav a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}++nav a:hover{color:var(--text)}nav a.on{color:#fff;background:var(--accent)}++nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}++.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;padding:0 1.4rem}++.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}++.thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}++.thumb img{width:100%;height:100%;object-fit:cover;display:block}++.thumb .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.5}++.thumb .dur{position:absolute;right:.5rem;bottom:.5rem;background:rgba(0,0,0,.75);padding:.1rem .45rem;border-radius:6px;font-size:.75rem}++.thumb .kind{position:absolute;left:.5rem;top:.5rem;background:rgba(139,92,246,.85);color:#fff;padding:.1rem .5rem;border-radius:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}++.meta{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.35rem;flex:1}++.title{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}++.desc{color:var(--muted);font-size:.8rem;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}++.foot{margin-top:auto;display:flex;justify-content:space-between;gap:.6rem;color:var(--muted);font-size:.74rem;flex-wrap:wrap}++.foot a{color:var(--accent);text-decoration:none}.foot a:hover{text-decoration:underline}++.pager{display:flex;gap:.5rem;justify-content:center;align-items:center;padding:1.4rem;color:var(--muted);font-size:.88rem}++.pg{color:var(--accent);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--border);border-radius:8px}++.pg.dis{color:var(--muted);opacity:.4}++.empty{padding:3rem;text-align:center;color:var(--muted)}++</style></head><body>++<header>++ <svg class="logo" viewBox="0 0 100 100" fill="none" stroke="#8b5cf6" stroke-width="7"><circle cx="28" cy="66" r="11"/><circle cx="72" cy="66" r="11"/><circle cx="50" cy="30" r="11"/><line x1="34" y1="58" x2="45" y2="40"/><line x1="66" y1="58" x2="55" y2="40"/><line x1="39" y1="66" x2="61" y2="66"/></svg>++ <h1>OpenVibe<b>.Media</b></h1>++ <span class="sub">every public file on the network — videos, clips, images, pastes & thumbnails</span>++</header>++<nav>${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>++${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}++<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>++</body></html>`;++}++++function handle(req, res) {++ try {++ const tab = TABS.some(([k]) => k === req.query.tab) ? req.query.tab : 'all';++ const page = Math.max(1, parseInt(req.query.page, 10) || 1);++ const data = fetchTab(tab, page);++ res.set({ 'Cache-Control': 'public, max-age=30', 'Content-Type': 'text/html; charset=utf-8' });++ res.send(renderPage(tab, page, data, tabCounts()));++ } catch (err) {++ console.error('[Browse] render error:', err.message);++ res.status(500).send('Failed to render media index');++ }++}++++module.exports = { handle };@@@ -249,6 +249,13 @@ router.get('/live/:sel/frame.jpg', async (req, res) => {}});++// ── Media index home page ────────────────────────────────────++// GET / — server-rendered mass index of all public media (tabs: All / Videos /++// Clips / Images / Text / Thumbnails / Files), each card linking back to its++// source page in the owning app. See public/browse.js.++router.get('/', (req, res) => require('./browse').handle(req, res));++router.get('/browse', (req, res) => require('./browse').handle(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".@@@ -293,6 +300,7 @@ router.get('/api/thumbnails/:name', (req, res) => {? db.getVodById(parseInt(m[2], 10), 'live'): db.getClipById(parseInt(m[2], 10), 'live');if (row && row.thumbnail_url && !row.thumbnail_url.endsWith(`/${name}`)) {++ res.set('Cache-Control', 'public, max-age=3600');return res.redirect(302, row.thumbnail_url);}}@@@ -313,7 +321,7 @@ router.get('/f/screenshots/:name', (req, res) => {if (!name || !filePath.startsWith(dir) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {return res.status(404).json({ error: 'Not found' });}-- streamFileWithRange(req, res, filePath, { 'Cache-Control': 'public, max-age=86400', 'X-Robots-Tag': 'noindex' });++ streamFileWithRange(req, res, filePath, { 'Cache-Control': 'public, max-age=604800, immutable', 'X-Robots-Tag': 'noindex' });} catch (err) {console.error('[Public] /f/screenshots error:', err.message);if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });@@@ -211,14 +211,16 @@ function serveThumbnail(req, res) {const ext = path.extname(filename).toLowerCase();const contentType = ext === '.png' ? 'image/png' : 'image/jpeg';-- // Cache briefly for live thumbnails, longer for VOD/clip++ // Cache briefly for live thumbnails; VOD/clip filenames are timestamped++ // (a regenerated thumb gets a NEW name), so those bytes never change —++ // mark them immutable so browsers stop revalidating on every render.const isLive = filename.startsWith('stream-');-- const maxAge = isLive ? 30 : 86400;++ const cacheControl = isLive ? 'public, max-age=30' : 'public, max-age=604800, immutable';res.writeHead(200, {'Content-Type': contentType,'Content-Length': stat.size,-- 'Cache-Control': `public, max-age=${maxAge}`,++ 'Cache-Control': cacheControl,'X-Robots-Tag': 'noindex',});fs.createReadStream(filePath).pipe(res);@@@ -71,8 +71,9 @@ function clipPublic(clip) {duration_seconds: clip.duration_seconds || 0,// Basename only — the inherited SPA derives its playback URL from this.file_path: clip.file_path ? path.basename(clip.file_path) : null,-- playback_url: `/c/${clip.id}`,-- thumbnail_url: clip.thumbnail_url || null,++ // Absolute URLs — cross-origin consumers render these directly.++ playback_url: `${config.publicUrl}/c/${clip.id}`,++ thumbnail_url: require('./finalize')._absUrl(clip.thumbnail_url),visibility: clip.visibility || 'public',is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',storage_provider: clip.storage_provider || 'local',@@@ -16,9 +16,17 @@ const path = require('path');const fs = require('fs');const { spawn } = require('child_process');const db = require('../db/database');++const config = require('../config');const tools = require('./media-tools');const { sendWebhook } = require('../webhooks');++/** Absolute-ize a stored Media-relative URL (/t/…, /api/thumbnails/…). */++function _absUrl(u) {++ if (!u) return null;++ if (/^https?:\/\//i.test(u)) return u;++ return `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`;++}++const _finalizing = new Set();function isFinalizing(vodId) {@@@ -43,8 +51,10 @@ function vodPublic(vod) {// Basename only (server paths stay private) — the inherited SPA derives// its /file/<name> playback URL from this.file_path: vod.file_path ? path.basename(vod.file_path) : null,-- playback_url: `/v/${vod.id}`,-- thumbnail_url: vod.thumbnail_url || null,++ // Absolute URLs — consumers on other origins (app SPAs) render these++ // directly; relative paths resolved against the app's origin and broke.++ playback_url: `${config.publicUrl}/v/${vod.id}`,++ thumbnail_url: _absUrl(vod.thumbnail_url),storage_provider: vod.storage_provider || 'local',visibility: vod.visibility || 'public',// Kept alongside visibility — inherited SPA surfaces (manager badges,@@@ -288,4 +298,4 @@ async function _doFinalize(vodId, opts) {return finalVod;}--module.exports = { finalizeVod, isFinalizing, vodPublic, rebuildWebmFromMaster };++module.exports = { finalizeVod, isFinalizing, vodPublic, rebuildWebmFromMaster, _absUrl };
@@ -28,10 +28,27 @@ function getDb() {
@@ -28,10 +28,27 @@ function getDb() {
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
database.exec(schema);
database.exec(schema);
migrateColumns();
migrateColumns();
+ normalizeThumbnailUrls();
seedSettings();
seedSettings();
return database;
return database;
}
}
+// One-time normalization: migrated rows stored old-stack thumbnail URLs
+// (/api/thumbnails/<name> or absolute variants). Canonical form is /t/<name>
+// — the legacy redirect route covers stragglers, but direct URLs cache better.
+function normalizeThumbnailUrls() {
+ try {
+ for (const table of ['vods', 'clips']) {
+ const info = database.prepare(
+ `UPDATE ${table}
+ SET thumbnail_url = '/t/' || replace(thumbnail_url, rtrim(thumbnail_url, replace(thumbnail_url, '/', '')), '')
+ WHERE thumbnail_url LIKE '%/api/thumbnails/%'`
+ ).run();
+ if (info.changes) console.log(`[DB] Normalized ${info.changes} legacy thumbnail URLs in ${table}`);
+ }
+ } catch (e) { console.warn('[DB] thumbnail URL normalization:', e.message); }
+}
+
// Additive column migrations for DBs created before the column existed
// Additive column migrations for DBs created before the column existed
// (CREATE TABLE IF NOT EXISTS won't add columns to an existing table).
// (CREATE TABLE IF NOT EXISTS won't add columns to an existing table).
function migrateColumns() {
function migrateColumns() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,260 @@
@@ -0,0 +1,260 @@
+/**
+ * OpenVibe.Media — public media index ("home page").
+ *
+ * Server-rendered browse UI at GET / — a mass index of every public piece of
+ * media this service owns, tabbed by kind (Videos / Clips / Images / Text /
+ * Thumbnails / Files), each card carrying its description / AI overview and a
+ * link back to the SOURCE page in the owning app (the VOD / clip / stream /
+ * paste it came from). Dependency-free: inline CSS, no client JS required.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const config = require('../config');
+const db = require('../db/database');
+
+const PAGE_SIZE = 48;
+const THUMB_DIR = path.resolve(config.thumbnails.path);
+
+// Public base URL per app for "source" links (env-overridable JSON map).
+const APP_PUBLIC_URLS = (() => {
+ try { const m = JSON.parse(process.env.APP_PUBLIC_URLS || ''); if (m && typeof m === 'object') return m; } catch { /* */ }
+ return {
+ live: 'https://openvibe.live',
+ games: 'https://openvibe.games',
+ tools: 'https://openvibe.tools',
+ network: 'https://openvibe.network',
+ };
+})();
+const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;
+
+const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
+const snip = (s, n = 180) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
+const fmtDur = (s) => { s = Math.round(s || 0); const m = Math.floor(s / 60), r = s % 60; return m >= 60 ? `${Math.floor(m / 60)}h${m % 60}m` : `${m}:${String(r).padStart(2, '0')}`; };
+
+// ── Row → card model per kind ────────────────────────────────
+function vodCard(v) {
+ return {
+ kind: 'video', title: v.title || `VOD #${v.id}`, date: v.created_at, app: v.app_id,
+ thumb: abs(v.thumbnail_url), duration: v.duration_seconds,
+ text: snip(v.description || v.ai_overview),
+ view: `${config.publicUrl}/v/${v.id}`,
+ source: `${appUrl(v.app_id)}/vod/${v.id}`, sourceLabel: 'VOD page',
+ };
+}
+function clipCard(c) {
+ return {
+ kind: 'clip', title: c.title || `Clip #${c.id}`, date: c.created_at, app: c.app_id,
+ thumb: abs(c.thumbnail_url), duration: c.duration_seconds,
+ text: snip(c.description || c.ai_overview),
+ view: `${config.publicUrl}/c/${c.id}`,
+ source: `${appUrl(c.app_id)}/clip/${c.id}`, sourceLabel: 'Clip page',
+ };
+}
+function pasteCard(p) {
+ const isImage = p.type === 'screenshot';
+ return {
+ kind: isImage ? 'image' : 'text', title: p.title || p.slug, date: p.created_at, app: p.app_id,
+ thumb: isImage ? `${config.publicUrl}/p/${encodeURIComponent(p.slug)}/screenshot` : null,
+ text: snip(isImage ? (p.content || p.description) : p.content),
+ view: `${config.publicUrl}/p/${encodeURIComponent(p.slug)}`,
+ source: `${appUrl(p.app_id)}/p/${encodeURIComponent(p.slug)}`, sourceLabel: 'Paste page',
+ lang: p.language && p.language !== 'text' ? p.language : null,
+ };
+}
+function fileCard(f) {
+ return {
+ kind: 'file', title: f.original_name || f.key, date: f.created_at, app: f.app_id,
+ thumb: (f.mime || '').startsWith('image/') ? `${config.publicUrl}/f/${encodeURIComponent(f.key)}` : null,
+ text: snip(`${f.mime || 'file'}${f.size > 0 ? ` · ${Math.round(f.size / 1024)} KB` : ''}`),
+ view: `${config.publicUrl}/f/${encodeURIComponent(f.key)}`,
+ source: null,
+ };
+}
+function thumbCard(name, mtimeMs) {
+ const m = /^(vod|clip|stream)-(\d+)/.exec(name);
+ let source = null, sourceLabel = null;
+ if (m) {
+ const base = appUrl('live');
+ if (m[1] === 'vod') { source = `${base}/vod/${m[2]}`; sourceLabel = 'VOD page'; }
+ else if (m[1] === 'clip') { source = `${base}/clip/${m[2]}`; sourceLabel = 'Clip page'; }
+ else { source = `${base}/stream/${m[2]}`; sourceLabel = 'Stream'; }
+ }
+ return {
+ kind: 'thumbnail', title: name, date: new Date(mtimeMs).toISOString().slice(0, 19).replace('T', ' '),
+ thumb: `${config.publicUrl}/t/${encodeURIComponent(name)}`, text: '',
+ view: `${config.publicUrl}/t/${encodeURIComponent(name)}`, source, sourceLabel,
+ };
+}
+
+// ── Data per tab ─────────────────────────────────────────────
+const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0";
+const C_WHERE = "COALESCE(is_public,1) = 1";
+const P_WHERE = "visibility = 'public'";
+
+let _thumbListCache = { at: 0, list: [] };
+function thumbList() {
+ if (Date.now() - _thumbListCache.at < 60_000) return _thumbListCache.list;
+ let list = [];
+ try {
+ list = fs.readdirSync(THUMB_DIR)
+ .filter(f => /\.(jpe?g|png|webp)$/i.test(f))
+ .map(f => { try { return [f, fs.statSync(path.join(THUMB_DIR, f)).mtimeMs]; } catch { return null; } })
+ .filter(Boolean)
+ .sort((a, b) => b[1] - a[1]);
+ } catch { /* */ }
+ _thumbListCache = { at: Date.now(), list };
+ return list;
+}
+
+function fetchTab(tab, page) {
+ const off = (page - 1) * PAGE_SIZE;
+ const L = `ORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`;
+ switch (tab) {
+ case 'videos': return {
+ total: db.get(`SELECT COUNT(*) c FROM vods WHERE ${V_WHERE}`).c,
+ cards: db.all(`SELECT * FROM vods WHERE ${V_WHERE} ${L}`).map(vodCard),
+ };
+ case 'clips': return {
+ total: db.get(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`).c,
+ cards: db.all(`SELECT * FROM clips WHERE ${C_WHERE} ${L}`).map(clipCard),
+ };
+ case 'images': return {
+ total: db.get(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`).c,
+ cards: db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND type = 'screenshot' ${L}`).map(pasteCard),
+ };
+ case 'text': return {
+ total: db.get(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`).c,
+ cards: db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot' ${L}`).map(pasteCard),
+ };
+ case 'files': return {
+ total: db.get('SELECT COUNT(*) c FROM files').c,
+ cards: db.all(`SELECT * FROM files ${L}`).map(fileCard),
+ };
+ case 'thumbnails': {
+ const list = thumbList();
+ return { total: list.length, cards: list.slice(off, off + PAGE_SIZE).map(([f, t]) => thumbCard(f, t)) };
+ }
+ default: { // all — one unified reverse-chronological index across DB-backed media
+ const rows = db.all(`
+ SELECT 'vod' k, id ref, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL slug, NULL type, NULL content, NULL language FROM vods WHERE ${V_WHERE}
+ UNION ALL
+ SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE ${C_WHERE}
+ UNION ALL
+ SELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE ${P_WHERE}
+ ORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`);
+ const total = db.get(`SELECT
+ (SELECT COUNT(*) FROM vods WHERE ${V_WHERE}) +
+ (SELECT COUNT(*) FROM clips WHERE ${C_WHERE}) +
+ (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) c`).c;
+ const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref })
+ : r.k === 'clip' ? clipCard({ ...r, id: r.ref })
+ : pasteCard({ ...r, id: r.ref }));
+ return { total, cards };
+ }
+ }
+}
+
+let _countCache = { at: 0, counts: null };
+function tabCounts() {
+ if (_countCache.counts && Date.now() - _countCache.at < 60_000) return _countCache.counts;
+ const c = (sql) => { try { return db.get(sql).c; } catch { return 0; } };
+ const counts = {
+ videos: c(`SELECT COUNT(*) c FROM vods WHERE ${V_WHERE}`),
+ clips: c(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`),
+ images: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),
+ text: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),
+ files: c('SELECT COUNT(*) c FROM files'),
+ thumbnails: thumbList().length,
+ };
+ counts.all = counts.videos + counts.clips + counts.images + counts.text;
+ _countCache = { at: Date.now(), counts };
+ return counts;
+}
+
+// ── Rendering ────────────────────────────────────────────────
+const KIND_ICON = { video: '🎬', clip: '✂️', image: '🖼️', text: '📄', thumbnail: '🏞️', file: '📦' };
+
+function renderCard(c) {
+ const preview = c.thumb
+ ? `<img src="${esc(c.thumb)}" alt="" loading="lazy">`
+ : `<div class="ph">${KIND_ICON[c.kind] || '📦'}</div>`;
+ return `<div class="card">
+ <a class="thumb" href="${esc(c.view)}">${preview}
+ ${c.duration ? `<span class="dur">${fmtDur(c.duration)}</span>` : ''}
+ <span class="kind">${esc(c.kind)}</span></a>
+ <div class="meta">
+ <div class="title" title="${esc(c.title)}">${esc(c.title)}</div>
+ ${c.text ? `<div class="desc">${esc(c.text)}</div>` : ''}
+ <div class="foot">
+ <span>${esc(String(c.date || '').slice(0, 16))}${c.lang ? ` · ${esc(c.lang)}` : ''}${c.app ? ` · ${esc(c.app)}` : ''}</span>
+ <span class="links"><a href="${esc(c.view)}">view</a>${c.source ? ` · <a href="${esc(c.source)}" title="${esc(c.sourceLabel || 'source')}">source ↗</a>` : ''}</span>
+ </div>
+ </div>
+</div>`;
+}
+
+const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
+
+function renderPage(tab, page, data, counts) {
+ const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
+ const nav = (p, label, dis) => dis ? `<span class="pg dis">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>OpenVibe.Media — Media Index</title>
+<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, images, pastes, and thumbnails.">
+<style>
+:root{--bg:#0f1420;--panel:#161c2c;--border:#232b40;--text:#e6e9f2;--muted:#9aa3b8;--accent:#8b5cf6}
+*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}
+header{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);position:sticky;top:0;background:rgba(15,20,32,.92);backdrop-filter:blur(8px);z-index:2}
+header .logo{width:30px;height:30px}
+header h1{font-size:1.15rem}header h1 b{color:var(--accent)}
+header .sub{color:var(--muted);font-size:.82rem;margin-left:auto}
+nav{display:flex;gap:.25rem;padding:.7rem 1.4rem;flex-wrap:wrap}
+nav a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}
+nav a:hover{color:var(--text)}nav a.on{color:#fff;background:var(--accent)}
+nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;padding:0 1.4rem}
+.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
+.thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
+.thumb img{width:100%;height:100%;object-fit:cover;display:block}
+.thumb .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.5}
+.thumb .dur{position:absolute;right:.5rem;bottom:.5rem;background:rgba(0,0,0,.75);padding:.1rem .45rem;border-radius:6px;font-size:.75rem}
+.thumb .kind{position:absolute;left:.5rem;top:.5rem;background:rgba(139,92,246,.85);color:#fff;padding:.1rem .5rem;border-radius:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}
+.meta{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.35rem;flex:1}
+.title{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.desc{color:var(--muted);font-size:.8rem;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
+.foot{margin-top:auto;display:flex;justify-content:space-between;gap:.6rem;color:var(--muted);font-size:.74rem;flex-wrap:wrap}
+.foot a{color:var(--accent);text-decoration:none}.foot a:hover{text-decoration:underline}
+.pager{display:flex;gap:.5rem;justify-content:center;align-items:center;padding:1.4rem;color:var(--muted);font-size:.88rem}
+.pg{color:var(--accent);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--border);border-radius:8px}
+.pg.dis{color:var(--muted);opacity:.4}
+.empty{padding:3rem;text-align:center;color:var(--muted)}
+</style></head><body>
+<header>
+ <svg class="logo" viewBox="0 0 100 100" fill="none" stroke="#8b5cf6" stroke-width="7"><circle cx="28" cy="66" r="11"/><circle cx="72" cy="66" r="11"/><circle cx="50" cy="30" r="11"/><line x1="34" y1="58" x2="45" y2="40"/><line x1="66" y1="58" x2="55" y2="40"/><line x1="39" y1="66" x2="61" y2="66"/></svg>
+ <h1>OpenVibe<b>.Media</b></h1>
+ <span class="sub">every public file on the network — videos, clips, images, pastes & thumbnails</span>
+</header>
+<nav>${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
+${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
+<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
+</body></html>`;
+}
+
+function handle(req, res) {
+ try {
+ const tab = TABS.some(([k]) => k === req.query.tab) ? req.query.tab : 'all';
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const data = fetchTab(tab, page);
+ res.set({ 'Cache-Control': 'public, max-age=30', 'Content-Type': 'text/html; charset=utf-8' });
+ res.send(renderPage(tab, page, data, tabCounts()));
+ } catch (err) {
+ console.error('[Browse] render error:', err.message);
+ res.status(500).send('Failed to render media index');
+ }
+}
+
+module.exports = { handle };
@@ -249,6 +249,13 @@ router.get('/live/:sel/frame.jpg', async (req, res) => {
@@ -249,6 +249,13 @@ router.get('/live/:sel/frame.jpg', async (req, res) => {
}
}
});
});
+// ── Media index home page ────────────────────────────────────
+// GET / — server-rendered mass index of all public media (tabs: All / Videos /
+// Clips / Images / Text / Thumbnails / Files), each card linking back to its
+// source page in the owning app. See public/browse.js.
+router.get('/', (req, res) => require('./browse').handle(req, res));
+router.get('/browse', (req, res) => require('./browse').handle(req, res));
+
// ── Dev data APIs: transcripts / AI timelines / chat insight ─
// ── Dev data APIs: transcripts / AI timelines / chat insight ─
// JSON companions to the frame API — same selector grammar, 30s cache as the
// JSON companions to the frame API — same selector grammar, 30s cache as the
// rate limit, CORS-open. See README "Public serving".
// rate limit, CORS-open. See README "Public serving".
@@ -293,6 +300,7 @@ router.get('/api/thumbnails/:name', (req, res) => {
@@ -293,6 +300,7 @@ router.get('/api/thumbnails/:name', (req, res) => {
? db.getVodById(parseInt(m[2], 10), 'live')
? db.getVodById(parseInt(m[2], 10), 'live')
: db.getClipById(parseInt(m[2], 10), 'live');
: db.getClipById(parseInt(m[2], 10), 'live');
if (row && row.thumbnail_url && !row.thumbnail_url.endsWith(`/${name}`)) {
if (row && row.thumbnail_url && !row.thumbnail_url.endsWith(`/${name}`)) {
+ res.set('Cache-Control', 'public, max-age=3600');
return res.redirect(302, row.thumbnail_url);
return res.redirect(302, row.thumbnail_url);
}
}
}
}
@@ -313,7 +321,7 @@ router.get('/f/screenshots/:name', (req, res) => {
@@ -313,7 +321,7 @@ router.get('/f/screenshots/:name', (req, res) => {
if (!name || !filePath.startsWith(dir) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
if (!name || !filePath.startsWith(dir) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return res.status(404).json({ error: 'Not found' });
return res.status(404).json({ error: 'Not found' });
}
}
- streamFileWithRange(req, res, filePath, { 'Cache-Control': 'public, max-age=86400', 'X-Robots-Tag': 'noindex' });
+ streamFileWithRange(req, res, filePath, { 'Cache-Control': 'public, max-age=604800, immutable', 'X-Robots-Tag': 'noindex' });
} catch (err) {
} catch (err) {
console.error('[Public] /f/screenshots error:', err.message);
console.error('[Public] /f/screenshots error:', err.message);
if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });
if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });
@@ -211,14 +211,16 @@ function serveThumbnail(req, res) {
@@ -211,14 +211,16 @@ function serveThumbnail(req, res) {
const ext = path.extname(filename).toLowerCase();
const ext = path.extname(filename).toLowerCase();
const contentType = ext === '.png' ? 'image/png' : 'image/jpeg';
const contentType = ext === '.png' ? 'image/png' : 'image/jpeg';
- // Cache briefly for live thumbnails, longer for VOD/clip
+ // Cache briefly for live thumbnails; VOD/clip filenames are timestamped
+ // (a regenerated thumb gets a NEW name), so those bytes never change —
+ // mark them immutable so browsers stop revalidating on every render.
const isLive = filename.startsWith('stream-');
const isLive = filename.startsWith('stream-');
- const maxAge = isLive ? 30 : 86400;
+ const cacheControl = isLive ? 'public, max-age=30' : 'public, max-age=604800, immutable';
res.writeHead(200, {
res.writeHead(200, {
'Content-Type': contentType,
'Content-Type': contentType,
'Content-Length': stat.size,
'Content-Length': stat.size,
- 'Cache-Control': `public, max-age=${maxAge}`,
+ 'Cache-Control': cacheControl,
'X-Robots-Tag': 'noindex',
'X-Robots-Tag': 'noindex',
});
});
fs.createReadStream(filePath).pipe(res);
fs.createReadStream(filePath).pipe(res);
@@ -71,8 +71,9 @@ function clipPublic(clip) {
@@ -71,8 +71,9 @@ function clipPublic(clip) {
duration_seconds: clip.duration_seconds || 0,
duration_seconds: clip.duration_seconds || 0,
// Basename only — the inherited SPA derives its playback URL from this.
// Basename only — the inherited SPA derives its playback URL from this.
file_path: clip.file_path ? path.basename(clip.file_path) : null,
file_path: clip.file_path ? path.basename(clip.file_path) : null,
- playback_url: `/c/${clip.id}`,
- thumbnail_url: clip.thumbnail_url || null,
+ // Absolute URLs — cross-origin consumers render these directly.
+ playback_url: `${config.publicUrl}/c/${clip.id}`,
+ thumbnail_url: require('./finalize')._absUrl(clip.thumbnail_url),
visibility: clip.visibility || 'public',
visibility: clip.visibility || 'public',
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
storage_provider: clip.storage_provider || 'local',
storage_provider: clip.storage_provider || 'local',
@@ -16,9 +16,17 @@ const path = require('path');
@@ -16,9 +16,17 @@ const path = require('path');
const fs = require('fs');
const fs = require('fs');
const { spawn } = require('child_process');
const { spawn } = require('child_process');
const db = require('../db/database');
const db = require('../db/database');
+const config = require('../config');
const tools = require('./media-tools');
const tools = require('./media-tools');
const { sendWebhook } = require('../webhooks');
const { sendWebhook } = require('../webhooks');
+/** Absolute-ize a stored Media-relative URL (/t/…, /api/thumbnails/…). */
+function _absUrl(u) {
+ if (!u) return null;
+ if (/^https?:\/\//i.test(u)) return u;
+ return `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`;
+}
+
const _finalizing = new Set();
const _finalizing = new Set();
function isFinalizing(vodId) {
function isFinalizing(vodId) {
@@ -43,8 +51,10 @@ function vodPublic(vod) {
@@ -43,8 +51,10 @@ function vodPublic(vod) {
// Basename only (server paths stay private) — the inherited SPA derives
// Basename only (server paths stay private) — the inherited SPA derives
// its /file/<name> playback URL from this.
// its /file/<name> playback URL from this.
file_path: vod.file_path ? path.basename(vod.file_path) : null,
file_path: vod.file_path ? path.basename(vod.file_path) : null,
- playback_url: `/v/${vod.id}`,
- thumbnail_url: vod.thumbnail_url || null,
+ // Absolute URLs — consumers on other origins (app SPAs) render these
+ // directly; relative paths resolved against the app's origin and broke.
+ playback_url: `${config.publicUrl}/v/${vod.id}`,
+ thumbnail_url: _absUrl(vod.thumbnail_url),
storage_provider: vod.storage_provider || 'local',
storage_provider: vod.storage_provider || 'local',
visibility: vod.visibility || 'public',
visibility: vod.visibility || 'public',
// Kept alongside visibility — inherited SPA surfaces (manager badges,
// Kept alongside visibility — inherited SPA surfaces (manager badges,
@@ -288,4 +298,4 @@ async function _doFinalize(vodId, opts) {
@@ -288,4 +298,4 @@ async function _doFinalize(vodId, opts) {
return finalVod;
return finalVod;
}
}
-module.exports = { finalizeVod, isFinalizing, vodPublic, rebuildWebmFromMaster };
+module.exports = { finalizeVod, isFinalizing, vodPublic, rebuildWebmFromMaster, _absUrl };