openvibers/openvibe.media Commits

A shared virtual clip opens on its canonical watch page (#267)

OP OpenVibers committed 12 days ago · c01485037f · 4 files, +148 −6

* A shared virtual clip opens on its canonical watch page

A virtual clip (storage_provider 'timeline', no file) has no bytes for a watch page of its own: the page's player and download link name ?raw=1 bytes it does not have. A browser navigating to one got the raw HLS playlist instead (a 302 to /o/<object>/master.m3u8). Such a clip is watched on its canonical page - OpenVibe.Video for Live's items, the owning app's page otherwise - so a navigation now answers 302 to that page (Cache-Control private, max-age=0; X-Robots-Tag noindex). Every other reader (players, fetches, Range, ?raw=1, ?download) keeps today's playlist redirect, a closed clip (private, or a sandbox tenant's) keeps its signed playlist, and no view is counted for the page redirect.

* The home page's HTML budget: 52 KB (it measures 51.9)

The WebSite + Organization JSON-LD (#264) made the home page 51.9 KB raw, over the
51.8 KB budgeted for it, so main's test job has been red since it landed. The budget
now says what the page measures: 52 KB (the check fires above it, so the page keeps
0.1 KB of room before it counts as over).

Changes

ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex b90f4c9..3281ba3 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -50,6 +50,22 @@ function sourcePage(kind, record) {
return watchPageUrl(kind, record.app_id, record.id);
}
++/**
++ * The canonical watch page of an item when it lives on another site: an absolute https URL
++ * (OpenVibe.Video for Live's items, the owning app's own page otherwise). Null when this site
++ * IS the canonical home (the same host), or when the URL is not an absolute https URL: the
++ * caller then keeps serving what it serves today instead of redirecting nowhere.
++ */
++function canonicalPageUrl(kind, record) {
++ const url = sourcePage(kind, record);
++ if (!url) return null;
++ let target, self;
++ try { target = new URL(url); self = new URL(config.publicUrl); } catch { return null; }
++ if (target.protocol !== 'https:') return null;
++ if (target.hostname === self.hostname) return null; // this site is the canonical home
++ return target.toString();
++}
++
/** A clip the owning app's automation cut (AI moments), not a person. */
function isAiClip(kind, record) {
return kind === 'clip' && !!Number(record.auto_generated || 0);
@@@ -228,4 +244,4 @@ function renderWatchPage(kind, record, readiness = null) {
});
}
--module.exports = { wantsHtmlPage, renderWatchPage, sourcePage, watchIndexable, isAiClip };
++module.exports = { wantsHtmlPage, renderWatchPage, sourcePage, canonicalPageUrl, watchIndexable, isAiClip };
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 3c245e0..d611930 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -225,14 +225,28 @@ async function serveMediaRecord(kind, record, req, res) {
if (visibility === 'private' && !canAccessPrivate(record, req, kind)) return notFound(req, res);
// A virtual clip (F3.5) has no file: its bytes are its HLS playlist over the source's timeline. Every reader allowed
-- // here (public, the owner, or a 'getc' URL from GET /api/clips/:id/signed-url), a browser navigation included, is sent
-- // to the master playlist, signed for a closed clip with the clip's own 'hls' token, which reaches only the segments
-- // inside the clip's window. Before the watch page: its player and download link name ?raw=1 bytes it does not have.
++ // here (public, the owner, or a 'getc' URL from GET /api/clips/:id/signed-url) is sent to the master playlist,
++ // signed for a closed clip with the clip's own 'hls' token, which reaches only the segments inside the clip's
++ // window. A browser navigating to an open clip gets the canonical watch page instead (it has the player and
++ // the SEO; this site's own watch page cannot: it has no bytes for the ?raw=1 its player asks for).
if (kind === 'clip' && !record.file_path && record.storage_provider === 'timeline') {
++ // A closed clip (private, or a sandbox tenant's) plays through a signed playlist: only a reader
++ // holding a signed link can open it, and the canonical page cannot show it to them.
++ const closed = visibility === 'private' || await db.isSandboxTenant(record.app_id);
++ // A browser NAVIGATING to a shared virtual clip gets the canonical watch page (OpenVibe.Video for
++ // Live's items, the owning app's page otherwise): a virtual clip has no bytes for a watch page of
++ // its own — its player and download link name ?raw=1 bytes it does not have — and that page plays it.
++ // No view is counted for it (a view is for bytes); every other reader keeps the playlist below.
++ if (!closed && pages.wantsHtmlPage(req)) {
++ const canonical = pages.canonicalPageUrl(kind, record);
++ if (canonical) {
++ res.set({ 'Cache-Control': 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
++ return res.redirect(302, canonical);
++ }
++ }
if (drill.refuseBytes(res)) return;
if (!config.hls.enabled || !record.object_id || record.status !== 'ready') return res.status(404).json({ error: 'Media file unavailable' });
await trackUniqueView(kind, record.id, req, record.user_id);
-- const closed = visibility === 'private' || await db.isSandboxTenant(record.app_id);
const asFile = downloadDisposition(req, kind, record, '.mp4');
if (asFile) return sendVirtualClipFile(req, res, record, closed, asFile);
res.set({ 'Cache-Control': 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 4c7f91f..6259351 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -13,7 +13,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
(async () => {
const BUDGETS = {
-- htmlRawKB: 51.8, // 2026-11-01: WebSite + Organization JSON-LD; 50.1 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 48.6 with the phone pick block and folds (2026-10-30; a fresh database measures a little larger); 43.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); measured 38.8 on 2026-10-22 (fresh database): the home gained its developer half (what it does, code, limits, questions and their FAQPage data); 19.8 before
++ htmlRawKB: 52, // 2026-11-01: the WebSite + Organization JSON-LD (#264) measures 51.9, over the 51.8 it was budgeted at; 50.1 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 48.6 with the phone pick block and folds (2026-10-30; a fresh database measures a little larger); 43.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); measured 38.8 on 2026-10-22 (fresh database): the home gained its developer half (what it does, code, limits, questions and their FAQPage data); 19.8 before
htmlBrotliKB: 12.3, // 12.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 10.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 9.7 on 2026-10-22; 5.0 before
jsFiles: 4, // 3
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
ddiff --git a/test/virtual-clip-page.test.js b/test/virtual-clip-page.test.js
nnew file mode 100644
iindex 0000000..7a3e384
---- /dev/null
++++ b/test/virtual-clip-page.test.js
@@@ -0,0 +1,112 @@
++'use strict';
++// A virtual clip (storage_provider 'timeline', no file of its own) has no bytes for a watch page here:
++// the page's player and download link name ?raw=1 bytes it does not have. So a browser NAVIGATING to a
++// shared virtual clip is sent to the canonical watch page (OpenVibe.Video for Live's items, the owning
++// app's own page otherwise) — the page that plays the clip. Every other reader (players, fetches,
++// ?raw=1, ?download) keeps today's playlist redirect, and a private clip keeps answering 404 to a reader
++// who may not see it.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-virtual-page-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ Object.assign(process.env, {
++ VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'), THUMBNAILS_PATH: dir('thumbnails'),
++ PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'), MEDIA_PUBLIC_URL: 'https://media.test',
++ MEDIA_HLS_ENABLED: '1', MEDIA_INVARIANT_SCAN_HOURS: '0',
++ });
++
++ const config = require('../server/config');
++ const db = require('../server/db/database');
++ const model = require('../server/objects/model');
++ const pages = require('../server/public/pages');
++ await db.upsertApp({ app_id: 'live', api_key: 'live-key-virtual-page' });
++
++ // A virtual clip: an HLS playlist over the source's timeline, no file of its own.
++ const makeClip = async ({ title, visibility }) => {
++ const objectId = await model.createObject({ app_id: 'live', owner_user_id: 5, visibility,
++ lifecycle_status: 'ready', kind: 'clip', mime_type: 'video/mp4', size_bytes: 1 });
++ const id = Number((await db.run(`INSERT INTO clips (app_id, user_id, title, file_path, storage_provider, object_id,
++ status, is_public, visibility, duration_seconds, start_time, end_time)
++ VALUES ('live', 5, ?, NULL, 'timeline', ?, 'ready', ?, ?, 6, 5, 11) RETURNING id`,
++ [title, objectId, visibility === 'public' ? 1 : 0, visibility])).lastInsertRowid);
++ return { id, objectId };
++ };
++ const open = await makeClip({ title: 'Shared moment', visibility: 'public' });
++ const hidden = await makeClip({ title: 'Private moment', visibility: 'private' });
++
++ const express = require('express');
++ const app = express();
++ app.use('/', require('../server/public/routes'));
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const port = server.address().port;
++ const get = (p, headers = {}) => new Promise((resolve, reject) => {
++ http.get({ host: '127.0.0.1', port, path: p, headers }, (res) => {
++ let body = ''; res.setEncoding('utf8'); res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
++ }).on('error', reject);
++ });
++ const viewsOf = async (id) => Number((await db.get('SELECT COALESCE(view_count,0) AS n FROM clips WHERE id = ?', [id])).n);
++
++ try {
++ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };
++ const playlist = `${config.publicUrl}/o/${open.objectId}/master.m3u8`;
++
++ // ── A browser navigating to a shared virtual clip: the canonical watch page ──
++ let r = await get(`/c/${open.id}`, nav);
++ assert.strictEqual(r.status, 302, 'a navigation is redirected, not answered with the playlist');
++ assert.strictEqual(r.headers.location, `https://openvibe.video/c/${open.id}`, 'Live\'s clip is canonical on OpenVibe.Video');
++ assert.strictEqual(r.headers['cache-control'], 'private, max-age=0', 'no stale redirect is cached');
++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex', 'the redirect itself never indexes');
++ assert.strictEqual(await viewsOf(open.id), 0, 'the page redirect counts no view (a view is for bytes)');
++ console.log('✅ a browser navigating to a virtual clip is redirected to its canonical watch page');
++
++ // ── The bytes are unchanged: ?raw=1 and a player still get the playlist ──
++ r = await get(`/c/${open.id}?raw=1`, nav);
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, playlist, '?raw=1 still yields the master playlist');
++ assert.strictEqual(await viewsOf(open.id), 1, 'the bytes count a view');
++ r = await get(`/c/${open.id}`, { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' });
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, playlist, 'a player still gets the master playlist');
++ r = await get(`/c/${open.id}`, { accept: '*/*' });
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, playlist, 'a plain fetch still gets the master playlist');
++ console.log('✅ ?raw=1, a player and a plain fetch keep today\'s playlist redirect');
++
++ // ── A private clip looks missing to a reader who may not see it ──
++ r = await get(`/c/${hidden.id}`, nav);
++ assert.strictEqual(r.status, 404, 'a private virtual clip seen anonymously: 404, as today');
++ assert.ok(!r.headers.location, 'and no redirect that would name it');
++ console.log('✅ a private virtual clip seen anonymously is still a 404');
++
++ // ── The rule itself: another site's canonical page, or none at all ──
++ assert.strictEqual(pages.canonicalPageUrl('clip', { id: 7, app_id: 'live' }), 'https://openvibe.video/c/7');
++ assert.strictEqual(pages.canonicalPageUrl('clip', { id: 7, app_id: 'games' }), 'https://openvibe.games/clip/7');
++ {
++ // The same host: this site is the canonical home, so there is nothing to redirect to.
++ const framePath = require.resolve('../server/public/page-frame');
++ const pagesPath = require.resolve('../server/public/pages');
++ const realVideo = process.env.VIDEO_PUBLIC_URL;
++ process.env.VIDEO_PUBLIC_URL = 'https://media.test';
++ delete require.cache[framePath];
++ delete require.cache[pagesPath];
++ const sameHost = require(pagesPath);
++ assert.strictEqual(sameHost.canonicalPageUrl('clip', { id: 7, app_id: 'live' }), null, 'no canonical page on another host: nothing to redirect to');
++ process.env.VIDEO_PUBLIC_URL = realVideo;
++ delete require.cache[framePath];
++ delete require.cache[pagesPath];
++ }
++ console.log('✅ the canonical page is another site\'s, or none: the rule is one place');
++
++ console.log('virtual clip page: all checks passed');
++ } finally {
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });