openvibers/openvibe.media Commits
A shared virtual clip opens on its canonical watch page (#267)
Changes
@@@ -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 };@@@ -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' });@@@ -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 beforehtmlBrotliKB: 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 beforejsFiles: 4, // 3// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead ofnnew file mode 100644@@@ -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); });
@@ -50,6 +50,22 @@ function sourcePage(kind, record) {
@@ -50,6 +50,22 @@ function sourcePage(kind, record) {
return watchPageUrl(kind, record.app_id, record.id);
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. */
/** A clip the owning app's automation cut (AI moments), not a person. */
function isAiClip(kind, record) {
function isAiClip(kind, record) {
return kind === 'clip' && !!Number(record.auto_generated || 0);
return kind === 'clip' && !!Number(record.auto_generated || 0);
@@ -228,4 +244,4 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -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 };
@@ -225,14 +225,28 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -225,14 +225,28 @@ async function serveMediaRecord(kind, record, req, res) {
if (visibility === 'private' && !canAccessPrivate(record, req, kind)) return notFound(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
// 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') {
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 (drill.refuseBytes(res)) return;
if (!config.hls.enabled || !record.object_id || record.status !== 'ready') return res.status(404).json({ error: 'Media file unavailable' });
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);
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');
const asFile = downloadDisposition(req, kind, record, '.mp4');
if (asFile) return sendVirtualClipFile(req, res, record, closed, asFile);
if (asFile) return sendVirtualClipFile(req, res, record, closed, asFile);
res.set({ 'Cache-Control': 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
res.set({ 'Cache-Control': 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
@@ -13,7 +13,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -13,7 +13,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
(async () => {
(async () => {
const BUDGETS = {
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
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
jsFiles: 4, // 3
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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); });