openvibers/openvibe.mediahub Commits

A stream recorded in parts is one video page: later parts redirect to it at their second, it plays the joined playlist, chat replay in the video's time, a file per session in Download, clips in the video's time (#146)

OP OpenVibers committed 17 days ago · 5486f13c23 · 3 files, +192 −4

Changes

ddiff --git a/server/video/downloads.js b/server/video/downloads.js
iindex 4b8eced..cf4cdfe 100644
---- a/server/video/downloads.js
++++ b/server/video/downloads.js
@@@ -217,11 +217,24 @@ function videoExt(item) {
* chapters: n } (counts; 0 = left out). A <details> menu: it opens and closes without JavaScript; video.js only closes
* it on a click elsewhere or Escape and keeps it on screen.
*/
++/** The parts of a whole recording (Live's `recording.parts` on its first part, once joined), or null. */
++function wholeParts(item) {
++ const r = item && item.recording;
++ if (!r || !r.joined || String(r.vod_id) !== String(item.id) || !Array.isArray(r.parts) || r.parts.length < 2) return null;
++ return r.parts.filter((p) => p && /^\d+$/.test(String(p.vod_id)));
++}
++
function downloadMenu({ kind, id, item, has }) {
const at = `/${kind === 'clip' ? 'c' : 'v'}/${id}/download`;
const ready = !item.is_recording && (kind === 'vod' || !item.status || item.status === 'ready');
const rows = [];
-- if (ready) {
++ const parts = wholeParts(item);
++ if (ready && parts) {
++ // A stream recorded in parts plays as one video (Media's recording); its files are one per session, named by where
++ // each sits in it.
++ rows.push(row({ icon: 'fa-solid fa-film', label: 'Video', about: `${parts.length} files, one per recorded session`,
++ links: parts.map((p, i) => ({ href: `${at}/video?part=${p.vod_id}`, ext: videoExt(item), main: i === 0, text: `${clockLabel(p.start_sec)}–${clockLabel(p.start_sec + p.duration_sec)}` })) }));
++ } else if (ready) {
const size = sizeLabel(item.file_size);
const ext = videoExt(item);
rows.push(row({ icon: 'fa-solid fa-film', label: 'Video', about: [kind === 'clip' ? 'The clip' : 'The whole recording', size].filter(Boolean).join(' · '), links: [{ href: `${at}/video`, ext, main: true }] }));
@@@ -364,10 +377,20 @@ function mountDownloads(r, deps) {
if (file === 'video') {
// OpenVibe.Media's file as an attachment: Live hands out its URL (a signed one for an owner's private item).
++ // A whole recording's file is one per part (?part=<its VOD>): only the recording's own parts.
++ let fileId = id;
++ let fileName = name;
++ const parts = kind === 'vod' ? wholeParts(item) : null;
++ if (parts && req.query.part != null) {
++ const i = parts.findIndex((p) => String(p.vod_id) === String(req.query.part));
++ if (i < 0) return notFound(req, res);
++ fileId = String(parts[i].vod_id);
++ fileName = `${name} - part ${i + 1}`;
++ }
try {
const got = own
-- ? await asPerson(req, res, (token) => live.downloadUrlAs(token, kind, id, name))
-- : await live.downloadUrl(kind, id, name);
++ ? await asPerson(req, res, (token) => live.downloadUrlAs(token, kind, fileId, fileName))
++ : await live.downloadUrl(kind, fileId, fileName);
const url = got && httpsUrl(got.url);
if (!url) return notFound(req, res);
res.set({ 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex' });
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex afb2908..f90ea61 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -282,6 +282,26 @@ function relativeDate(iso) {
function chatMessageRows(vod, messages, limit = 1000) {
const streamStart = utcSeconds(vod && vod.stream_started_at);
if (!Number.isFinite(streamStart) || !Array.isArray(messages)) return null;
++ // A whole recording (its parts joined, Live's recording.parts): each message at its moment in the video, the time
++ // between parts left out.
++ const rec = vod.recording;
++ const whole = rec && rec.joined && String(rec.vod_id) === String(vod.id) && Array.isArray(rec.parts)
++ ? rec.parts.filter((p) => p && p.stream_offset_sec != null && Number(p.duration_sec) > 0) : null;
++ if (whole && whole.length > 1) {
++ const rows = [];
++ for (const m of messages) {
++ if (!m || typeof m !== 'object' || m.is_deleted || m.is_filtered || m.message_type !== 'chat') continue;
++ const at = utcSeconds(m.timestamp);
++ if (!Number.isFinite(at)) continue;
++ const s = at - streamStart;
++ const p = whole.find((x) => s >= Number(x.stream_offset_sec) && s <= Number(x.stream_offset_sec) + Number(x.duration_sec) + 1);
++ if (!p) continue;
++ rows.push({ offset: Math.floor(Number(p.start_sec) + s - Number(p.stream_offset_sec)), name: m.display_name || m.username || '', username: m.username || '', sentAt: new Date(at * 1000).toISOString(), text: m.message == null ? '' : String(m.message) });
++ }
++ rows.sort((a, b) => a.offset - b.offset);
++ if (!rows.length) return null;
++ return { rows: rows.slice(0, limit), leftOut: rows.length > limit ? rows.length - limit : 0 };
++ }
const partAt = Number(vod.stream_offset_sec);
const inPart = vod.stream_offset_sec != null && Number.isFinite(partAt) && partAt >= 0;
const start = streamStart + (inPart ? partAt : 0);
@@@ -598,8 +618,11 @@ function createVideoRoutes(ctx) {
return html`<a class="ovv-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}" title="Sign in to make a clip of up to ${CLIP_MAX_SECONDS} seconds"><i class="fa-solid fa-scissors" aria-hidden="true"></i> Clip</a>`;
}
const dur = Math.floor(Number(vod.duration_seconds) || 0);
++ // The whole video of a stream recorded in parts: its times are the video's, and a clip may cross a join.
++ const whole = !!(vod.recording && vod.recording.joined && String(vod.recording.vod_id) === String(id));
return actionMenu({ icon: 'fa-solid fa-scissors', label: 'Clip', title: `Make a clip of up to ${CLIP_MAX_SECONDS} seconds`, cls: 'ovv-clipper', body: html`<p class="ovv-pop-title">Make a clip</p>
<form class="ovv-owner-form" method="post" action="/v/${id}/clip">
++${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
<div class="ovv-clip-times">
<label class="ovv-field"><span>Start</span><input name="start" type="text" inputmode="numeric" placeholder="0:00" pattern="[0-9:.]{1,12}" required data-ovv-clip-start${dur ? html` data-ovv-clip-duration="${dur}"` : ''}></label>
<label class="ovv-field"><span>End</span><input name="end" type="text" inputmode="numeric" placeholder="0:30" pattern="[0-9:.]{1,12}" required data-ovv-clip-end></label>
@@@ -1041,6 +1064,16 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
}
if (!data || !data.vod) return notFound(req, res);
const vod = data.vod;
++ // A part of a stream recorded in parts, once the parts play as one video (Media's recording): its page is the whole
++ // video's, at the second this part starts (owner, 2026-10-27: "a single vod … that seeks just fine").
++ const rec = vod.recording;
++ if (rec && rec.joined && live.isId(rec.vod_id) && String(rec.vod_id) !== id) {
++ const t = Math.max(0, Math.floor(Number(rec.start_sec || 0) + (Number(req.query.t) > 0 ? Number(req.query.t) : 0)));
++ const q = new URLSearchParams();
++ if (t) q.set('t', String(t));
++ if (req.query.list) q.set('list', String(req.query.list));
++ return res.redirect(301, `/v/${rec.vod_id}${q.toString() ? `?${q}` : ''}`);
++ }
if (vod && vod.ai_overview) vod.ai_overview = noLooks(vod.ai_overview).trim() || null;
const manageable = mayManage(req, 'vod', vod);
const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];
@@@ -1269,7 +1302,8 @@ ${up.html}
const title = String(b.title || '').trim().slice(0, 100);
let out;
try {
-- out = await asPerson(req, res, (token) => live.createClip(token, { vod_id: Number(id), start_time: start, end_time: end, ...(title ? { title } : {}) }, req.ip));
++ out = await asPerson(req, res, (token) => live.createClip(token, { vod_id: Number(id), start_time: start, end_time: end, ...(title ? { title } : {}),
++ ...(b.recording_time === '1' ? { recording_time: 1 } : {}) }, req.ip));
} catch (err) { return ownerRefused(req, res, err, back); }
const clipId = out && out.clip && out.clip.id;
live.forget('vod', id);
ddiff --git a/test/video-recording.test.js b/test/video-recording.test.js
nnew file mode 100644
iindex 0000000..b27e264
---- /dev/null
++++ b/test/video-recording.test.js
@@@ -0,0 +1,131 @@
++'use strict';
++/**
++ * A stream recorded in parts is one video on openvibe.video (owner, 2026-10-27: "instead of 'The stream was recorded in
++ * 2 parts: Part 1 (this one) · Part 2' it should automatically just have them as a single vod … that seeks just fine").
++ * Once Live answers a joined recording (OpenVibe.Media docs/recordings.md):
++ * - a later part's page sends people to the whole video at the second it starts (301, ?t carried over);
++ * - the whole video's page plays the joined playlist, says no "Part 1 · Part 2", shows the whole length;
++ * - its chat replay is every part's, in the video's time (the gap between parts left out);
++ * - its Download menu offers one file per recorded session, named by where it sits in the video;
++ * - its clip form sends recording_time, so the clip is placed on the recording (across a join too).
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++const usernameOf = (auth) => {
++ const tok = String(auth || '').replace(/^Bearer /, '');
++ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString('utf8')).username || null; } catch { return null; }
++};
++
++function startLive() {
++ const parts = [{ vod_id: 6025, start_sec: 0, duration_sec: 678, gap_before_sec: 0, stream_offset_sec: 28 },
++ { vod_id: 6026, start_sec: 678, duration_sec: 1221, gap_before_sec: 3, stream_offset_sec: 709 }];
++ const recording = { id: 'med_REC', vod_id: 6025, joined: true, duration_seconds: 1899, start_sec: 0, parts };
++ const vods = {
++ 6025: { id: 6025, stream_id: 2795, title: 'BIRD DRAMA', description: '', duration_seconds: 1899, part_duration_seconds: 678, created_at: '2026-10-26T20:54:07.000Z',
++ stream_started_at: '2026-10-26 20:53:39', stream_ended_at: '2026-10-26 21:26:00', stream_offset_sec: 28,
++ playback_url: 'https://openvibe.media/v/6025', file_url: 'https://openvibe.media/v/6025', file_path: 'vod_6025.mp4',
++ hls_url: 'https://openvibe.media/o/med_REC/master.m3u8', visibility: 'public', username: 'Maticus', display_name: 'Maticus', recording },
++ 6026: { id: 6026, stream_id: 2795, title: 'BIRD DRAMA (Part 2)', duration_seconds: 1221, created_at: '2026-10-26T21:05:28.000Z', part: 2,
++ playback_url: 'https://openvibe.media/v/6026', file_url: 'https://openvibe.media/v/6026', visibility: 'public', username: 'Maticus', display_name: 'Maticus',
++ recording: { ...recording, start_sec: 678 } },
++ };
++ const at = (sec) => new Date(Date.parse('2026-10-26T20:53:39Z') + sec * 1000).toISOString();
++ const messages = [
++ { message_type: 'chat', timestamp: at(100), username: 'a', display_name: 'A', message: 'in part one' },
++ { message_type: 'chat', timestamp: at(707.5), username: 'b', display_name: 'B', message: 'in the gap' },
++ { message_type: 'chat', timestamp: at(720), username: 'c', display_name: 'C', message: 'in part two' },
++ ];
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://127.0.0.1');
++ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ calls.push({ method: req.method, path: url.pathname + url.search, who: usernameOf(req.headers.authorization), body: raw ? JSON.parse(raw) : null });
++ const v = /^\/api\/vods\/(\d+)$/.exec(url.pathname);
++ if (v && req.method === 'GET') return vods[v[1]] ? json(200, { vod: vods[v[1]], clips: [] }) : json(404, { error: 'not found' });
++ if (url.pathname === '/api/chat/2795/replay') return json(200, { messages });
++ const d = /^\/api\/vods\/(\d+)\/download-url$/.exec(url.pathname);
++ if (d) return json(200, { url: `https://openvibe.media/v/${d[1]}?download=1&name=${encodeURIComponent(url.searchParams.get('name') || '')}` });
++ if (req.method === 'POST' && url.pathname === '/api/vods/clips') return json(201, { clip: { id: 701, vod_id: 6025, status: 'ready' } });
++ return json(404, { error: 'not found' });
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) })));
++}
++
++function vreq(base, path, { method = 'GET', cookie, form } = {}) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(base + path);
++ const h = { Host: 'openvibe.video' };
++ if (cookie) h.cookie = cookie;
++ if (method !== 'GET') h['sec-fetch-site'] = 'same-origin';
++ let data;
++ if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; h['content-length'] = data.length; }
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));
++ });
++ req.on('error', reject);
++ if (data) req.write(data);
++ req.end();
++ });
++}
++
++(async () => {
++ const live = await startLive();
++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url } });
++ const sam = t.network.addUser('sam');
++ const samCookie = t.signIn(sam);
++
++ await check('a later part sends people to the whole video at its second', async () => {
++ let r = await vreq(t.base, '/v/6026');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.location, '/v/6025?t=678');
++ r = await vreq(t.base, '/v/6026?t=10&list=pl_1');
++ assert.strictEqual(r.headers.location, '/v/6025?t=688&list=pl_1', '?t into the part is carried over');
++ });
++
++ await check('the whole video plays the joined playlist, with no "Part 1 · Part 2"', async () => {
++ const r = await vreq(t.base, '/v/6025');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_REC/master.m3u8"'), 'the recording\'s playlist');
++ assert.ok(!/recorded in \d parts/.test(r.text), 'no parts line');
++ assert.ok(r.text.includes('31:39'), 'the whole length');
++ });
++
++ await check('chat replay: every part, in the video\'s time, the gap left out', async () => {
++ const r = await vreq(t.base, '/v/6025/download/chat.json');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ const got = JSON.parse(r.text).messages.map((m) => [m.seconds, m.message]);
++ assert.deepStrictEqual(got, [[72, 'in part one'], [689, 'in part two']], JSON.stringify(got));
++ });
++
++ await check('Download: one file per recorded session, named by where it sits', async () => {
++ const page = (await vreq(t.base, '/v/6025')).text;
++ assert.ok(page.includes('2 files, one per recorded session'));
++ assert.ok(page.includes('href="/v/6025/download/video?part=6025"') && page.includes('href="/v/6025/download/video?part=6026"'));
++ assert.ok(page.includes('>0:00–11:18<') && page.includes('>11:18–31:39<'), 'labelled by time');
++ const r = await vreq(t.base, '/v/6025/download/video?part=6026');
++ assert.strictEqual(r.status, 302);
++ assert.ok(r.headers.location.startsWith('https://openvibe.media/v/6026?download=1&name=BIRD%20DRAMA') && r.headers.location.endsWith('%20-%20part%202'), r.headers.location);
++ assert.strictEqual((await vreq(t.base, '/v/6025/download/video?part=999')).status, 404, 'only the recording\'s own parts');
++ });
++
++ await check('the clip form sends recording_time, so the clip is placed on the whole video', async () => {
++ const page = (await vreq(t.base, '/v/6025', { cookie: samCookie })).text;
++ assert.ok(page.includes('<input type="hidden" name="recording_time" value="1">'));
++ const r = await vreq(t.base, '/v/6025/clip', { method: 'POST', cookie: samCookie, form: { start: '11:10', end: '11:30', recording_time: '1' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 200));
++ const call = live.calls.filter((c) => c.method === 'POST' && c.path === '/api/vods/clips').pop();
++ assert.deepStrictEqual(call.body, { vod_id: 6025, start_time: 670, end_time: 690, recording_time: 1 });
++ });
++
++ await t.close();
++ await live.close();
++ done();
++})();