openvibers/openvibe.media Commits

Zero-VOD prevention, chat-asset registry, AI fields in responses, navbar on index

OP OpenVibers committed 18 Aug 2026 · d7a4bcae3b · 14 files, +497 −22

- Zero/ghost VODs: finalize deletes rows with no media file; recorder failure
  fallback quarantines instead of bare-marking ready+public; health-job boot
  sweep deletes empty ghosts and quarantines short strays (fixes the 0:00
  cards across all streaming methods).
- App assets: new assets table + /api/v1/:app/assets (app-key upsert/list/
  delete) + public /a/:id serving — canonical home for app emotes and channel
  sounds, with uploader + channel attribution.
- Media index: Emotes and Sounds tabs (cards link to the uploader's channel),
  assets included in All; shared OpenVibe navbar embedded.
- API mappers now emit ai_overview/ai_analyzed_at (vods, clips), ai_summary/
  ai_tags (pastes); detail responses include ai_transcript.

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

Changes

ddiff --git a/server/assets/routes.js b/server/assets/routes.js
nnew file mode 100644
iindex 0000000..881eea2
---- /dev/null
++++ b/server/assets/routes.js
@@@ -0,0 +1,97 @@
++/**
++ * OpenVibe.Media — app asset API (emotes / channel sounds).
++ * Mounted at /api/v1/:app/assets, app-key auth only: the owning app syncs its
++ * chat assets here so Media is their canonical public home (served at /a/:id,
++ * browsable on the media index with uploader + channel attribution).
++ *
++ * POST / multipart `file` + { kind, name, username?, user_id?,
++ * channel_username?, duration_seconds?, meta? } → upsert
++ * GET /?kind= list
++ * DELETE /:id
++ */
++'use strict';
++
++const express = require('express');
++const path = require('path');
++const fs = require('fs');
++const crypto = require('crypto');
++const multer = require('multer');
++const config = require('../config');
++const db = require('../db/database');
++const { tenantAuth } = require('../auth');
++
++const ASSET_DIR = config.assets.path;
++if (!fs.existsSync(ASSET_DIR)) fs.mkdirSync(ASSET_DIR, { recursive: true });
++
++const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
++const router = express.Router({ mergeParams: true });
++
++const assetUrl = (a) => `${config.publicUrl}/a/${a.id}`;
++function assetPublic(a) {
++ return {
++ id: a.id, app_id: a.app_id, kind: a.kind, name: a.name,
++ url: assetUrl(a), mime: a.mime,
++ user_id: a.user_id, username: a.username, channel_username: a.channel_username,
++ duration_seconds: a.duration_seconds || 0, created_at: a.created_at,
++ };
++}
++
++router.post('/', tenantAuth(), upload.single('file'), (req, res) => {
++ try {
++ const { kind, name } = req.body || {};
++ if (!['emote', 'sound'].includes(kind)) return res.status(400).json({ error: "kind must be 'emote' or 'sound'" });
++ if (!name || !req.file) return res.status(400).json({ error: 'name and file are required' });
++ const ext = (path.extname(req.file.originalname || '') || '').toLowerCase().slice(0, 8) || '.bin';
++ const filename = `${kind}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
++ const filePath = path.join(ASSET_DIR, filename);
++ fs.writeFileSync(filePath, req.file.buffer);
++ let meta = {};
++ try { meta = req.body.meta ? JSON.parse(req.body.meta) : {}; } catch { /* */ }
++ const prev = db.get('SELECT file_path FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?',
++ [req.appId, kind, String(name), String(req.body.channel_username || '')]);
++ const row = db.upsertAsset({
++ app_id: req.appId, kind, name: String(name).slice(0, 100),
++ file_path: filePath, mime: req.file.mimetype || 'application/octet-stream',
++ user_id: req.body.user_id != null ? parseInt(req.body.user_id, 10) || null : null,
++ username: String(req.body.username || '').slice(0, 60),
++ channel_username: String(req.body.channel_username || '').slice(0, 60),
++ duration_seconds: parseFloat(req.body.duration_seconds) || 0,
++ meta,
++ });
++ // Replaced an earlier upload for the same identity → drop the old file.
++ if (prev && prev.file_path && prev.file_path !== filePath && fs.existsSync(prev.file_path)) {
++ try { fs.unlinkSync(prev.file_path); } catch { /* */ }
++ }
++ res.status(201).json({ asset: assetPublic(row) });
++ } catch (err) {
++ console.error('[Assets] upload error:', err.message);
++ res.status(500).json({ error: 'Failed to store asset' });
++ }
++});
++
++router.get('/', tenantAuth(), (req, res) => {
++ try {
++ const limit = Math.min(Math.max(parseInt(req.query.limit || '200', 10), 1), 1000);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ const kind = ['emote', 'sound'].includes(req.query.kind) ? req.query.kind : null;
++ const assets = db.listAssets(req.appId, { kind, limit, offset }).map(assetPublic);
++ res.json({ assets, total: db.countAssets(req.appId, { kind }), limit, offset });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to list assets' });
++ }
++});
++
++router.delete('/:id', tenantAuth(), (req, res) => {
++ try {
++ const a = db.getAssetById(parseInt(req.params.id, 10));
++ if (!a || a.app_id !== req.appId) return res.status(404).json({ error: 'Not found' });
++ try { if (a.file_path && fs.existsSync(a.file_path)) fs.unlinkSync(a.file_path); } catch { /* */ }
++ db.deleteAsset(a.id);
++ res.json({ success: true });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to delete asset' });
++ }
++});
++
++module.exports = router;
++module.exports.assetPublic = assetPublic;
ddiff --git a/server/config.js b/server/config.js
iindex 09b6d9b..e77e3e5 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -32,6 +32,9 @@ const config = {
pastes: {
path: process.env.PASTES_PATH || './data/pastes',
},
++ assets: {
++ path: process.env.ASSETS_PATH || './data/assets',
++ },
thumbnails: {
path: process.env.THUMBNAILS_PATH || './data/thumbnails',
},
@@@ -65,6 +68,7 @@ for (const p of ['path']) {
}
config.vod.clipsPath = path.resolve(config.vod.clipsPath);
config.pastes.path = path.resolve(config.pastes.path);
++config.assets.path = path.resolve(config.assets.path);
config.thumbnails.path = path.resolve(config.thumbnails.path);
config.files.path = path.resolve(config.files.path);
config.db.path = path.resolve(config.db.path);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 1c790d0..aa398a8 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -532,6 +532,34 @@ function getFileByKey(key, appId = null) {
return get(`SELECT * FROM files WHERE key = ?${clause}`, params);
}
++// ── App assets (emotes / channel sounds) ─────────────────────
++function upsertAsset({ app_id, kind, name, file_path, mime, user_id, username, channel_username, duration_seconds, meta }) {
++ const info = run(`
++ INSERT INTO assets (app_id, kind, name, file_path, mime, user_id, username, channel_username, duration_seconds, meta_json)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT(app_id, kind, name, channel_username) DO UPDATE SET
++ file_path = excluded.file_path, mime = excluded.mime,
++ user_id = excluded.user_id, username = excluded.username,
++ duration_seconds = excluded.duration_seconds, meta_json = excluded.meta_json`,
++ [app_id, kind, name, file_path, mime || 'application/octet-stream', user_id ?? null,
++ username || '', channel_username || '', duration_seconds || 0, JSON.stringify(meta || {})]);
++ return get('SELECT * FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?',
++ [app_id, kind, name, channel_username || '']);
++}
++function getAssetById(id) { return get('SELECT * FROM assets WHERE id = ?', [id]); }
++function listAssets(appId, { kind = null, limit = 100, offset = 0 } = {}) {
++ const conds = ['app_id = ?']; const params = [appId];
++ if (kind) { conds.push('kind = ?'); params.push(kind); }
++ params.push(limit, offset);
++ return all(`SELECT * FROM assets WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);
++}
++function countAssets(appId, { kind = null } = {}) {
++ const conds = ['app_id = ?']; const params = [appId];
++ if (kind) { conds.push('kind = ?'); params.push(kind); }
++ return get(`SELECT COUNT(*) c FROM assets WHERE ${conds.join(' AND ')}`, params).c;
++}
++function deleteAsset(id) { return run('DELETE FROM assets WHERE id = ?', [id]); }
++
function listFiles(appId, { limit = 100, offset = 0 } = {}) {
return all('SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', [appId, limit, offset]);
}
@@@ -585,6 +613,7 @@ module.exports = {
// vods
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
latestVodThumbsByManagedStreams, getAppStats,
++ upsertAsset, getAssetById, listAssets, countAssets, deleteAsset,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
// clips
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex 7df3e21..f2649b6 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -182,3 +182,23 @@ CREATE TABLE IF NOT EXISTS media_settings (
type TEXT DEFAULT 'string',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
++
++-- ── App assets (emotes / channel sounds) ─────────────────────
++-- Canonical public home for per-channel chat assets the apps upload; browse
++-- index shows uploader + channel. Unique per (app, kind, name, channel).
++CREATE TABLE IF NOT EXISTS assets (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ app_id TEXT NOT NULL DEFAULT 'live',
++ kind TEXT NOT NULL, -- 'emote' | 'sound'
++ name TEXT NOT NULL, -- emote code or !command
++ file_path TEXT NOT NULL,
++ mime TEXT DEFAULT 'application/octet-stream',
++ user_id INTEGER, -- uploader (app-local id)
++ username TEXT DEFAULT '', -- uploader name snapshot
++ channel_username TEXT DEFAULT '', -- channel it belongs to
++ duration_seconds REAL DEFAULT 0,
++ meta_json TEXT DEFAULT '{}',
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++CREATE INDEX IF NOT EXISTS idx_assets_app_kind ON assets(app_id, kind, created_at DESC);
++CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_identity ON assets(app_id, kind, name, channel_username);
ddiff --git a/server/index.js b/server/index.js
iindex a9c13da..90c39ff 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -69,6 +69,7 @@ app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/pastes', require('./pastes/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
++app.use('/api/v1/:app/assets', require('./assets/routes'));
app.use('/api/v1/:app/admin/storage', require('./admin/routes'));
app.use('/', require('./public/routes')); // /v /c /p /t /f
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex a0b13f7..1d8fdd4 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -173,6 +173,9 @@ function pastePublic(paste) {
copies: paste.copies || 0,
likes: paste.likes || 0,
is_nsfw: !!paste.is_nsfw,
++ ai_summary: paste.ai_summary || null,
++ ai_tags: paste.ai_tags || null,
++ ai_analyzed_at: paste.ai_analyzed_at || null,
url: `/p/${paste.slug}`,
raw_url: `/p/${paste.slug}/raw`,
created_at: paste.created_at,
ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex af62b0a..ae25d6f 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -73,6 +73,20 @@ function fileCard(f) {
source: null,
};
}
++function assetCard(a) {
++ const isEmote = a.kind === 'emote';
++ const channel = a.channel_username || a.username || '';
++ return {
++ kind: a.kind, title: isEmote ? a.name : `!${a.name}`, date: a.created_at, app: a.app_id,
++ thumb: isEmote ? `${config.publicUrl}/a/${a.id}` : null,
++ duration: !isEmote ? a.duration_seconds : null,
++ text: `uploaded by ${a.username || 'unknown'}${channel ? ` · channel @${channel}` : ''}`,
++ view: `${config.publicUrl}/a/${a.id}`,
++ source: channel ? `${appUrl(a.app_id)}/@${encodeURIComponent(channel)}` : null,
++ sourceLabel: channel ? `@${channel}'s channel` : null,
++ };
++}
++
function thumbCard(name, mtimeMs) {
const m = /^(vod|clip|stream)-(\d+)/.exec(name);
let source = null, sourceLabel = null;
@@@ -133,6 +147,14 @@ function fetchTab(tab, page) {
total: db.get('SELECT COUNT(*) c FROM files').c,
cards: db.all(`SELECT * FROM files ${L}`).map(fileCard),
};
++ case 'emotes': return {
++ total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'").c,
++ cards: db.all(`SELECT * FROM assets WHERE kind = 'emote' ${L}`).map(assetCard),
++ };
++ case 'sounds': return {
++ total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'").c,
++ cards: db.all(`SELECT * FROM assets WHERE kind = 'sound' ${L}`).map(assetCard),
++ };
case 'thumbnails': {
const list = thumbList();
return { total: list.length, cards: list.slice(off, off + PAGE_SIZE).map(([f, t]) => thumbCard(f, t)) };
@@@ -144,13 +166,17 @@ function fetchTab(tab, page) {
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}
++ UNION ALL
++ SELECT 'asset', id, name, username, channel_username, kind, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM assets
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;
++ (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) +
++ (SELECT COUNT(*) FROM assets) c`).c;
const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref })
: r.k === 'clip' ? clipCard({ ...r, id: r.ref })
++ : r.k === 'asset' ? assetCard({ id: r.ref, name: r.title, username: r.description, channel_username: r.ai_overview, kind: r.thumbnail_url, duration_seconds: r.duration_seconds, created_at: r.created_at, app_id: r.app_id })
: pasteCard({ ...r, id: r.ref }));
return { total, cards };
}
@@@ -167,9 +193,11 @@ function tabCounts() {
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'),
++ emotes: c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),
++ sounds: c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),
thumbnails: thumbList().length,
};
-- counts.all = counts.videos + counts.clips + counts.images + counts.text;
++ counts.all = counts.videos + counts.clips + counts.images + counts.text + counts.emotes + counts.sounds;
_countCache = { at: Date.now(), counts };
return counts;
}
@@@ -196,7 +224,7 @@ function renderCard(c) {
</div>`;
}
--const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
++const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
function renderPage(tab, page, data, counts) {
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
@@@ -208,7 +236,7 @@ function renderPage(tab, page, data, counts) {
<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{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);background:rgba(15,20,32,.92)}
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}
@@@ -232,7 +260,11 @@ nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
.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>
++</style>
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++</head><body>
++<script src="https://openvibe.network/shared/navbar.js"></script>
++<script>try { OpenVibeNavbar.init({ service: 'media' }); } catch (e) { /* navbar optional */ }</script>
<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>
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex bb04372..f20359d 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -249,6 +249,24 @@ router.get('/live/:sel/frame.jpg', async (req, res) => {
}
});
++// ── App assets (emotes / sounds) by id ───────────────────────
++router.get('/a/:id', (req, res) => {
++ try {
++ const a = db.getAssetById(parseInt(req.params.id, 10));
++ if (!a || !a.file_path || !fs.existsSync(a.file_path)) return res.status(404).json({ error: 'Not found' });
++ // Content is replaced under the same URL on re-upload — cache a day, not immutable.
++ streamFileWithRange(req, res, a.file_path, {
++ 'Content-Type': a.mime || 'application/octet-stream',
++ 'Cache-Control': 'public, max-age=86400',
++ 'Access-Control-Allow-Origin': '*',
++ 'X-Robots-Tag': 'noindex',
++ });
++ } catch (err) {
++ console.error('[Public] /a error:', err.message);
++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve asset' });
++ }
++});
++
// ── 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
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 7ed5f7d..562124e 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -78,6 +78,8 @@ function clipPublic(clip) {
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
storage_provider: clip.storage_provider || 'local',
auto_generated: !!clip.auto_generated,
++ ai_overview: clip.ai_overview || null,
++ ai_analyzed_at: clip.ai_analyzed_at || null,
view_count: clip.view_count || 0,
created_at: clip.created_at,
};
@@@ -300,7 +302,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
const clip = _getClipScoped(req, res);
if (!clip) return;
// Bare object, mirroring GET /vods/:id per CONTRACTS.md.
-- res.json(clipPublic(clip));
++ // Detail responses carry the transcript too (lists stay light).
++ res.json({ ...clipPublic(clip), ai_transcript: clip.ai_transcript || null });
} catch (err) {
res.status(500).json({ error: 'Failed to get clip' });
}
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex c74a945..9c1c53e 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -64,6 +64,8 @@ function vodPublic(vod) {
health_status: vod.health_status,
clips_only: !!vod.clips_only,
is_recording: !!vod.is_recording,
++ ai_overview: vod.ai_overview || null,
++ ai_analyzed_at: vod.ai_analyzed_at || null,
view_count: vod.view_count || 0,
created_at: vod.created_at,
meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),
@@@ -154,10 +156,13 @@ async function _doFinalize(vodId, opts) {
}
if (!filePath || !fs.existsSync(filePath)) {
-- db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
-- const failed = db.getVodById(vodId);
-- _webhookForVod(failed, 'vod.failed');
-- return failed;
++ // No media was ever written (ingest started but produced nothing, or the
++ // process died before ffmpeg opened the file). Nothing to review — delete
++ // the row so a 0:00 ghost never reaches listings.
++ console.warn(`[VOD] vod ${vodId}: no recording file — deleting empty row`);
++ _webhookForVod(vod, 'vod.failed');
++ try { db.run('DELETE FROM vods WHERE id = ?', [vodId]); } catch { /* */ }
++ return null;
}
// Merge any pending browser-chunk segments before remuxing.
ddiff --git a/server/vod/health-job.js b/server/vod/health-job.js
iindex a2e2af7..eefe5fd 100644
---- a/server/vod/health-job.js
++++ b/server/vod/health-job.js
@@@ -223,9 +223,39 @@ async function _tick() {
}
}
++// One boot-time sweep for 0:00 ghost VODs that older failure paths marked
++// ready+public (finalize crash fallbacks, killed processes). Rows with no
++// media at all are deleted; short-but-nonempty ones are quarantined.
++function sweepJunkVods() {
++ try {
++ const fs = require('fs');
++ const rows = db.all(`SELECT id, file_path, file_size, duration_seconds FROM vods
++ WHERE is_recording = 0 AND is_public = 1
++ AND COALESCE(duration_seconds, 0) < 3
++ AND COALESCE(file_size, 0) < 10000000
++ AND (storage_provider IS NULL OR storage_provider = 'local')
++ AND COALESCE(health_status, 'unknown') IN ('unknown', '')`);
++ let deleted = 0, quarantined = 0;
++ for (const v of rows) {
++ const hasFile = v.file_path && fs.existsSync(v.file_path) && (() => { try { return fs.statSync(v.file_path).size > 0; } catch { return false; } })();
++ if (!hasFile) {
++ try { if (v.file_path && fs.existsSync(v.file_path)) fs.unlinkSync(v.file_path); } catch { /* */ }
++ db.run('DELETE FROM vods WHERE id = ?', [v.id]);
++ deleted++;
++ } else {
++ db.run(`UPDATE vods SET health_status = 'needs_review', health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
++ [JSON.stringify(['short_duration']), v.id]);
++ quarantined++;
++ }
++ }
++ if (deleted || quarantined) console.log(`[VOD-Health] Junk sweep: deleted ${deleted} empty VODs, quarantined ${quarantined} short ones`);
++ } catch (e) { console.warn('[VOD-Health] junk sweep error:', e.message); }
++}
++
function start() {
if (_running) return;
_running = true;
++ sweepJunkVods();
// First pass shortly after boot (idle window), then on the interval.
const first = setTimeout(() => { _tick().catch(() => {}); }, 90 * 1000);
if (first.unref) first.unref();
ddiff --git a/server/vod/recorder.js b/server/vod/recorder.js
iindex 3f13ad3..fb3eae4 100644
---- a/server/vod/recorder.js
++++ b/server/vod/recorder.js
@@@ -257,7 +257,11 @@ class StreamRecorder {
const filePath = rec?.filePath;
this._teardown(vodId);
require('./finalize').finalizeVod(vodId, { startTimeMs: rec?.startTime }).catch(() => {
-- db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
++ // Never bare-mark a failed recording as ready — that published
++ // 0:00 ghosts. Quarantine it out of listings instead.
++ db.run(`UPDATE vods SET is_recording = 0, health_status = 'needs_review',
++ health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
++ [JSON.stringify(['finalize_failed']), vodId]);
});
if (filePath && !fs.existsSync(filePath)) this._cleanupFailedVod(vodId, filePath);
});
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex bb9473f..910bfc9 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -319,7 +319,8 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
}
// Bare object per CONTRACTS.md: { id, title, status, duration, … }
-- res.json(vodPublic(vod));
++ // Detail responses carry the transcript too (lists stay light).
++ res.json({ ...vodPublic(vod), ai_transcript: vod.ai_transcript || null });
} catch (err) {
console.error('[VOD] Get error:', err.message);
res.status(500).json({ error: 'Failed to get VOD' });
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex 8eecb7d..ad00a31 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -3,12 +3,17 @@
// Consistent top bar across all services with logo, navigation,
// notification bell, account switcher, and theme-aware styling.
// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
++// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
++// endpoint returning { user } — used only when no ov_token exists),
++// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
++// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
++// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
// ═══════════════════════════════════════════════════════════════
(function (root) {
'use strict';
-- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null };
++ let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };
let _navEl = null;
function injectStyles() {
@@@ -362,6 +367,9 @@
],
media: [
{ label: 'Home', href: '/' },
++ { label: 'Network', href: 'https://openvibe.network' },
++ { label: 'Live', href: 'https://openvibe.live' },
++ { label: 'Tools', href: 'https://openvibe.tools' },
],
network: [
{ label: 'Home', href: '/' },
@@@ -429,6 +437,200 @@
try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
}
++ // ─── SSO auth resolution ───────────────────────────────────
++ // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,
++ // and Domain=.openvibe.tools on the tools apex + every tool subdomain.
++ // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,
++ // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,
++ // img., yt., docs., food., …) do NOT mount /auth/* — they only read the
++ // shared cookie — so sign-in/out always goes through the gateway apex.
++ // Detection order: ov_token cookie → localStorage → page-provided token →
++ // app-specific session endpoint (opt-in via config.sessionUrl).
++ const TOOLS_APEX = 'openvibe.tools';
++ const TOKEN_KEY = 'ov_token';
++
++ function currentHost() {
++ return (typeof location !== 'undefined' && location.hostname) || '';
++ }
++
++ function onToolsDomain(host) {
++ const h = host !== undefined ? host : currentHost();
++ return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);
++ }
++
++ function onNetworkDomain(host) {
++ const h = host !== undefined ? host : currentHost();
++ try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }
++ }
++
++ function parseCookieToken(cookieStr) {
++ const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);
++ if (!m || !m[1]) return null;
++ try { return decodeURIComponent(m[1]); } catch { return m[1]; }
++ }
++
++ function getCookieToken() {
++ if (typeof document === 'undefined') return null;
++ return parseCookieToken(document.cookie);
++ }
++
++ function getStoredToken() {
++ try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }
++ }
++
++ function resolveToken() {
++ return getCookieToken() || getStoredToken() || _config.token || null;
++ }
++
++ /** Where "Sign In" goes on this host. */
++ function resolveLoginHref(host, returnUrl) {
++ if (_config.loginUrl) return _config.loginUrl;
++ if (onToolsDomain(host)) {
++ // Every *.openvibe.tools host signs in through the gateway apex: it
++ // holds the OAuth state cookie + redirect_uri (both apex-host-only)
++ // and sets ov_token with Domain=.openvibe.tools, so the session
++ // reaches all satellites. A host-local /auth/login would 404 on
++ // satellites and break the OAuth state check on gateway subdomains.
++ return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
++ }
++ if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
++ return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;
++ }
++
++ function setAuthCookie(token) {
++ if (typeof document === 'undefined') return;
++ const maxAge = 24 * 60 * 60; // matches the 24h access JWT
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;
++ } else {
++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
++ }
++ }
++
++ function clearAuthState() {
++ if (typeof document !== 'undefined') {
++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;
++ }
++ }
++ try {
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.removeItem('openvibe_anon_token');
++ localStorage.removeItem('openvibe_active_account');
++ } catch { /* storage unavailable */ }
++ }
++
++ function persistToken(token) {
++ try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }
++ setAuthCookie(token);
++ }
++
++ /** GET {apiBase}/api/auth/me with the Bearer token. */
++ async function fetchMe(token) {
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/me`, {
++ headers: { Authorization: `Bearer ${token}` },
++ });
++ if (res.ok) {
++ const data = await res.json();
++ return { ok: true, user: (data && (data.user || data)) || null };
++ }
++ return { ok: false, unauthorized: res.status === 401 || res.status === 403 };
++ } catch {
++ return { ok: false, unauthorized: false };
++ }
++ }
++
++ /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
++ async function gatewayRefresh() {
++ try {
++ const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });
++ if (!res.ok) return null;
++ const data = await res.json();
++ return data && data.token ? data : null;
++ } catch { return null; }
++ }
++
++ /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */
++ async function networkRefresh(token) {
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${token}` },
++ });
++ if (!res.ok) return null;
++ const data = await res.json();
++ return data && data.token ? data : null;
++ } catch { return null; }
++ }
++
++ /**
++ * Resolve the signed-in user from the shared SSO state.
++ * With a token: validate it against the Network. If it is rejected, make
++ * ONE refresh attempt (same-origin gateway refresh first on tools hosts,
++ * then the Network's grace refresh) before giving up — never leave a
++ * stale "Sign In" when a recoverable session exists.
++ */
++ async function resolveSessionUser() {
++ const token = resolveToken();
++ if (token) {
++ const me = await fetchMe(token);
++ if (me.ok && me.user) return { user: me.user, token };
++ if (me.unauthorized) {
++ if (onToolsDomain()) {
++ const r = await gatewayRefresh();
++ if (r) {
++ const user = r.user || (await fetchMe(r.token)).user;
++ if (user) { persistToken(r.token); return { user, token: r.token }; }
++ }
++ }
++ const n = await networkRefresh(token);
++ if (n) {
++ const user = n.user || (await fetchMe(n.token)).user;
++ if (user) { persistToken(n.token); return { user, token: n.token }; }
++ }
++ }
++ return null;
++ }
++ // No token anywhere — ask the page's own session endpoint if it has one
++ if (_config.sessionUrl) {
++ try {
++ const res = await fetch(_config.sessionUrl, { credentials: 'include' });
++ if (res.ok) {
++ const data = await res.json();
++ const user = (data && (data.user || data)) || null;
++ if (user && (user.username || user.id)) return { user, token: null };
++ }
++ } catch { /* signed out */ }
++ }
++ return null;
++ }
++
++ let _authInFlight = null;
++
++ function refreshAuthState() {
++ if (_authInFlight) return _authInFlight;
++ _authInFlight = resolveSessionUser()
++ .then((session) => {
++ _authInFlight = null;
++ if (session && session.user) {
++ _config.user = session.user;
++ if (session.token) _config.token = session.token;
++ render();
++ }
++ try {
++ document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
++ detail: { user: session ? session.user : null, token: session ? session.token : null },
++ }));
++ } catch { /* non-DOM environment */ }
++ return session ? session.user : null;
++ })
++ .catch(() => { _authInFlight = null; return null; });
++ return _authInFlight;
++ }
++
function escapeAttr(value) {
return String(value ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
@@@ -497,8 +699,8 @@
const u = _config.user;
const accounts = getAccounts();
const isAnon = u && u.is_anon;
-- const loginHref = `https://openvibe.network/login?return=${encodeURIComponent(window.location.href)}`;
-- const addAccountHref = `https://openvibe.network/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
++ const loginHref = resolveLoginHref(currentHost(), window.location.href);
++ const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
nav.innerHTML = `
<a class="openvibe-navbar-brand" href="${brandHref}">
@@@ -588,12 +790,16 @@
dropdown.classList.remove('open');
if (_config.onLogout) _config.onLogout();
else {
-- document.cookie = 'ov_token=;path=/;max-age=0';
-- document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools';
-- localStorage.removeItem('ov_token');
-- localStorage.removeItem('openvibe_anon_token');
-- localStorage.removeItem('openvibe_active_account');
-- window.location.reload();
++ clearAuthState();
++ _config.user = null;
++ _config.token = null;
++ if (onToolsDomain()) {
++ // The gateway also clears the Domain=.openvibe.tools cookie
++ // and the httpOnly refresh cookie, then sends us back here.
++ window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
++ } else {
++ window.location.reload();
++ }
}
});
} else {
@@@ -621,9 +827,19 @@
init(opts = {}) {
Object.assign(_config, opts);
injectStyles();
-- return render();
++ const el = render();
++ // Pages that hand us a resolved user (openvibe.network, the tools
++ // gateway hub) keep full control. Everyone else — pages that pass
++ // only a token, or nothing at all — gets the user resolved from
++ // the shared SSO state (ov_token cookie / localStorage / optional
++ // sessionUrl) and the navbar re-renders when it arrives.
++ if (!_config.user) refreshAuthState();
++ return el;
},
++ /** Re-resolve the signed-in user from the shared SSO state. */
++ refreshAuth() { return refreshAuthState(); },
++
/** Update user (after account switch). */
setUser(user) {
_config.user = user;
@@@ -643,6 +859,18 @@
_navEl?.remove();
_navEl = null;
},
++
++ /** Internal auth helpers — exposed for tests, not a public API. */
++ _auth: {
++ parseCookieToken,
++ onToolsDomain,
++ onNetworkDomain,
++ resolveLoginHref,
++ resolveToken,
++ resolveSessionUser,
++ clearAuthState,
++ persistToken,
++ },
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;