openvibers/openvibe.live Commits

Play VODs and clips from Media's HLS playlist, keep fileurl as MP4 (#118)

OP OpenVibers committed 30 days ago · 920a7fb0ae · 7 files, +190 −10

* Play VODs and clips from Media's HLS playlist, keep fileurl as MP4

Asked: T4 step 5 — OpenVibe.Live: VOD and clip playback moves to HLS (server-side URL selection). ONE pull request from current origin/main (git fetch first; main is 8e5594d or newer). Today server/media-proxy/vods.js (~:136, ~:496) and server/media-proxy/clips.js (~:71, ~:252) set `playback_url` to Media…
Harness job live-t4-step-5-openvibe-live-225648 (plan T4).

* Add missing test:analytics-pg script for PostgreSQL analytics tests

Requested by finish-f2. Handed to the local harness by the cloud job pr-OpenVibe.Live-118; done by OpenCode Qwen3.7 Plus.

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 132c2505..7c4d5380 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -82,6 +82,22 @@ names the Video page, Live's sitemap leaves recordings and clips out (Video's si
sends to OpenVibe.Search link there, and an AI clip's canonical moment is the Video page at that second. Unlisted
items, recordings in progress and private items keep their Live canonical (they are not indexed anywhere).
++## Playback URLs
++
++Every VOD and clip row Live answers (`/api/vods`, `/api/vods/:id`, `/api/clips`, `/api/clips/:id` and the per-user
++lists) carries three URLs, set by `setPlaybackUrls()` in `server/media-client.js`:
++
++- `playback_url`: Media's HLS master playlist (`…/o/<object>/master.m3u8`) when the object has a timeline, otherwise
++ the byte route (`openvibe.media/v/<id>`, `/c/<id>`) for old and MP4-only objects.
++- `hls_url`: the playlist alone, absent until Media has one. A private object's playlist is signed for
++ `MEDIA_HLS_PLAYLIST_TTL_S` (Media's setting, 6 h by default): it is read from Media for each response and never
++ stored or cached by Live (a private item's detail answer is `private, no-store`; a public object's playlist is unsigned).
++- `file_url`: always the byte route, for anything that needs a real file (downloads, ffmpeg, previews).
++ `GET /api/vods/:id/file` and `/api/clips/:id/file` redirect to it.
++
++The watch pages play `hls_url` when there is one and fall back to the file. The server-rendered pages name no
++playback URL at all: their schema.org `contentUrl` is the page and their `og:video` tags carry only the duration.
++
## Views
A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)
ddiff --git a/package.json b/package.json
iindex 0601e72c..bc23ec80 100644
---- a/package.json
++++ b/package.json
@@@ -9,6 +9,7 @@
"test": "node test/run.js",
"test:unit": "node test/run.js",
"test:pg": "LIVE_TEST_STORE=pg node test/run.js",
++ "test:analytics-pg": "LIVE_TEST_STORE=pg node test/analytics-store.test.js && LIVE_TEST_STORE=pg node test/analytics-privacy.test.js",
"test:powerchat": "for f in test/powerchat-*.test.js; do node \"$f\" || exit 1; done",
"test:browser": "node test/browser/smoke.js",
"perf:page": "node scripts/perf/measure-page.js",
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 6fb8b457..3f0ef3be 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -315,6 +315,22 @@ function publicUrl(u) {
return `${MEDIA_PUBLIC_URL}${u.startsWith('/') ? '' : '/'}${u}`;
}
++/**
++ * The playback fields of a Media VOD or clip row, set in place and absolutized: file_url is the byte route (/v/:id,
++ * /c/:id — always a real file, for downloads, ffmpeg and anything else that needs one), hls_url is Media's timeline
++ * playlist (absent until the object has one), and playback_url is that playlist when there is one, else the file.
++ * A private object's hls_url is signed with a TTL (Media's MEDIA_HLS_PLAYLIST_TTL_S): it is computed per response
++ * from Media's answer and never stored.
++ */
++function setPlaybackUrls(row, kind) {
++ if (!row) return row;
++ const file = kind === 'clip' ? clipUrl(row.id) : vodPlaybackUrl(row.id);
++ row.file_url = row.file_url || publicUrl(row.playback_url) || file;
++ if (row.hls_url) row.hls_url = publicUrl(row.hls_url);
++ row.playback_url = row.hls_url || row.file_url;
++ return row;
++}
++
// ── Express proxy helper ─────────────────────────────────────────────────────
/**
@@@ -414,5 +430,5 @@ module.exports = {
uploadFile, getFileMeta, deleteFile,
uploadThumbnail, generateThumbnail,
// URL builders
-- vodPlaybackUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl,
++ vodPlaybackUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,
};
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 2ff48efd..3291977d 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -68,8 +68,8 @@ async function withUserFields(clip) {
}
}
// Absolutize Media-relative URLs so the SPA doesn't resolve them against Live.
-- clip.playback_url = media.publicUrl(clip.playback_url) || media.clipUrl(clip.id);
-- if (clip.hls_url) clip.hls_url = media.publicUrl(clip.hls_url);
++ // playback_url is Media's HLS playlist when the clip has one, else the file; file_url is always the file.
++ media.setPlaybackUrls(clip, 'clip');
if (clip.thumbnail_url) clip.thumbnail_url = media.publicUrl(clip.thumbnail_url);
// AI overview is Live-owned (clip_ai_state) — overlay short + full for the cards
// (the expander swaps the short teaser for the full text).
@@@ -257,7 +257,6 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
}
-- if (!clip.playback_url) clip.playback_url = media.clipUrl(clip.id);
if (clip.thumbnail_url) clip.thumbnail_url = media.publicUrl(clip.thumbnail_url);
// Overlay Live-owned AI state (overview + transcript live in clip_ai_state).
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex abab62b6..65dd53c7 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -133,9 +133,8 @@ async function withUserFields(row) {
}
// Media returns its own paths relative — absolutize onto MEDIA_PUBLIC_URL
// so the SPA doesn't resolve them against Live's origin.
-- row.playback_url = media.publicUrl(row.playback_url) || media.vodPlaybackUrl(row.id);
-- // Media's timeline playlist (absent until the VOD has one): the player prefers it to the file.
-- if (row.hls_url) row.hls_url = media.publicUrl(row.hls_url);
++ // playback_url is Media's HLS playlist when the VOD has one, else the file; file_url is always the file.
++ media.setPlaybackUrls(row, 'vod');
if (row.thumbnail_url) row.thumbnail_url = media.publicUrl(row.thumbnail_url);
// AI overview is Live-owned (vod_ai_state) — overlay short + full for the cards
// (the expander swaps the short teaser for the full text).
@@@ -493,7 +492,6 @@ router.get('/:id', optionalAuth, async (req, res) => {
vod.stream_peak_viewers = stream.peak_viewers;
}
}
-- if (!vod.playback_url) vod.playback_url = media.vodPlaybackUrl(vod.id);
if (vod.thumbnail_url) vod.thumbnail_url = media.publicUrl(vod.thumbnail_url);
// Overlay Live-owned AI state (overview + transcript live in vod_ai_state).
ddiff --git a/test/vod-clip-playback-url.test.js b/test/vod-clip-playback-url.test.js
nnew file mode 100644
iindex 00000000..c47c67e6
---- /dev/null
++++ b/test/vod-clip-playback-url.test.js
@@@ -0,0 +1,149 @@
++/**
++ * VOD and clip playback goes to HLS when Media has a playlist (T4 step 5).
++ *
++ * The media proxies set playback_url from the Media row: the object's hls_url (Media's timeline playlist) when it
++ * has one, the byte route (/v/:id, /c/:id) when it does not (old objects, MP4-only). file_url is always the byte
++ * route, for anything that needs a real file. The server-rendered /vod/:id and /clip/:id pages never carry the
++ * playlist (their contentUrl is the page; there is no og:video URL and recordings are not in the sitemap).
++ *
++ * The real routers run on the test database with Media stubbed in-process.
++ *
++ * node test/vod-clip-playback-url.test.js
++ */
++'use strict';
++const assert = require('assert');
++const http = require('http');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' ✓', name); }
++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }
++}
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ await raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
++ VALUES (3, 'alice', 'alice', 'alice@x', 'x', 'streamer', '2025-01-01 00:00:00') ON CONFLICT (id) DO NOTHING`).run();
++
++ const media = require('../server/media-client');
++ const HLS = 'https://media.test/o/obj_1/master.m3u8';
++ const SIGNED = 'https://media.test/o/obj_3/master.m3u8?t=tok&e=1';
++ const VODS = {
++ // Media answers its byte route relative; the playlist absolute (relative is absolutized too).
++ 100: { id: 100, user_id: 3, title: 'HLS VOD', visibility: 'public', is_public: 1, status: 'ready', playback_url: '/v/100', hls_url: HLS },
++ 101: { id: 101, user_id: 3, title: 'MP4 VOD', visibility: 'public', is_public: 1, status: 'ready', playback_url: '/v/101' },
++ 102: { id: 102, user_id: 3, title: 'Bare VOD', visibility: 'public', is_public: 1, status: 'ready' },
++ 103: { id: 103, user_id: 3, title: 'Relative HLS', visibility: 'public', is_public: 1, status: 'ready', playback_url: '/v/103', hls_url: '/o/obj_103/master.m3u8' },
++ };
++ const CLIPS = {
++ 200: { id: 200, user_id: 3, vod_id: 100, title: 'HLS clip', visibility: 'public', is_public: 1, status: 'ready', playback_url: '/c/200', hls_url: SIGNED, hls_expires_at: '2026-10-13T12:00:00Z' },
++ 201: { id: 201, user_id: 3, vod_id: 101, title: 'MP4 clip', visibility: 'public', is_public: 1, status: 'ready', playback_url: '/c/201' },
++ };
++ const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
++ media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };
++ media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
++ media.listVods = async () => ({ vods: Object.values(VODS).map((v) => ({ ...v })) });
++ media.listClips = async () => ({ clips: Object.values(CLIPS).map((c) => ({ ...c })) });
++
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/api/vods', require('../server/media-proxy/vods'));
++ app.use('/api/clips', require('../server/media-proxy/clips'));
++ const server = http.createServer(app).listen(0);
++ const get = (p) => new Promise((resolve, reject) => {
++ http.get({ port: server.address().port, path: p, headers: { 'cf-connecting-ip': '10.0.0.9' } }, (res) => {
++ let text = '';
++ res.on('data', (c) => { text += c; });
++ res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(text || 'null'), headers: res.headers }));
++ }).on('error', reject);
++ });
++
++ await check('VOD detail with hls_url: playback_url is the playlist, file_url the byte route', async () => {
++ const r = await get('/api/vods/100');
++ assert.strictEqual(r.status, 200);
++ const v = r.json.vod;
++ assert.strictEqual(v.playback_url, HLS);
++ assert.strictEqual(v.hls_url, HLS);
++ assert.strictEqual(v.file_url, media.vodPlaybackUrl(100));
++ assert.match(v.file_url, /\/v\/100$/);
++ });
++
++ await check('VOD detail without hls_url: playback_url and file_url are the byte route', async () => {
++ for (const id of [101, 102]) {
++ const r = await get(`/api/vods/${id}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.vod.playback_url, media.vodPlaybackUrl(id), `VOD ${id}`);
++ assert.strictEqual(r.json.vod.file_url, media.vodPlaybackUrl(id), `VOD ${id}`);
++ assert.ok(!r.json.vod.hls_url);
++ }
++ });
++
++ await check('a relative hls_url is absolutized onto Media', async () => {
++ const r = await get('/api/vods/103');
++ assert.strictEqual(r.json.vod.playback_url, media.publicUrl('/o/obj_103/master.m3u8'));
++ assert.match(r.json.vod.playback_url, /^https?:\/\/.+\.m3u8$/);
++ });
++
++ await check('VOD list: each row picks its own URL', async () => {
++ const r = await get('/api/vods');
++ const by = Object.fromEntries(r.json.vods.map((v) => [v.id, v]));
++ assert.strictEqual(by[100].playback_url, HLS);
++ assert.strictEqual(by[100].file_url, media.vodPlaybackUrl(100));
++ assert.strictEqual(by[101].playback_url, media.vodPlaybackUrl(101));
++ });
++
++ await check('clip detail: a signed playlist is passed through per response, the file stays reachable', async () => {
++ const r = await get('/api/clips/200');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.clip.playback_url, SIGNED);
++ assert.strictEqual(r.json.clip.hls_expires_at, '2026-10-13T12:00:00Z');
++ assert.strictEqual(r.json.clip.file_url, media.clipUrl(200));
++ const b = await get('/api/clips/201');
++ assert.strictEqual(b.json.clip.playback_url, media.clipUrl(201));
++ assert.strictEqual(b.json.clip.file_url, media.clipUrl(201));
++ });
++
++ await check('clip list: playlist when present, byte route otherwise', async () => {
++ const r = await get('/api/clips');
++ const by = Object.fromEntries(r.json.clips.map((c) => [c.id, c]));
++ assert.strictEqual(by[200].playback_url, SIGNED);
++ assert.strictEqual(by[201].playback_url, media.clipUrl(201));
++ assert.strictEqual(by[201].file_url, media.clipUrl(201));
++ });
++
++ await check('server-rendered /vod/:id and /clip/:id never carry the playlist', async () => {
++ const seo = require('../server/seo');
++ for (const p of ['/vod/100', '/clip/200']) {
++ const meta = await seo._pageMeta(p);
++ assert.ok(meta, `${p} renders`);
++ const s = JSON.stringify(meta);
++ assert.ok(!/\.m3u8/.test(s), `${p} has no .m3u8`);
++ const vo = meta.jsonLd.find((x) => x && /VideoObject/.test(x['@type']));
++ if (vo && vo.contentUrl) assert.ok(!/\.m3u8/.test(vo.contentUrl), `${p} contentUrl is not a playlist`);
++ }
++ });
++
++ await check('the file routes still redirect to the byte route', async () => {
++ const v = await new Promise((resolve) => http.get({ port: server.address().port, path: '/api/vods/100/file' }, resolve));
++ assert.strictEqual(v.statusCode, 302);
++ assert.strictEqual(v.headers.location, media.vodPlaybackUrl(100));
++ v.resume();
++ const c = await new Promise((resolve) => http.get({ port: server.address().port, path: '/api/clips/200/file' }, resolve));
++ assert.strictEqual(c.headers.location, media.clipUrl(200));
++ c.resume();
++ });
++
++ server.close();
++ if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
++ quiet('vod/clip playback url: all checks passed');
++ process.exit(0);
++})().catch((err) => { quiet(err); process.exit(1); });
ddiff --git a/test/vod-hls-source.test.js b/test/vod-hls-source.test.js
iindex 5343f947..c2055d30 100644
---- a/test/vod-hls-source.test.js
++++ b/test/vod-hls-source.test.js
@@@ -108,10 +108,11 @@ function sandbox({ hlsSupported = true } = {}) {
assert.strictEqual(made.length, 1, 'no new hls.js instance for a page that is gone');
assert.strictEqual(w.src, '');
}
-- // The media proxies keep Media's playlist URL on VOD and clip rows.
++ // The media proxies keep Media's playlist URL on VOD and clip rows (behaviour: test/vod-clip-playback-url.test.js).
for (const f of ['vods.js', 'clips.js']) {
const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'media-proxy', f), 'utf8');
-- assert.match(src, /hls_url = media\.publicUrl\(/, `${f} keeps hls_url`);
++ assert.match(src, /media\.setPlaybackUrls\(/, `${f} keeps hls_url`);
}
++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server', 'media-client.js'), 'utf8'), /row\.hls_url = publicUrl\(row\.hls_url\)/, 'media-client absolutizes hls_url');
console.log('vod hls source: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });