openvibers/openvibe.media Commits
Clip pages link a source VOD only when /v/:id can serve it (#261)
Changes
@@@ -17,6 +17,7 @@ const seo = require('openvibe-shared/seo');const releaseLastmod = require('../sitemap-lastmod');const cache = require('openvibe-shared/cache-policy');const pages = require('./pages');++const sourceVod = require('./source-vod');const readiness = require('../objects/readiness');const MAX_URLS = 45000; // under the 50,000-URL sitemap limit@@@ -40,11 +41,11 @@ const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicU* use it; pages.watchIndexable and the sandbox check still apply to each row.*/const WATCH_WHERE = {-- vod: `COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0 AND is_public = 1 AND COALESCE(visibility, 'public') = 'public'-- AND quarantined_at IS NULL AND COALESCE(health_status, 'ok') NOT IN ('corrupt', 'zero_byte', 'missing_file', 'needs_review')-- AND file_path IS NOT NULL AND app_id != 'live' AND ${readiness.playableSql('vods.object_id')}`,-- clip: `COALESCE(status, 'ready') = 'ready' AND COALESCE(is_public, 1) = 1 AND COALESCE(visibility, 'public') = 'public'-- AND COALESCE(auto_generated, 0) = 0 AND COALESCE(file_path, '') != '' AND app_id != 'live' AND ${readiness.playableSql('clips.object_id')}`,++ vod: `COALESCE(vods.is_recording, 0) = 0 AND COALESCE(vods.clips_only, 0) = 0 AND vods.is_public = 1 AND COALESCE(vods.visibility, 'public') = 'public'++ AND vods.quarantined_at IS NULL AND COALESCE(vods.health_status, 'ok') NOT IN ('corrupt', 'zero_byte', 'missing_file', 'needs_review')++ AND vods.file_path IS NOT NULL AND vods.app_id != 'live' AND ${readiness.playableSql('vods.object_id')}`,++ clip: `COALESCE(clips.status, 'ready') = 'ready' AND COALESCE(clips.is_public, 1) = 1 AND COALESCE(clips.visibility, 'public') = 'public'++ AND COALESCE(clips.auto_generated, 0) = 0 AND COALESCE(clips.file_path, '') != '' AND clips.app_id != 'live' AND ${readiness.playableSql('clips.object_id')}`,};async function sandboxApps() {@@@ -118,13 +119,20 @@ function llmsTxt() {async function indexableWatchRows(limit = MAX_URLS) {const sandbox = await sandboxApps();const out = [];-- const base = 'id, app_id, visibility, is_public, title, description, ai_overview, thumbnail_url, file_path, duration_seconds, view_count, created_at';++ const base = ['id', 'app_id', 'visibility', 'is_public', 'title', 'description', 'ai_overview', 'thumbnail_url', 'file_path', 'duration_seconds', 'view_count', 'created_at'];++ // Qualify the clip columns: its LEFT JOIN adds the source VOD's own id, app_id and visibility.++ const cols = (alias, extra = []) => [...base, ...extra].map((c) => `${alias}.${c}`).join(', ');for (const kind of ['vod', 'clip']) {const table = kind === 'vod' ? 'vods' : 'clips';// vods has no auto_generated column (only clips do); pages.watchIndexable reads it for clips.-- const cols = kind === 'clip' ? `${base}, auto_generated` : base;-- const rows = await db.all(`SELECT ${cols} FROM ${table} WHERE ${WATCH_WHERE[kind]} ORDER BY created_at DESC LIMIT ?`, [limit]);++ // The clip page's source VOD comes from the same read, so rendering a list stays one query.++ const select = kind === 'clip'++ ? `${cols('clips', ['auto_generated'])}, ${sourceVod.COLUMNS}`++ : cols('vods');++ const from = kind === 'clip' ? 'clips LEFT JOIN vods sv ON sv.id = clips.vod_id' : table;++ const rows = await db.all(`SELECT ${select} FROM ${from} WHERE ${WATCH_WHERE[kind]} ORDER BY ${table}.created_at DESC LIMIT ?`, [limit]);for (const row of rows) {++ if (kind === 'clip') sourceVod.attach(row);if (sandbox.has(row.app_id) || !pages.watchIndexable(kind, row)) continue;out.push({ kind, row });}@@@ -66,6 +66,23 @@ function watchIndexable(kind, record) {return visibility === 'public' && !ownedByLive && !isAiClip(kind, record);}++/**++ * The source VOD link for an AI clip, or null. `record.source_vod` is read with the clip++ * (server/public/source-vod.js): it must be a row /v/:id answers 200 for — not private, not a++ * clips-only shell, finished and playable — and not missing. Without that evidence no link is shown:++ * a private or unread VOD used to be linked anyway and answered 404.++ */++function sourceVodHref(record) {++ const source = record && record.source_vod;++ if (!source) return null;++ const visibility = source.visibility || (Number(source.is_public) ? 'public' : 'private');++ if (visibility === 'private') return null;++ if (Number(source.clips_only || 0) || Number(source.is_recording || 0)) return null;++ if (!source.playable) return null;++ const id = source.id != null ? source.id : record.vod_id;++ return id != null ? `${config.publicUrl}/v/${id}` : null;++}++/*** What the watch page says instead of a player when the item is not playable (readiness.reason,* server/objects/readiness.js): [heading, detail].@@@ -112,7 +129,7 @@ function renderWatchPage(kind, record, readiness = null) {const playable = !readiness || !!readiness.playable;// The owning app has the canonical page; unlisted items, AI clips and pages without a player never index.const robots = playable && watchIndexable(kind, record) ? 'index, follow' : 'noindex, follow';-- const sourceVod = ai && record.vod_id ? `${config.publicUrl}/v/${record.vod_id}` : null;++ const sourceVod = ai && record.vod_id ? sourceVodHref(record) : null;const mime = VIDEO_MIME[path.extname(record.file_path || '').toLowerCase()] || 'video/mp4';const uploadDate = isoDate(record.created_at);const duration = isoDuration(record.duration_seconds);@@@ -32,6 +32,7 @@ const db = require('../db/database');const tools = require('../vod/media-tools');const { optionalIdentity } = require('../auth');const pages = require('./pages');++const sourceVod = require('./source-vod');// A restore drill (MEDIA_DRILL) serves no stored bytes: every byte route answers 503 before it looks// at a file path (the database's paths are production's files). Watch pages still render.const drill = require('../drill');@@@ -393,10 +394,14 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })router.get('/c/:id', optionalIdentity, async (req, res) => {try {if (!/^\d+$/.test(req.params.id)) return notFound(res);-- const clip = await db.getClipById(parseInt(req.params.id, 10));++ const wantsPage = pages.wantsHtmlPage(req);++ // The page needs its source VOD's state beside the clip; the bytes path keeps the plain read.++ const clip = wantsPage++ ? await sourceVod.clipForPage(parseInt(req.params.id, 10))++ : await db.getClipById(parseInt(req.params.id, 10));// A clip still being cut (or whose cut failed) has no file yet: its bytes are a 404, and a// browser navigation gets the watch page saying so (private clips still look missing).-- if (!clip || (!clip.file_path && clip.storage_provider !== 'timeline' && !pages.wantsHtmlPage(req))) return notFound(res);++ if (!clip || (!clip.file_path && clip.storage_provider !== 'timeline' && !wantsPage)) return notFound(res);await serveMediaRecord('clip', clip, req, res);} catch (err) {console.error('[Public] /c error:', err.message);nnew file mode 100644@@@ -0,0 +1,46 @@++/**++ * OpenVibe.Media — a clip page's source VOD, read with the clip.++ *++ * A clip cut from a stream links back to the VOD it came from, but only while that VOD's own page++ * (/v/:id) can answer 200: the row exists, it is not a clips-only shell, it is not private, and its++ * object is playable (server/objects/readiness.js). Reading the decision as joined columns keeps the++ * page at one query, and a list of clips at one query too.++ *++ * `source_vod` is attached to the clip row as { id, visibility, is_public, clips_only, is_recording,++ * playable }; pages.renderWatchPage() never links a clip whose source VOD is missing.++ */++'use strict';++++const db = require('../db/database');++const readiness = require('../objects/readiness');++++/** Columns a query selects beside the clip's own: the source VOD under the alias `sv`. */++const COLUMNS = `++ sv.id AS source_vod_id,++ sv.visibility AS source_vod_visibility,++ sv.is_public AS source_vod_is_public,++ COALESCE(sv.clips_only, 0) AS source_vod_clips_only,++ COALESCE(sv.is_recording, 0) AS source_vod_is_recording,++ ${readiness.playableSql('sv.object_id')} AS source_vod_playable`;++++/** Put the joined source-VOD columns on the row as one object (null when the clip has no source). */++function attach(row) {++ if (!row) return row;++ row.source_vod = row.source_vod_id == null ? null : {++ id: row.source_vod_id,++ visibility: row.source_vod_visibility,++ is_public: row.source_vod_is_public,++ clips_only: row.source_vod_clips_only,++ is_recording: row.source_vod_is_recording,++ playable: !!row.source_vod_playable,++ };++ return row;++}++++/** One clip with its source VOD's linkability, for /c/:id's page (the bytes path reads the clip alone). */++async function clipForPage(id) {++ return attach(await db.get(`SELECT c.*, ${COLUMNS}++ FROM clips c LEFT JOIN vods sv ON sv.id = c.vod_id WHERE c.id = ?`, [id]));++}++++module.exports = { COLUMNS, attach, clipForPage };@@@ -28,6 +28,7 @@ const express = require('express');const db = require('../server/db/database');const pages = require('../server/public/pages');++ const sourceVod = require('../server/public/source-vod');await db.upsertApp({ app_id: 'live', api_key: 'k1-live-crawl' });await db.upsertApp({ app_id: 'games', api_key: 'k2-games-crawl' });@@@ -49,6 +50,11 @@ const express = require('express');await clip.run(13, 'games', 1, 'Cutting', 1, 'public', 0, 'processing', '2026-09-22 12:00:00');await clip.run(14, 'games', 1, 'Unlisted clip', 0, 'unlisted', 0, 'ready', '2026-09-22 13:00:00');await clip.run(15, 'live', 4, 'Live clip', 1, 'public', 0, 'ready', '2026-09-22 14:00:00');++ // AI clips whose source VOD is private, missing, or public and playable (the page links only the last).++ await clip.run(16, 'games', 3, 'AI private source', 1, 'public', 1, 'ready', '2026-09-22 15:00:00');++ await clip.run(17, 'games', 999, 'AI missing source', 1, 'public', 1, 'ready', '2026-09-22 16:00:00');++ await clip.run(18, 'games', 1, 'AI public source', 1, 'public', 1, 'ready', '2026-09-22 17:00:00');++ await clip.run(19, 'games', 8, 'AI unverified source', 1, 'public', 1, 'ready', '2026-09-22 18:00:00');// Public and finished, but its only copy (B2) has never been checked: not playable, never listed.await raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, health_status, storage_provider, created_at, duration_seconds) OVERRIDING SYSTEM VALUEVALUES (8, 'games', 'Games unverified', '/gone.mp4', 1, 'public', 'ok', 'b2', '2026-09-21 14:00:00', 60) RETURNING id`).run();@@@ -60,7 +66,7 @@ const express = require('express');const model = require('../server/objects/model');const readiness = require('../server/objects/readiness');for (const id of [1, 2, 3, 4, 5, 6, 7, 8]) await model.sync('vod', id);-- for (const id of [11, 12, 13, 14, 15]) await model.sync('clip', id);++ for (const id of [11, 12, 13, 14, 15, 16, 17, 18, 19]) await model.sync('clip', id);const app = express();app.use('/', require('../server/public/routes'));@@@ -148,13 +154,24 @@ const express = require('express');assert.deepStrictEqual(ldOf(pub).author, { '@type': 'Person', name: 'gamer', url: 'https://openvibe.games/@gamer' });assert.deepStrictEqual(ldOf(pub).creator, ldOf(pub).author);assert.strictEqual(ldOf(pub).isLiveBroadcast, undefined);-- const aiHtml = pages.renderWatchPage('clip', { ...await byId('clips', 12), username: 'gamer' });++ const aiHtml = pages.renderWatchPage('clip', { ...await sourceVod.clipForPage(12), username: 'gamer' });assert.ok(aiHtml.includes('content="noindex, follow"'), 'AI clips never index');const aiLd = ldOf(aiHtml);assert.deepStrictEqual(aiLd.author, aiLd.creator, 'automation is credited to an organization');assert.deepStrictEqual(aiLd.creator, { '@type': 'Organization', name: 'OpenVibe AI', url: 'https://openvibe.network' });assert.strictEqual(aiLd.isBasedOn, 'https://media.test/v/1');assert.ok(aiHtml.includes('<span>AI clip</span>') && !aiHtml.includes('by gamer'));++ // The source VOD is linked only while /v/:id can answer 200 for it: private, missing and++ // unverified (no playable copy) ones are not.++ const nav = { accept: 'text/html', 'sec-fetch-dest': 'document' };++ for (const [id, linked] of [[16, false], [17, false], [18, true], [19, false]]) {++ r = await get(`/c/${id}`, nav);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.includes('See the VOD this clip came from'), linked, `clip ${id}: source VOD card`);++ assert.strictEqual(r.body.includes('"isBasedOn"'), linked, `clip ${id}: source VOD isBasedOn`);++ }++ assert.ok(!(await sourceVod.clipForPage(19)).source_vod.playable, 'the joined source VOD is not playable');++ assert.ok((await get('/c/18', nav)).body.includes('https://media.test/v/1'), 'a public source VOD keeps its URL');assert.strictEqual(ldOf(pages.renderWatchPage('vod', await byId('vods', 2))), null, 'unlisted pages carry no structured data');assert.ok(pages.renderWatchPage('clip', await byId('clips', 15)).includes('content="noindex, follow"'), 'Live-owned: canonical on Live');r = await get('/c/12', { accept: 'text/html', 'sec-fetch-dest': 'document' });@@@ -106,11 +106,24 @@ const http = require('http');assert.deepStrictEqual(checkCards(pages.renderWatchPage('clip', { ...vod, id: 9 }), 'Live clip'),['https://openvibe.video/c/9?ov_from=media', 'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media']);// An AI clip with a source VOD: every card applies, and four is the most that render.-- const aiClip = pages.renderWatchPage('clip', { ...vod, id: 10, auto_generated: 1, vod_id: 42 });++ const sourceVod = (fields = {}) => ({ id: 42, visibility: 'public', is_public: 1, clips_only: 0, is_recording: 0, playable: true, ...fields });++ const aiClip = pages.renderWatchPage('clip', { ...vod, id: 10, auto_generated: 1, vod_id: 42, source_vod: sourceVod() });assert.deepStrictEqual(checkCards(aiClip, 'AI clip'), ['https://openvibe.video/c/10?ov_from=media', 'https://media.test/v/42?ov_from=media','https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media'], 'a clip with a source VOD shows the VOD card');assert.ok(aiClip.includes('See the VOD this clip came from'));assert.strictEqual((aiClip.match(/class="sc-next-item"/g) || []).length, 4, 'at most four cards');++ // A source VOD that /v/:id would not answer 200 for: no link card and no isBasedOn.++ const noSource = (source) => pages.renderWatchPage('clip', { ...vod, id: 12, auto_generated: 1, vod_id: 42, ...(source === undefined ? {} : { source_vod: source }) });++ for (const [label, page] of [++ ['private', noSource(sourceVod({ visibility: 'private', is_public: 0 }))],++ ['missing', noSource(undefined)],++ ['still recording', noSource(sourceVod({ is_recording: 1 }))],++ ['not playable', noSource(sourceVod({ playable: false }))],++ ]) {++ assert.ok(!page.includes('See the VOD this clip came from'), `${label} source VOD shows no link card`);++ assert.ok(!page.includes('"isBasedOn"'), `${label} source VOD is not isBasedOn`);++ assert.ok(!checkCards(page, label).some((href) => /\/v\/42/.test(href)), `${label} source VOD adds no card`);++ }// A clip without a player offers no clip-cutting card.const pending = pages.renderWatchPage('clip', { ...vod, id: 11 }, { playable: false, reason: 'processing' });assert.ok(!cards(pending).includes('https://openvibe.live/?ov_from=media'), 'no "go live" card without a player');
@@ -17,6 +17,7 @@ const seo = require('openvibe-shared/seo');
@@ -17,6 +17,7 @@ const seo = require('openvibe-shared/seo');
const releaseLastmod = require('../sitemap-lastmod');
const releaseLastmod = require('../sitemap-lastmod');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const pages = require('./pages');
const pages = require('./pages');
+const sourceVod = require('./source-vod');
const readiness = require('../objects/readiness');
const readiness = require('../objects/readiness');
const MAX_URLS = 45000; // under the 50,000-URL sitemap limit
const MAX_URLS = 45000; // under the 50,000-URL sitemap limit
@@ -40,11 +41,11 @@ const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicU
@@ -40,11 +41,11 @@ const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicU
* use it; pages.watchIndexable and the sandbox check still apply to each row.
* use it; pages.watchIndexable and the sandbox check still apply to each row.
*/
*/
const WATCH_WHERE = {
const WATCH_WHERE = {
- vod: `COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0 AND is_public = 1 AND COALESCE(visibility, 'public') = 'public'
- AND quarantined_at IS NULL AND COALESCE(health_status, 'ok') NOT IN ('corrupt', 'zero_byte', 'missing_file', 'needs_review')
- AND file_path IS NOT NULL AND app_id != 'live' AND ${readiness.playableSql('vods.object_id')}`,
- clip: `COALESCE(status, 'ready') = 'ready' AND COALESCE(is_public, 1) = 1 AND COALESCE(visibility, 'public') = 'public'
- AND COALESCE(auto_generated, 0) = 0 AND COALESCE(file_path, '') != '' AND app_id != 'live' AND ${readiness.playableSql('clips.object_id')}`,
+ vod: `COALESCE(vods.is_recording, 0) = 0 AND COALESCE(vods.clips_only, 0) = 0 AND vods.is_public = 1 AND COALESCE(vods.visibility, 'public') = 'public'
+ AND vods.quarantined_at IS NULL AND COALESCE(vods.health_status, 'ok') NOT IN ('corrupt', 'zero_byte', 'missing_file', 'needs_review')
+ AND vods.file_path IS NOT NULL AND vods.app_id != 'live' AND ${readiness.playableSql('vods.object_id')}`,
+ clip: `COALESCE(clips.status, 'ready') = 'ready' AND COALESCE(clips.is_public, 1) = 1 AND COALESCE(clips.visibility, 'public') = 'public'
+ AND COALESCE(clips.auto_generated, 0) = 0 AND COALESCE(clips.file_path, '') != '' AND clips.app_id != 'live' AND ${readiness.playableSql('clips.object_id')}`,
};
};
async function sandboxApps() {
async function sandboxApps() {
@@ -118,13 +119,20 @@ function llmsTxt() {
@@ -118,13 +119,20 @@ function llmsTxt() {
async function indexableWatchRows(limit = MAX_URLS) {
async function indexableWatchRows(limit = MAX_URLS) {
const sandbox = await sandboxApps();
const sandbox = await sandboxApps();
const out = [];
const out = [];
- const base = 'id, app_id, visibility, is_public, title, description, ai_overview, thumbnail_url, file_path, duration_seconds, view_count, created_at';
+ const base = ['id', 'app_id', 'visibility', 'is_public', 'title', 'description', 'ai_overview', 'thumbnail_url', 'file_path', 'duration_seconds', 'view_count', 'created_at'];
+ // Qualify the clip columns: its LEFT JOIN adds the source VOD's own id, app_id and visibility.
+ const cols = (alias, extra = []) => [...base, ...extra].map((c) => `${alias}.${c}`).join(', ');
for (const kind of ['vod', 'clip']) {
for (const kind of ['vod', 'clip']) {
const table = kind === 'vod' ? 'vods' : 'clips';
const table = kind === 'vod' ? 'vods' : 'clips';
// vods has no auto_generated column (only clips do); pages.watchIndexable reads it for clips.
// vods has no auto_generated column (only clips do); pages.watchIndexable reads it for clips.
- const cols = kind === 'clip' ? `${base}, auto_generated` : base;
- const rows = await db.all(`SELECT ${cols} FROM ${table} WHERE ${WATCH_WHERE[kind]} ORDER BY created_at DESC LIMIT ?`, [limit]);
+ // The clip page's source VOD comes from the same read, so rendering a list stays one query.
+ const select = kind === 'clip'
+ ? `${cols('clips', ['auto_generated'])}, ${sourceVod.COLUMNS}`
+ : cols('vods');
+ const from = kind === 'clip' ? 'clips LEFT JOIN vods sv ON sv.id = clips.vod_id' : table;
+ const rows = await db.all(`SELECT ${select} FROM ${from} WHERE ${WATCH_WHERE[kind]} ORDER BY ${table}.created_at DESC LIMIT ?`, [limit]);
for (const row of rows) {
for (const row of rows) {
+ if (kind === 'clip') sourceVod.attach(row);
if (sandbox.has(row.app_id) || !pages.watchIndexable(kind, row)) continue;
if (sandbox.has(row.app_id) || !pages.watchIndexable(kind, row)) continue;
out.push({ kind, row });
out.push({ kind, row });
}
}
@@ -66,6 +66,23 @@ function watchIndexable(kind, record) {
@@ -66,6 +66,23 @@ function watchIndexable(kind, record) {
return visibility === 'public' && !ownedByLive && !isAiClip(kind, record);
return visibility === 'public' && !ownedByLive && !isAiClip(kind, record);
}
}
+/**
+ * The source VOD link for an AI clip, or null. `record.source_vod` is read with the clip
+ * (server/public/source-vod.js): it must be a row /v/:id answers 200 for — not private, not a
+ * clips-only shell, finished and playable — and not missing. Without that evidence no link is shown:
+ * a private or unread VOD used to be linked anyway and answered 404.
+ */
+function sourceVodHref(record) {
+ const source = record && record.source_vod;
+ if (!source) return null;
+ const visibility = source.visibility || (Number(source.is_public) ? 'public' : 'private');
+ if (visibility === 'private') return null;
+ if (Number(source.clips_only || 0) || Number(source.is_recording || 0)) return null;
+ if (!source.playable) return null;
+ const id = source.id != null ? source.id : record.vod_id;
+ return id != null ? `${config.publicUrl}/v/${id}` : null;
+}
+
/**
/**
* What the watch page says instead of a player when the item is not playable (readiness.reason,
* What the watch page says instead of a player when the item is not playable (readiness.reason,
* server/objects/readiness.js): [heading, detail].
* server/objects/readiness.js): [heading, detail].
@@ -112,7 +129,7 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -112,7 +129,7 @@ function renderWatchPage(kind, record, readiness = null) {
const playable = !readiness || !!readiness.playable;
const playable = !readiness || !!readiness.playable;
// The owning app has the canonical page; unlisted items, AI clips and pages without a player never index.
// The owning app has the canonical page; unlisted items, AI clips and pages without a player never index.
const robots = playable && watchIndexable(kind, record) ? 'index, follow' : 'noindex, follow';
const robots = playable && watchIndexable(kind, record) ? 'index, follow' : 'noindex, follow';
- const sourceVod = ai && record.vod_id ? `${config.publicUrl}/v/${record.vod_id}` : null;
+ const sourceVod = ai && record.vod_id ? sourceVodHref(record) : null;
const mime = VIDEO_MIME[path.extname(record.file_path || '').toLowerCase()] || 'video/mp4';
const mime = VIDEO_MIME[path.extname(record.file_path || '').toLowerCase()] || 'video/mp4';
const uploadDate = isoDate(record.created_at);
const uploadDate = isoDate(record.created_at);
const duration = isoDuration(record.duration_seconds);
const duration = isoDuration(record.duration_seconds);
@@ -32,6 +32,7 @@ const db = require('../db/database');
@@ -32,6 +32,7 @@ const db = require('../db/database');
const tools = require('../vod/media-tools');
const tools = require('../vod/media-tools');
const { optionalIdentity } = require('../auth');
const { optionalIdentity } = require('../auth');
const pages = require('./pages');
const pages = require('./pages');
+const sourceVod = require('./source-vod');
// A restore drill (MEDIA_DRILL) serves no stored bytes: every byte route answers 503 before it looks
// A restore drill (MEDIA_DRILL) serves no stored bytes: every byte route answers 503 before it looks
// at a file path (the database's paths are production's files). Watch pages still render.
// at a file path (the database's paths are production's files). Watch pages still render.
const drill = require('../drill');
const drill = require('../drill');
@@ -393,10 +394,14 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
@@ -393,10 +394,14 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
router.get('/c/:id', optionalIdentity, async (req, res) => {
router.get('/c/:id', optionalIdentity, async (req, res) => {
try {
try {
if (!/^\d+$/.test(req.params.id)) return notFound(res);
if (!/^\d+$/.test(req.params.id)) return notFound(res);
- const clip = await db.getClipById(parseInt(req.params.id, 10));
+ const wantsPage = pages.wantsHtmlPage(req);
+ // The page needs its source VOD's state beside the clip; the bytes path keeps the plain read.
+ const clip = wantsPage
+ ? await sourceVod.clipForPage(parseInt(req.params.id, 10))
+ : await db.getClipById(parseInt(req.params.id, 10));
// A clip still being cut (or whose cut failed) has no file yet: its bytes are a 404, and a
// A clip still being cut (or whose cut failed) has no file yet: its bytes are a 404, and a
// browser navigation gets the watch page saying so (private clips still look missing).
// browser navigation gets the watch page saying so (private clips still look missing).
- if (!clip || (!clip.file_path && clip.storage_provider !== 'timeline' && !pages.wantsHtmlPage(req))) return notFound(res);
+ if (!clip || (!clip.file_path && clip.storage_provider !== 'timeline' && !wantsPage)) return notFound(res);
await serveMediaRecord('clip', clip, req, res);
await serveMediaRecord('clip', clip, req, res);
} catch (err) {
} catch (err) {
console.error('[Public] /c error:', err.message);
console.error('[Public] /c error:', err.message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+/**
+ * OpenVibe.Media — a clip page's source VOD, read with the clip.
+ *
+ * A clip cut from a stream links back to the VOD it came from, but only while that VOD's own page
+ * (/v/:id) can answer 200: the row exists, it is not a clips-only shell, it is not private, and its
+ * object is playable (server/objects/readiness.js). Reading the decision as joined columns keeps the
+ * page at one query, and a list of clips at one query too.
+ *
+ * `source_vod` is attached to the clip row as { id, visibility, is_public, clips_only, is_recording,
+ * playable }; pages.renderWatchPage() never links a clip whose source VOD is missing.
+ */
+'use strict';
+
+const db = require('../db/database');
+const readiness = require('../objects/readiness');
+
+/** Columns a query selects beside the clip's own: the source VOD under the alias `sv`. */
+const COLUMNS = `
+ sv.id AS source_vod_id,
+ sv.visibility AS source_vod_visibility,
+ sv.is_public AS source_vod_is_public,
+ COALESCE(sv.clips_only, 0) AS source_vod_clips_only,
+ COALESCE(sv.is_recording, 0) AS source_vod_is_recording,
+ ${readiness.playableSql('sv.object_id')} AS source_vod_playable`;
+
+/** Put the joined source-VOD columns on the row as one object (null when the clip has no source). */
+function attach(row) {
+ if (!row) return row;
+ row.source_vod = row.source_vod_id == null ? null : {
+ id: row.source_vod_id,
+ visibility: row.source_vod_visibility,
+ is_public: row.source_vod_is_public,
+ clips_only: row.source_vod_clips_only,
+ is_recording: row.source_vod_is_recording,
+ playable: !!row.source_vod_playable,
+ };
+ return row;
+}
+
+/** One clip with its source VOD's linkability, for /c/:id's page (the bytes path reads the clip alone). */
+async function clipForPage(id) {
+ return attach(await db.get(`SELECT c.*, ${COLUMNS}
+ FROM clips c LEFT JOIN vods sv ON sv.id = c.vod_id WHERE c.id = ?`, [id]));
+}
+
+module.exports = { COLUMNS, attach, clipForPage };
@@ -28,6 +28,7 @@ const express = require('express');
@@ -28,6 +28,7 @@ const express = require('express');
const db = require('../server/db/database');
const db = require('../server/db/database');
const pages = require('../server/public/pages');
const pages = require('../server/public/pages');
+ const sourceVod = require('../server/public/source-vod');
await db.upsertApp({ app_id: 'live', api_key: 'k1-live-crawl' });
await db.upsertApp({ app_id: 'live', api_key: 'k1-live-crawl' });
await db.upsertApp({ app_id: 'games', api_key: 'k2-games-crawl' });
await db.upsertApp({ app_id: 'games', api_key: 'k2-games-crawl' });
@@ -49,6 +50,11 @@ const express = require('express');
@@ -49,6 +50,11 @@ const express = require('express');
await clip.run(13, 'games', 1, 'Cutting', 1, 'public', 0, 'processing', '2026-09-22 12:00:00');
await clip.run(13, 'games', 1, 'Cutting', 1, 'public', 0, 'processing', '2026-09-22 12:00:00');
await clip.run(14, 'games', 1, 'Unlisted clip', 0, 'unlisted', 0, 'ready', '2026-09-22 13:00:00');
await clip.run(14, 'games', 1, 'Unlisted clip', 0, 'unlisted', 0, 'ready', '2026-09-22 13:00:00');
await clip.run(15, 'live', 4, 'Live clip', 1, 'public', 0, 'ready', '2026-09-22 14:00:00');
await clip.run(15, 'live', 4, 'Live clip', 1, 'public', 0, 'ready', '2026-09-22 14:00:00');
+ // AI clips whose source VOD is private, missing, or public and playable (the page links only the last).
+ await clip.run(16, 'games', 3, 'AI private source', 1, 'public', 1, 'ready', '2026-09-22 15:00:00');
+ await clip.run(17, 'games', 999, 'AI missing source', 1, 'public', 1, 'ready', '2026-09-22 16:00:00');
+ await clip.run(18, 'games', 1, 'AI public source', 1, 'public', 1, 'ready', '2026-09-22 17:00:00');
+ await clip.run(19, 'games', 8, 'AI unverified source', 1, 'public', 1, 'ready', '2026-09-22 18:00:00');
// Public and finished, but its only copy (B2) has never been checked: not playable, never listed.
// Public and finished, but its only copy (B2) has never been checked: not playable, never listed.
await raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, health_status, storage_provider, created_at, duration_seconds) OVERRIDING SYSTEM VALUE
await raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, health_status, storage_provider, created_at, duration_seconds) OVERRIDING SYSTEM VALUE
VALUES (8, 'games', 'Games unverified', '/gone.mp4', 1, 'public', 'ok', 'b2', '2026-09-21 14:00:00', 60) RETURNING id`).run();
VALUES (8, 'games', 'Games unverified', '/gone.mp4', 1, 'public', 'ok', 'b2', '2026-09-21 14:00:00', 60) RETURNING id`).run();
@@ -60,7 +66,7 @@ const express = require('express');
@@ -60,7 +66,7 @@ const express = require('express');
const model = require('../server/objects/model');
const model = require('../server/objects/model');
const readiness = require('../server/objects/readiness');
const readiness = require('../server/objects/readiness');
for (const id of [1, 2, 3, 4, 5, 6, 7, 8]) await model.sync('vod', id);
for (const id of [1, 2, 3, 4, 5, 6, 7, 8]) await model.sync('vod', id);
- for (const id of [11, 12, 13, 14, 15]) await model.sync('clip', id);
+ for (const id of [11, 12, 13, 14, 15, 16, 17, 18, 19]) await model.sync('clip', id);
const app = express();
const app = express();
app.use('/', require('../server/public/routes'));
app.use('/', require('../server/public/routes'));
@@ -148,13 +154,24 @@ const express = require('express');
@@ -148,13 +154,24 @@ const express = require('express');
assert.deepStrictEqual(ldOf(pub).author, { '@type': 'Person', name: 'gamer', url: 'https://openvibe.games/@gamer' });
assert.deepStrictEqual(ldOf(pub).author, { '@type': 'Person', name: 'gamer', url: 'https://openvibe.games/@gamer' });
assert.deepStrictEqual(ldOf(pub).creator, ldOf(pub).author);
assert.deepStrictEqual(ldOf(pub).creator, ldOf(pub).author);
assert.strictEqual(ldOf(pub).isLiveBroadcast, undefined);
assert.strictEqual(ldOf(pub).isLiveBroadcast, undefined);
- const aiHtml = pages.renderWatchPage('clip', { ...await byId('clips', 12), username: 'gamer' });
+ const aiHtml = pages.renderWatchPage('clip', { ...await sourceVod.clipForPage(12), username: 'gamer' });
assert.ok(aiHtml.includes('content="noindex, follow"'), 'AI clips never index');
assert.ok(aiHtml.includes('content="noindex, follow"'), 'AI clips never index');
const aiLd = ldOf(aiHtml);
const aiLd = ldOf(aiHtml);
assert.deepStrictEqual(aiLd.author, aiLd.creator, 'automation is credited to an organization');
assert.deepStrictEqual(aiLd.author, aiLd.creator, 'automation is credited to an organization');
assert.deepStrictEqual(aiLd.creator, { '@type': 'Organization', name: 'OpenVibe AI', url: 'https://openvibe.network' });
assert.deepStrictEqual(aiLd.creator, { '@type': 'Organization', name: 'OpenVibe AI', url: 'https://openvibe.network' });
assert.strictEqual(aiLd.isBasedOn, 'https://media.test/v/1');
assert.strictEqual(aiLd.isBasedOn, 'https://media.test/v/1');
assert.ok(aiHtml.includes('<span>AI clip</span>') && !aiHtml.includes('by gamer'));
assert.ok(aiHtml.includes('<span>AI clip</span>') && !aiHtml.includes('by gamer'));
+ // The source VOD is linked only while /v/:id can answer 200 for it: private, missing and
+ // unverified (no playable copy) ones are not.
+ const nav = { accept: 'text/html', 'sec-fetch-dest': 'document' };
+ for (const [id, linked] of [[16, false], [17, false], [18, true], [19, false]]) {
+ r = await get(`/c/${id}`, nav);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.includes('See the VOD this clip came from'), linked, `clip ${id}: source VOD card`);
+ assert.strictEqual(r.body.includes('"isBasedOn"'), linked, `clip ${id}: source VOD isBasedOn`);
+ }
+ assert.ok(!(await sourceVod.clipForPage(19)).source_vod.playable, 'the joined source VOD is not playable');
+ assert.ok((await get('/c/18', nav)).body.includes('https://media.test/v/1'), 'a public source VOD keeps its URL');
assert.strictEqual(ldOf(pages.renderWatchPage('vod', await byId('vods', 2))), null, 'unlisted pages carry no structured data');
assert.strictEqual(ldOf(pages.renderWatchPage('vod', await byId('vods', 2))), null, 'unlisted pages carry no structured data');
assert.ok(pages.renderWatchPage('clip', await byId('clips', 15)).includes('content="noindex, follow"'), 'Live-owned: canonical on Live');
assert.ok(pages.renderWatchPage('clip', await byId('clips', 15)).includes('content="noindex, follow"'), 'Live-owned: canonical on Live');
r = await get('/c/12', { accept: 'text/html', 'sec-fetch-dest': 'document' });
r = await get('/c/12', { accept: 'text/html', 'sec-fetch-dest': 'document' });
@@ -106,11 +106,24 @@ const http = require('http');
@@ -106,11 +106,24 @@ const http = require('http');
assert.deepStrictEqual(checkCards(pages.renderWatchPage('clip', { ...vod, id: 9 }), 'Live clip'),
assert.deepStrictEqual(checkCards(pages.renderWatchPage('clip', { ...vod, id: 9 }), 'Live clip'),
['https://openvibe.video/c/9?ov_from=media', 'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media']);
['https://openvibe.video/c/9?ov_from=media', 'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media']);
// An AI clip with a source VOD: every card applies, and four is the most that render.
// An AI clip with a source VOD: every card applies, and four is the most that render.
- const aiClip = pages.renderWatchPage('clip', { ...vod, id: 10, auto_generated: 1, vod_id: 42 });
+ const sourceVod = (fields = {}) => ({ id: 42, visibility: 'public', is_public: 1, clips_only: 0, is_recording: 0, playable: true, ...fields });
+ const aiClip = pages.renderWatchPage('clip', { ...vod, id: 10, auto_generated: 1, vod_id: 42, source_vod: sourceVod() });
assert.deepStrictEqual(checkCards(aiClip, 'AI clip'), ['https://openvibe.video/c/10?ov_from=media', 'https://media.test/v/42?ov_from=media',
assert.deepStrictEqual(checkCards(aiClip, 'AI clip'), ['https://openvibe.video/c/10?ov_from=media', 'https://media.test/v/42?ov_from=media',
'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media'], 'a clip with a source VOD shows the VOD card');
'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media'], 'a clip with a source VOD shows the VOD card');
assert.ok(aiClip.includes('See the VOD this clip came from'));
assert.ok(aiClip.includes('See the VOD this clip came from'));
assert.strictEqual((aiClip.match(/class="sc-next-item"/g) || []).length, 4, 'at most four cards');
assert.strictEqual((aiClip.match(/class="sc-next-item"/g) || []).length, 4, 'at most four cards');
+ // A source VOD that /v/:id would not answer 200 for: no link card and no isBasedOn.
+ const noSource = (source) => pages.renderWatchPage('clip', { ...vod, id: 12, auto_generated: 1, vod_id: 42, ...(source === undefined ? {} : { source_vod: source }) });
+ for (const [label, page] of [
+ ['private', noSource(sourceVod({ visibility: 'private', is_public: 0 }))],
+ ['missing', noSource(undefined)],
+ ['still recording', noSource(sourceVod({ is_recording: 1 }))],
+ ['not playable', noSource(sourceVod({ playable: false }))],
+ ]) {
+ assert.ok(!page.includes('See the VOD this clip came from'), `${label} source VOD shows no link card`);
+ assert.ok(!page.includes('"isBasedOn"'), `${label} source VOD is not isBasedOn`);
+ assert.ok(!checkCards(page, label).some((href) => /\/v\/42/.test(href)), `${label} source VOD adds no card`);
+ }
// A clip without a player offers no clip-cutting card.
// A clip without a player offers no clip-cutting card.
const pending = pages.renderWatchPage('clip', { ...vod, id: 11 }, { playable: false, reason: 'processing' });
const pending = pages.renderWatchPage('clip', { ...vod, id: 11 }, { playable: false, reason: 'processing' });
assert.ok(!cards(pending).includes('https://openvibe.live/?ov_from=media'), 'no "go live" card without a player');
assert.ok(!cards(pending).includes('https://openvibe.live/?ov_from=media'), 'no "go live" card without a player');