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)
Changes
@@@ -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' });@@@ -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);nnew file mode 100644@@@ -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();++})();
@@ -217,11 +217,24 @@ function videoExt(item) {
@@ -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
* 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.
* 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 }) {
function downloadMenu({ kind, id, item, has }) {
const at = `/${kind === 'clip' ? 'c' : 'v'}/${id}/download`;
const at = `/${kind === 'clip' ? 'c' : 'v'}/${id}/download`;
const ready = !item.is_recording && (kind === 'vod' || !item.status || item.status === 'ready');
const ready = !item.is_recording && (kind === 'vod' || !item.status || item.status === 'ready');
const rows = [];
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 size = sizeLabel(item.file_size);
const ext = videoExt(item);
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 }] }));
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) {
@@ -364,10 +377,20 @@ function mountDownloads(r, deps) {
if (file === 'video') {
if (file === 'video') {
// OpenVibe.Media's file as an attachment: Live hands out its URL (a signed one for an owner's private item).
// 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 {
try {
const got = own
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);
const url = got && httpsUrl(got.url);
if (!url) return notFound(req, res);
if (!url) return notFound(req, res);
res.set({ 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex' });
res.set({ 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex' });
@@ -282,6 +282,26 @@ function relativeDate(iso) {
@@ -282,6 +282,26 @@ function relativeDate(iso) {
function chatMessageRows(vod, messages, limit = 1000) {
function chatMessageRows(vod, messages, limit = 1000) {
const streamStart = utcSeconds(vod && vod.stream_started_at);
const streamStart = utcSeconds(vod && vod.stream_started_at);
if (!Number.isFinite(streamStart) || !Array.isArray(messages)) return null;
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 partAt = Number(vod.stream_offset_sec);
const inPart = vod.stream_offset_sec != null && Number.isFinite(partAt) && partAt >= 0;
const inPart = vod.stream_offset_sec != null && Number.isFinite(partAt) && partAt >= 0;
const start = streamStart + (inPart ? partAt : 0);
const start = streamStart + (inPart ? partAt : 0);
@@ -598,8 +618,11 @@ function createVideoRoutes(ctx) {
@@ -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>`;
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);
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>
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">
<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">
<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>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>
<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
@@ -1041,6 +1064,16 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
}
}
if (!data || !data.vod) return notFound(req, res);
if (!data || !data.vod) return notFound(req, res);
const vod = data.vod;
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;
if (vod && vod.ai_overview) vod.ai_overview = noLooks(vod.ai_overview).trim() || null;
const manageable = mayManage(req, 'vod', vod);
const manageable = mayManage(req, 'vod', vod);
const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];
const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];
@@ -1269,7 +1302,8 @@ ${up.html}
@@ -1269,7 +1302,8 @@ ${up.html}
const title = String(b.title || '').trim().slice(0, 100);
const title = String(b.title || '').trim().slice(0, 100);
let out;
let out;
try {
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); }
} catch (err) { return ownerRefused(req, res, err, back); }
const clipId = out && out.clip && out.clip.id;
const clipId = out && out.clip && out.clip.id;
live.forget('vod', id);
live.forget('vod', id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -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();
+})();