openvibers/openvibe.media Commits

Clip pages link a source VOD only when /v/:id can serve it (#261)

OP OpenVibers committed 12 days ago · 026c10a6f5 · 6 files, +120 −14

Changes

ddiff --git a/server/public/crawl.js b/server/public/crawl.js
iindex 79a9c6d..d8440b7 100644
---- a/server/public/crawl.js
++++ b/server/public/crawl.js
@@@ -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 });
}
ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex 599e758..b90f4c9 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -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);
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex eb8697b..abf8096 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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);
ddiff --git a/server/public/source-vod.js b/server/public/source-vod.js
nnew file mode 100644
iindex 0000000..8148b18
---- /dev/null
++++ b/server/public/source-vod.js
@@@ -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 };
ddiff --git a/test/crawl.test.js b/test/crawl.test.js
iindex 97eead2..fdfe108 100644
---- a/test/crawl.test.js
++++ b/test/crawl.test.js
@@@ -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 VALUE
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');
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' });
ddiff --git a/test/public-pages.test.js b/test/public-pages.test.js
iindex f93943f..ad38864 100644
---- a/test/public-pages.test.js
++++ b/test/public-pages.test.js
@@@ -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');