openvibers/openvibe.live Commits

VODs: stop browser chunk uploads spawning empty VOD shells while the server records

OP OpenVibers committed 12 Sept 2026 · e026fd08df · 3 files, +34 −4

The chunk route created a fresh Media VOD for every 30 s browser chunk whenever the active
recording was server-side (rtp/rtmp) — registerChunkSession never replaced it, so each chunk
orphaned a pending 0:00 row (28 blank cards tonight). Now the route drops the chunk and answers
status 'server-recording'; the browser stops its MediaRecorder for good. Lists also hide stale
pending shells (no bytes, no thumb, >10 min) as a backstop.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/index.html b/public/index.html
iindex b3ed9baf..62d54490 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5111,7 +5111,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
<script src="/js/broadcast-workspace.js?v=16" defer></script>
--<script src="/js/broadcast.js?v=27" defer></script>
++<script src="/js/broadcast.js?v=28" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 0ddaa98c..256d8957 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -2538,6 +2538,7 @@ function startVodRecording(streamId) {
const ss = getStreamState(streamId);
if (!ss || !ss.localStream) return;
if (ss.vodRecorder && ss.vodRecorder.state !== 'inactive') return;
++ if (ss.vodServerRecording) return; // server ingest is recording this stream — no browser chunks
try {
const mimeType = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'].find(m => MediaRecorder.isTypeSupported(m));
if (!mimeType) { console.warn('[VOD] No supported codec'); return; }
@@@ -2592,6 +2593,15 @@ async function uploadVodChunk(streamId) {
if (resp.ok) {
const data = await resp.json();
if (data.vodId) ss.vodId = data.vodId;
++ if (data.status === 'server-recording') {
++ // The server records this stream itself (Media ingest) — browser chunks are not
++ // needed and would only make orphan VOD shells. Stop the local recorder for good.
++ ss.vodServerRecording = true;
++ ss.vodChunks = [];
++ if (ss.vodRecorder && ss.vodRecorder.state !== 'inactive') { try { ss.vodRecorder.stop(); } catch {} }
++ ss.vodRecorder = null;
++ console.log('[VOD] Server-side recording active — browser chunk upload stopped');
++ }
} else {
console.warn('[VOD] Chunk upload HTTP error:', resp.status);
}
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 9e3c2f6c..f69cb602 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -82,6 +82,18 @@ function mediaErr(res, err, fallback) {
// Overlay Live-owned user fields (username/display_name/avatar) onto Media rows —
// the SPA's cards render them and Media only stores our opaque user_id.
++/**
++ * A VOD shell that was created but never got any media (status still pending, no bytes, no
++ * thumbnail) and is old enough that no ingest is coming — hidden from lists so it can't show
++ * up as a blank 0:00 card. (Media keeps the row; the recorder's _abort deletes its own.)
++ */
++function isGhostVod(v) {
++ if (!v || v.status !== 'pending' || v.is_recording) return false;
++ if ((v.duration || v.duration_seconds || 0) > 0 || (v.file_size || 0) > 0 || v.thumbnail_url) return false;
++ const t = Date.parse(String(v.created_at || '').replace(' ', 'T') + (String(v.created_at || '').includes('Z') ? '' : 'Z'));
++ return Number.isFinite(t) && Date.now() - t > 10 * 60 * 1000;
++}
++
function withUserFields(row) {
if (!row) return row;
if (row.user_id != null) {
@@@ -157,7 +169,14 @@ router.post('/stream/:streamId/chunk', requireAuth, memUpload.single('chunk'), a
let rec = recorder.getActiveRecording(streamId);
let created = false;
-- if (!rec || rec.type !== 'chunks') {
++ if (rec && rec.type !== 'chunks') {
++ // The server is already recording this stream itself (RTP/RTMP ingest into Media).
++ // Browser chunks are only the fallback for when it is not — accepting them here used
++ // to create a fresh VOD shell per chunk (one every 30 s) that never got a file: the
++ // blank 0:00 cards. Drop the chunk and tell the browser to stop sending them.
++ return res.json({ vodId: rec.vodId || null, status: 'server-recording', skipped: true });
++ }
++ if (!rec) {
const { id } = await media.createVod({
title: stream.title || 'Stream Recording',
stream_id: streamId,
@@@ -248,7 +267,7 @@ router.get('/:id/live-info', optionalAuth, async (req, res) => {
router.get('/', optionalAuth, async (req, res) => {
try {
const out = await media.listVods(usernameToUserId(req.query));
-- const vods = (out?.vods || (Array.isArray(out) ? out : [])).map(withUserFields);
++ const vods = (out?.vods || (Array.isArray(out) ? out : [])).filter(v => !isGhostVod(v)).map(withUserFields);
res.json({
vods,
total: out?.total ?? vods.length,
@@@ -268,7 +287,7 @@ router.get('/mine', requireAuth, async (req, res) => {
// App-key call (not the user's JWT): Live already authenticated the owner,
// and Media only honors include_private for the owning app.
const out = await media.listVods({ ...req.query, user_id: req.user.id, include_private: 1 });
-- const vods = (out?.vods || (Array.isArray(out) ? out : [])).map(withUserFields);
++ const vods = (out?.vods || (Array.isArray(out) ? out : [])).filter(v => !isGhostVod(v)).map(withUserFields);
res.json({ vods, total: out?.total ?? vods.length, limit: out?.limit ?? vods.length, offset: out?.offset ?? 0 });
} catch (err) {
mediaErr(res, err, 'Failed to list VODs');
@@@ -677,3 +696,4 @@ router.post('/clips/:id/trim', requireAuth, (req, res) => {
});
module.exports = router;
++module.exports.isGhostVod = isGhostVod;