openvibers/openvibe.live Commits

A browser chunk opens a new VOD only for a live stream and only when it starts a WebM file; the broadcast page stops or restarts its recorder when told (#340)

OP OpenVibers committed 20 days ago · 9656cadf84 · 3 files, +70 −2

Changes

ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 27f3e84f..52c24987 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -2439,13 +2439,15 @@ function startVodRecording(streamId) {
ss.vodUploading = false; // upload lock
ss.vodFinalized = false; // prevent double finalize on actual stream end
ss.vodSegmentId = (ss.vodSegmentId || 0) + 1;
-- ss.vodRecorder = new MediaRecorder(ss.localStream, {
++ const recorder = new MediaRecorder(ss.localStream, {
mimeType,
videoBitsPerSecond: Math.max(600000, Math.round(getTargetVideoBitrate() * 0.85)),
audioBitsPerSecond: 128000,
});
++ ss.vodRecorder = recorder;
-- ss.vodRecorder.ondataavailable = (e) => {
++ recorder.ondataavailable = (e) => {
++ if (ss.vodRecorder !== recorder) return; // a recorder replaced by restartVodRecording: its last slice is dropped
if (e.data.size > 0) {
ss.vodChunks.push(e.data);
uploadVodChunk(streamId); // try to flush immediately
@@@ -2458,6 +2460,22 @@ function startVodRecording(streamId) {
} catch (err) { console.warn('[VOD] Failed to start:', err); }
}
++/**
++ * Start a fresh MediaRecorder for a stream whose recording the server has to open again (needs-header): only a
++ * recorder's first slice carries the WebM header, so the pending slices are dropped and the next upload starts a
++ * playable file.
++ */
++function restartVodRecording(streamId) {
++ const ss = getStreamState(streamId);
++ if (!ss) return;
++ const old = ss.vodRecorder;
++ ss.vodRecorder = null;
++ ss.vodChunks = [];
++ if (old && old.state !== 'inactive') { try { old.stop(); } catch {} }
++ console.log('[VOD] Restarting the browser recorder so the next slice starts a new file');
++ startVodRecording(streamId);
++}
++
/**
* Upload the next pending VOD chunk to the server.
* Sequential — only one upload at a time per stream to maintain chunk order.
@@@ -2494,6 +2512,17 @@ async function uploadVodChunk(streamId) {
ss.vodRecorder = null;
console.log('[VOD] Server-side recording active — browser chunk upload stopped');
}
++ } else if (resp.status === 409) {
++ const data = await resp.json().catch(() => ({}));
++ if (data.status === 'stream-ended') {
++ // The server ended this stream: nothing more to record, and no new VOD should start from it.
++ ss.vodChunks = [];
++ const old = ss.vodRecorder; ss.vodRecorder = null;
++ if (old && old.state !== 'inactive') { try { old.stop(); } catch {} }
++ console.log('[VOD] The stream has ended on the server — browser recording stopped');
++ } else if (data.status === 'needs-header') {
++ restartVodRecording(streamId);
++ }
} 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 17669b0b..679bcb35 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -14,6 +14,14 @@ const db = require('../db/database');
const chatReads = require('../chat/chat-reads');
const media = require('../media-client');
const recorder = require('../streaming/recorder');
++// The EBML magic every WebM file starts with: a MediaRecorder's first slice has it, the later ones do not.
++const WEBM_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
++/** May this chunk open a new VOD? null when it may, else the 409 answer: the stream ended, or the chunk is not a file start. */
++function chunkOpensVod(stream, buffer) {
++ if (stream && stream.ended_at) return { error: 'This stream has ended', status: 'stream-ended' };
++ if (!Buffer.isBuffer(buffer) || !buffer.subarray(0, 4).equals(WEBM_MAGIC)) return { error: 'A new recording must start with the WebM header', status: 'needs-header' };
++ return null;
++}
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const access = require('./access');
@@@ -212,6 +220,13 @@ router.post('/stream/:streamId/chunk', requireAuth, memUpload.single('chunk'), a
return res.json({ vodId: rec.vodId || null, status: 'server-recording', skipped: true });
}
if (!rec) {
++ // A chunk opens a new VOD only for a stream that is still live, and only when it starts a WebM file. After
++ // Live ended Maticus's stream (2026-10-23 23:31, ingest_ended) his browser kept sending its 30-second
++ // slices: each one with no recording open made a new VOD from a mid-stream slice with no header, which
++ // nothing could play (5939, 5940). The browser stops on stream-ended and restarts its recorder (a slice
++ // with a header) on needs-header.
++ const refused = chunkOpensVod(stream, req.file.buffer);
++ if (refused) return res.status(409).json(refused);
const { id } = await media.createVod({
title: stream.title || 'Stream Recording',
stream_id: streamId,
@@@ -808,3 +823,4 @@ router.post('/clips/:id/trim', requireAuth, (req, res) => {
module.exports = router;
module.exports.isGhostVod = isGhostVod;
++module.exports.chunkOpensVod = chunkOpensVod;
ddiff --git a/test/chunk-opens-vod.test.js b/test/chunk-opens-vod.test.js
nnew file mode 100644
iindex 00000000..c7f938ef
---- /dev/null
++++ b/test/chunk-opens-vod.test.js
@@@ -0,0 +1,23 @@
++'use strict';
++// A browser chunk opens a new VOD only for a live stream, and only when it starts a WebM file (server/media-proxy/vods.js
++// chunkOpensVod). After Live ended Maticus's stream on 2026-10-23 his browser kept sending its 30-second MediaRecorder
++// slices; each one with no recording open made a VOD from a slice with no header, which nothing could play (5939,
++// 5940). The answers are what public/js/broadcast.js acts on: stream-ended stops the recorder, needs-header restarts it.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { chunkOpensVod } = require('../server/media-proxy/vods');
++
++const header = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(100)]);
++const middle = Buffer.concat([Buffer.from([0x43, 0xc3, 0x81, 0x17]), Buffer.alloc(100)]); // 5939's first bytes
++assert.strictEqual(chunkOpensVod({ id: 1, ended_at: null }, header), null, 'a live stream, a file start: a new VOD');
++assert.strictEqual(chunkOpensVod({ id: 1, ended_at: null }, middle).status, 'needs-header');
++assert.strictEqual(chunkOpensVod({ id: 1, ended_at: '2026-10-23 23:31:42' }, header).status, 'stream-ended');
++assert.strictEqual(chunkOpensVod({ id: 1 }, null).status, 'needs-header');
++
++// The browser handles both answers (a static file: these names are its contract with the route).
++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');
++assert.ok(/data\.status === 'stream-ended'/.test(js) && /data\.status === 'needs-header'/.test(js), 'broadcast.js reads both 409 answers');
++assert.ok(/function restartVodRecording\(/.test(js) && /ss\.vodRecorder !== recorder/.test(js), "a replaced recorder's last slice is dropped");
++console.log('chunk-opens-vod: ok');
++process.exit(0);