openvibers/openvibe.mediahub Commits
MediaHub: Video plays Live's file_url as the file (ready for playback_url becoming the HLS playlist) (#42)
Changes
@@@ -47,10 +47,18 @@ function httpsUrl(u) {try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; }}++/**++ * An item's playable file. Live's `file_url` is always the file; its `playback_url` is the HLS playlist when Media has++ * one (Live T4 step 5), so it is only the fallback for answers from before `file_url` existed.++ */++function fileOf(item) {++ return httpsUrl(item.file_url) || httpsUrl(item.playback_url);++}++/** The player's HLS source: Media's timeline playlist, for a finished item that has one, on the playback file's origin. */function hlsAttrs(item) {const hls = !item.is_recording && httpsUrl(item.hls_url);-- const file = httpsUrl(item.playback_url);++ const file = fileOf(item);if (!hls || !file || new URL(hls).origin !== new URL(file).origin) return '';return html` data-ovv-hls="${hls}" data-ovv-hls-lib="${asset('vendor/hls.light.min.js')}"`;}@@@ -112,7 +120,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })...seo.jsonLd.video({name: title, url: pageUrl, description: String(description || title).slice(0, 500),thumbnailUrl: thumb ? [thumb] : undefined, uploadDate: isoTime(item.created_at),-- duration: isoDuration(item.duration_seconds), contentUrl: httpsUrl(item.playback_url) || undefined, embedUrl: pageUrl,++ duration: isoDuration(item.duration_seconds), contentUrl: fileOf(item) || undefined, embedUrl: pageUrl,}),...(item.view_count != null ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(item.view_count) || 0 } } : {}),...(channel ? { author: { '@type': 'Person', name: channel, ...(item.username ? { url: `${base}/@${item.username}` } : {}) } } : {}),@@@ -716,7 +724,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarbody: html`<div class="ovv-watch"><div class="ovv-main">${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}--<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(vod.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>++<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div><h1 class="ovv-title">${vod.title || 'Stream recording'}</h1><div class="ovv-byline">${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}</a>` : ''}@@@ -794,7 +802,7 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i<div class="ovv-main">${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is credited to you on OpenVibe.Live and here.', 'ok') : ''}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`-- : html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}++ : html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}<h1 class="ovv-title">${clip.title || 'Clip'}</h1><div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>@@@ -88,6 +88,9 @@ function startLive() {ai_overview: '', ai_transcript_json: null,stream_id: 1002, stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:35:00',};++ // Live's answer since T4 step 5: playback_url is the playlist, file_url the file.++ const hlsFirstVod = { ...secondVod, id: 105, title: 'Playlist first', playback_url: 'https://openvibe.media/o/med_105/master.m3u8',++ hls_url: 'https://openvibe.media/o/med_105/master.m3u8', file_url: 'https://openvibe.media/v/105' };const state = { down: false, chatDown: false };const server = http.createServer((req, res) => {const url = new URL(req.url, 'http://x');@@@ -127,6 +130,7 @@ function startLive() {if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });if (vodDetail[1] === '104') return json(200, { vod: secondVod, clips: [] });++ if (vodDetail[1] === '105') return json(200, { vod: hlsFirstVod, clips: [] });return json(404, { error: 'not found' });}const clipDetail = /^\/api\/clips\/(\d+)$/.exec(p);@@@ -353,6 +357,14 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('private drive'));});++ await check('an answer whose playback_url is the playlist plays the file_url as the file, with the playlist for hls.js', async () => {++ const r = await getVideo('/v/105');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('src="https://openvibe.media/v/105"'), 'the file is the <video> source');++ assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_105/master.m3u8"'));++ assert.ok(r.text.includes('"contentUrl":"https://openvibe.media/v/105"'), 'contentUrl is the file');++ });++await check('the VOD page lists the chat replay with offsets, escaping and no private fields', async () => {const r = await getVideo('/v/101');assert.strictEqual(r.status, 200);
@@ -47,10 +47,18 @@ function httpsUrl(u) {
@@ -47,10 +47,18 @@ function httpsUrl(u) {
try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; }
try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; }
}
}
+/**
+ * An item's playable file. Live's `file_url` is always the file; its `playback_url` is the HLS playlist when Media has
+ * one (Live T4 step 5), so it is only the fallback for answers from before `file_url` existed.
+ */
+function fileOf(item) {
+ return httpsUrl(item.file_url) || httpsUrl(item.playback_url);
+}
+
/** The player's HLS source: Media's timeline playlist, for a finished item that has one, on the playback file's origin. */
/** The player's HLS source: Media's timeline playlist, for a finished item that has one, on the playback file's origin. */
function hlsAttrs(item) {
function hlsAttrs(item) {
const hls = !item.is_recording && httpsUrl(item.hls_url);
const hls = !item.is_recording && httpsUrl(item.hls_url);
- const file = httpsUrl(item.playback_url);
+ const file = fileOf(item);
if (!hls || !file || new URL(hls).origin !== new URL(file).origin) return '';
if (!hls || !file || new URL(hls).origin !== new URL(file).origin) return '';
return html` data-ovv-hls="${hls}" data-ovv-hls-lib="${asset('vendor/hls.light.min.js')}"`;
return html` data-ovv-hls="${hls}" data-ovv-hls-lib="${asset('vendor/hls.light.min.js')}"`;
}
}
@@ -112,7 +120,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })
@@ -112,7 +120,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })
...seo.jsonLd.video({
...seo.jsonLd.video({
name: title, url: pageUrl, description: String(description || title).slice(0, 500),
name: title, url: pageUrl, description: String(description || title).slice(0, 500),
thumbnailUrl: thumb ? [thumb] : undefined, uploadDate: isoTime(item.created_at),
thumbnailUrl: thumb ? [thumb] : undefined, uploadDate: isoTime(item.created_at),
- duration: isoDuration(item.duration_seconds), contentUrl: httpsUrl(item.playback_url) || undefined, embedUrl: pageUrl,
+ duration: isoDuration(item.duration_seconds), contentUrl: fileOf(item) || undefined, embedUrl: pageUrl,
}),
}),
...(item.view_count != null ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(item.view_count) || 0 } } : {}),
...(item.view_count != null ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: Number(item.view_count) || 0 } } : {}),
...(channel ? { author: { '@type': 'Person', name: channel, ...(item.username ? { url: `${base}/@${item.username}` } : {}) } } : {}),
...(channel ? { author: { '@type': 'Person', name: channel, ...(item.username ? { url: `${base}/@${item.username}` } : {}) } } : {}),
@@ -716,7 +724,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -716,7 +724,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
body: html`<div class="ovv-watch">
body: html`<div class="ovv-watch">
<div class="ovv-main">
<div class="ovv-main">
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
-<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(vod.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>
+<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<div class="ovv-byline">
<div class="ovv-byline">
${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}</a>` : ''}
${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}</a>` : ''}
@@ -794,7 +802,7 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -794,7 +802,7 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
<div class="ovv-main">
<div class="ovv-main">
${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is credited to you on OpenVibe.Live and here.', 'ok') : ''}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}
${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is credited to you on OpenVibe.Live and here.', 'ok') : ''}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}
${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`
${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`
- : html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
+ : html`<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<div class="ovv-byline">
<div class="ovv-byline">
<div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
<div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
@@ -88,6 +88,9 @@ function startLive() {
@@ -88,6 +88,9 @@ function startLive() {
ai_overview: '', ai_transcript_json: null,
ai_overview: '', ai_transcript_json: null,
stream_id: 1002, stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:35:00',
stream_id: 1002, stream_started_at: '2026-10-12 09:30:00', stream_ended_at: '2026-10-12 09:35:00',
};
};
+ // Live's answer since T4 step 5: playback_url is the playlist, file_url the file.
+ const hlsFirstVod = { ...secondVod, id: 105, title: 'Playlist first', playback_url: 'https://openvibe.media/o/med_105/master.m3u8',
+ hls_url: 'https://openvibe.media/o/med_105/master.m3u8', file_url: 'https://openvibe.media/v/105' };
const state = { down: false, chatDown: false };
const state = { down: false, chatDown: false };
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const url = new URL(req.url, 'http://x');
@@ -127,6 +130,7 @@ function startLive() {
@@ -127,6 +130,7 @@ function startLive() {
if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });
if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });
if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });
if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });
if (vodDetail[1] === '104') return json(200, { vod: secondVod, clips: [] });
if (vodDetail[1] === '104') return json(200, { vod: secondVod, clips: [] });
+ if (vodDetail[1] === '105') return json(200, { vod: hlsFirstVod, clips: [] });
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
}
}
const clipDetail = /^\/api\/clips\/(\d+)$/.exec(p);
const clipDetail = /^\/api\/clips\/(\d+)$/.exec(p);
@@ -353,6 +357,14 @@ function countOccurrences(haystack, needle) {
@@ -353,6 +357,14 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('private drive'));
assert.ok(r.text.includes('private drive'));
});
});
+ await check('an answer whose playback_url is the playlist plays the file_url as the file, with the playlist for hls.js', async () => {
+ const r = await getVideo('/v/105');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('src="https://openvibe.media/v/105"'), 'the file is the <video> source');
+ assert.ok(r.text.includes('data-ovv-hls="https://openvibe.media/o/med_105/master.m3u8"'));
+ assert.ok(r.text.includes('"contentUrl":"https://openvibe.media/v/105"'), 'contentUrl is the file');
+ });
+
await check('the VOD page lists the chat replay with offsets, escaping and no private fields', async () => {
await check('the VOD page lists the chat replay with offsets, escaping and no private fields', async () => {
const r = await getVideo('/v/101');
const r = await getVideo('/v/101');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);