openvibers/openvibe.live Commits

Link recordings and clips at their watch id, YouTube's shape (#676)

OP OpenVibers committed 6 days ago · f72081c371 · 8 files, +34 −19

Video addresses a watch page as /watch?v=<watch id> (Media mints one for
every VOD and clip). Live's videoUrl() now takes the row's watch id and builds
that address, with &t=<seconds> for a moment; a row without one keeps the
numbered address, which Video still answers and redirects.

The Content/Moments feed carries watch_id and links the watch page, so the
menu card, Video's sitemap and IndexNow all name it. Search documents use it
as the canonical URL, and the home and SEO pages link it where the row has
one. The feed's public allow-list gains watch_id.

Tests: videoUrl's watch-id shape (with and without a moment, a malformed id
ignored), and the feed item's watch_id + href.

Changes

ddiff --git a/server/content/feed.js b/server/content/feed.js
iindex 0e6bd9c8..6779ce15 100644
---- a/server/content/feed.js
++++ b/server/content/feed.js
@@@ -123,7 +123,7 @@ async function vodItem(v) {
if (banned(owner)) return null;
// Content is what people made: the card carries no AI overview (the VOD page still has it).
return {
-- kind: 'vod', id: Number(v.id), href: videoUrl('vod', v.id),
++ kind: 'vod', id: Number(v.id), watch_id: v.watch_id || null, href: videoUrl('vod', v.id, null, v.watch_id),
title: clean(v.title, 140) || 'Stream recording',
created_at: iso(v.created_at), views: num(v.view_count),
duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
@@@ -146,7 +146,7 @@ async function clipItem(c, { ai }) {
if (ai) { try { const s = await db.getClipAiState(c.id); summary = s && (s.ai_overview_short || s.ai_overview); } catch { /* */ } }
const by = !ai && creator && channelOwner && creator.id !== channelOwner.id ? person(creator) : null;
return {
-- kind: 'clip', id: Number(c.id), href: videoUrl('clip', c.id),
++ kind: 'clip', id: Number(c.id), watch_id: c.watch_id || null, href: videoUrl('clip', c.id, null, c.watch_id),
title: clean(c.title, 140) || 'Clip',
created_at: iso(c.created_at), views: num(c.view_count),
duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
ddiff --git a/server/events/search-media-documents.js b/server/events/search-media-documents.js
iindex 3e210161..5b3baedc 100644
---- a/server/events/search-media-documents.js
++++ b/server/events/search-media-documents.js
@@@ -80,7 +80,7 @@ async function documentFor(kind, row) {
const doc = {
owner: 'live', type: kind, id: String(row.id), deleted: false, visibility: 'public',
// The canonical page is OpenVibe.Video's (web/video-url.js), as Live's own page says.
-- canonical_url: videoUrl(kind, row.id),
++ canonical_url: videoUrl(kind, row.id, null, row.watch_id),
title: clean(row.title || fallbackTitle, 500),
summary,
body: [clean(row.description, 4000), clean(ai.overview, 8000), ai.transcript].filter(Boolean).join('\n').slice(0, 40000),
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 89c98db1..a77874e6 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -99,10 +99,10 @@ async function heroMedia() {
try {
const pool = await _mediaContent();
for (const v of pool.vods) {
-- items.push({ kind: 'vod', title: v.title || 'VOD', thumbnail: media.publicUrl(v.thumbnail_url) || media.thumbUrl(`vod-${v.id}`), href: videoUrl('vod', v.id), username: v.username });
++ items.push({ kind: 'vod', title: v.title || 'VOD', thumbnail: media.publicUrl(v.thumbnail_url) || media.thumbUrl(`vod-${v.id}`), href: videoUrl('vod', v.id, null, v.watch_id), username: v.username });
}
for (const c of pool.clips) {
-- items.push({ kind: 'clip', title: c.title || 'Clip', thumbnail: media.publicUrl(c.thumbnail_url) || media.thumbUrl(`clip-${c.id}`), href: videoUrl('clip', c.id), username: c.username });
++ items.push({ kind: 'clip', title: c.title || 'Clip', thumbnail: media.publicUrl(c.thumbnail_url) || media.thumbUrl(`clip-${c.id}`), href: videoUrl('clip', c.id, null, c.watch_id), username: c.username });
}
for (const p of pool.pastes) {
items.push({
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 6aa046e9..9b2ebd62 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -202,8 +202,8 @@ async function _pageMeta(routePath, { page = 1, query = null } = {}) {
// Home
if (p === '/') return await _homeMeta();
// List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)
-- if (p === '/vods') return await _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: videoUrl('vod', v.id), name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));
-- if (p === '/clips') return await _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: videoUrl('clip', c.id), name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
++ if (p === '/vods') return await _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: videoUrl('vod', v.id, null, v.watch_id), name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));
++ if (p === '/clips') return await _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: videoUrl('clip', c.id, null, c.watch_id), name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
if (p === '/content') return await _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', async () => await _feedPage('content', page), { page });
if (p === '/moments') return await _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', async () => await _feedPage('moments', page), { page });
if (p === '/pastes') return await _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));
@@@ -321,7 +321,7 @@ async function _channelMeta(username, page = 1) {
if (vods.length) {
ld.push({
'@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,
-- itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: videoUrl('vod', v.id), name: clean(v.title || 'Stream recording', 110) })),
++ itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: videoUrl('vod', v.id, null, v.watch_id), name: clean(v.title || 'Stream recording', 110) })),
});
}
ld.push(_breadcrumb([{ name: 'Home', url: '/' }, { name, url: basePath }, ...(page > 1 ? [{ name: `Videos, page ${page}`, url: canonicalPath }] : [])]));
@@@ -340,13 +340,13 @@ async function _channelMeta(username, page = 1) {
body += `<section><h2>AI overview</h2><p class="ai-disclosure">Written by OpenVibe's AI from ${esc(name)}'s past streams.</p><p>${esc(clean(ov.overview, 1200))}</p></section>`;
}
body += _itemSection(page > 1 ? `Videos, page ${page}` : 'Videos', vods.map((v) => ({
-- url: videoUrl('vod', v.id), name: v.title || 'Stream recording',
++ url: videoUrl('vod', v.id, null, v.watch_id), name: v.title || 'Stream recording',
meta: [_fmtDur(v.duration_seconds ?? v.duration), _day(v.created_at)].filter(Boolean).join(' · '),
})), vods.length || page > 1 ? null : (track.partial ? null : 'No videos yet.'));
if (vods.length || page > 1) body += _pager(basePath, page, pages, vods.length === CHANNEL_PAGE_SIZE, 'videos');
-- if (clips.length) body += _itemSection('Clips', (await Promise.all(clips.map(async (c) => ({ url: videoUrl('clip', c.id), name: c.title || 'Clip', meta: [await _clipperOf(c) ? `clipped by ${await _clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })))));
++ if (clips.length) body += _itemSection('Clips', (await Promise.all(clips.map(async (c) => ({ url: videoUrl('clip', c.id, null, c.watch_id), name: c.title || 'Clip', meta: [await _clipperOf(c) ? `clipped by ${await _clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })))));
if (aiClips.length) {
-- body += _itemSection('AI Moments', aiClips.map((c) => ({ url: videoUrl('clip', c.id), name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),
++ body += _itemSection('AI Moments', aiClips.map((c) => ({ url: videoUrl('clip', c.id, null, c.watch_id), name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),
null, `<p class="ai-disclosure">Clips OpenVibe's AI cut from ${esc(name)}'s streams when chat reacted. No one clipped these.</p>`);
}
if (acct.followLinks.length) {
@@@ -564,8 +564,8 @@ async function _homeMeta() {
const ownerName = async (row, id) => row.display_name || row.username || _nameOf(await _channelOwner(id));
const liveItems = live.map(s => ({ url: `/@${s.username}`, name: s.title || `${s.display_name || s.username} live`, by: s.display_name || s.username, meta: s.category || 'live' }));
-- const vodItems = (await Promise.all(vods.map(async v => ({ url: videoUrl('vod', v.id), name: v.title || 'VOD', by: await ownerName(v, v.user_id), meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }))));
-- const clipItems = (await Promise.all(clips.map(async c => ({ url: videoUrl('clip', c.id), name: c.title || 'Clip', by: await ownerName(c, c.user_id), meta: _fmtDur(c.duration_seconds || c.duration) }))));
++ const vodItems = (await Promise.all(vods.map(async v => ({ url: videoUrl('vod', v.id, null, v.watch_id), name: v.title || 'VOD', by: await ownerName(v, v.user_id), meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }))));
++ const clipItems = (await Promise.all(clips.map(async c => ({ url: videoUrl('clip', c.id, null, c.watch_id), name: c.title || 'Clip', by: await ownerName(c, c.user_id), meta: _fmtDur(c.duration_seconds || c.duration) }))));
const pasteItems = pastes.map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text') }));
const statLine = stats ? `<p>${SITE_NAME} hosts ${stats.streamers || 0} streamers, ${stats.vods || 0} VODs, ${stats.clips || 0} clips, ${stats.pastes || 0} pastes and ${stats.chatMessages || 0} chat messages.</p>` : '';
@@@ -692,7 +692,7 @@ async function _sourceMoment(vodId, seconds) {
if (!v || !_pub(v)) return null;
const t = Math.max(0, Math.floor(Number(seconds) || 0));
// The moment on the recording's canonical page, OpenVibe.Video (web/video-url.js).
-- return { path: videoUrl('vod', vodId, t), vod: v };
++ return { path: videoUrl('vod', vodId, t, v.watch_id), vod: v };
}
async function _pasteMeta(slug) {
ddiff --git a/server/web/serializers.js b/server/web/serializers.js
iindex b6488799..6706b1d2 100644
---- a/server/web/serializers.js
++++ b/server/web/serializers.js
@@@ -71,7 +71,7 @@ function publicBenchPoll(row) {
// storage keys, owner subjects, AI state). Copied through an allow-list, so a field a source adds
// later never reaches the public feed by accident.
const FEED_ITEM_FIELDS = {
-- kind: 'string', id: 'id', href: 'string', title: 'string', created_at: 'string',
++ kind: 'string', id: 'id', watch_id: 'string', href: 'string', title: 'string', created_at: 'string',
views: 'number', likes: 'number', duration_seconds: 'number',
thumbnail_url: 'string', preview_url: 'string', image_url: 'string',
excerpt: 'string', paste_type: 'string', language: 'string', nsfw: 'boolean',
ddiff --git a/server/web/video-url.js b/server/web/video-url.js
iindex 5d66d95f..d3fcfd20 100644
---- a/server/web/video-url.js
++++ b/server/web/video-url.js
@@@ -8,15 +8,23 @@
* videoUrl('vod', 12) → 'https://openvibe.video/v/12'
* videoUrl('clip', 7) → 'https://openvibe.video/c/7'
* videoUrl('vod', 12, 95) → 'https://openvibe.video/v/12?t=95' (a moment)
++ * videoUrl('vod', 12, null, 'dQw4w9WgXcQ') → 'https://openvibe.video/watch?v=dQw4w9WgXcQ'
++ * videoUrl('vod', 12, 95, 'dQw4w9WgXcQ') → 'https://openvibe.video/watch?v=dQw4w9WgXcQ&t=95'
++ *
++ * A row's watch id (Media mints one for every VOD and clip) names the page in YouTube's shape; a row without one
++ * keeps the numbered address, which Video still answers and redirects to the watch page.
*
* VIDEO_PUBLIC_URL overrides the origin (development, a restore drill).
*/
const BASE = String(process.env.VIDEO_PUBLIC_URL || 'https://openvibe.video').replace(/\/+$/, '');
++const WATCH_ID_RE = /^[A-Za-z0-9_-]{11}$/;
--function videoUrl(kind, id, t = null) {
++function videoUrl(kind, id, t = null, watchId = null) {
const n = Number(id);
if (!Number.isSafeInteger(n) || n <= 0) return null;
-- const at = t != null && Number.isFinite(Number(t)) && Number(t) > 0 ? `?t=${Math.floor(Number(t))}` : '';
++ const watch = watchId && WATCH_ID_RE.test(String(watchId)) ? String(watchId) : null;
++ const at = t != null && Number.isFinite(Number(t)) && Number(t) > 0 ? `${watch ? '&' : '?'}t=${Math.floor(Number(t))}` : '';
++ if (watch) return `${BASE}/watch?v=${watch}${at}`;
return `${BASE}/${kind === 'clip' ? 'c' : 'v'}/${n}${at}`;
}
ddiff --git a/test/agent-stable-ids.test.js b/test/agent-stable-ids.test.js
iindex 49ba2fa8..b0c066e7 100644
---- a/test/agent-stable-ids.test.js
++++ b/test/agent-stable-ids.test.js
@@@ -20,4 +20,9 @@ assert.match(status, /\/vod\/:id\s+\/clip\/:id\s+a numeric id 301s to OpenVibe\.
const { videoUrl } = require('../server/web/video-url');
assert.strictEqual(videoUrl('vod', 12), 'https://openvibe.video/v/12');
assert.strictEqual(videoUrl('clip', 12), 'https://openvibe.video/c/12');
++// A row's watch id (Media mints one for every VOD and clip) names the page in YouTube's shape, the moment after it.
++assert.strictEqual(videoUrl('vod', 12, null, 'dQw4w9WgXcQ'), 'https://openvibe.video/watch?v=dQw4w9WgXcQ');
++assert.strictEqual(videoUrl('vod', 12, 95, 'dQw4w9WgXcQ'), 'https://openvibe.video/watch?v=dQw4w9WgXcQ&t=95');
++assert.strictEqual(videoUrl('clip', 12, null, 'dQw4w9WgXcQ'), 'https://openvibe.video/watch?v=dQw4w9WgXcQ');
++assert.strictEqual(videoUrl('vod', 12, null, 'too-short'), 'https://openvibe.video/v/12', 'a malformed id is not used');
console.log('agent stable ids: ok');
ddiff --git a/test/content-feed.test.js b/test/content-feed.test.js
iindex c11bb90d..94040e47 100644
---- a/test/content-feed.test.js
++++ b/test/content-feed.test.js
@@@ -48,7 +48,7 @@ let VODS = [], CLIPS = [];
const vod = (id, mins, extra = {}) => ({ id, user_id: 3, title: `VOD ${id}`, visibility: 'public', is_public: true, status: 'ready', duration_seconds: 600, view_count: 0, created_at: at(mins), thumbnail_url: `/t/vod-${id}.jpg`, file_path: `vod-${id}.webm`, storage_key: 'secret/key', ...extra });
const clip = (id, mins, extra = {}) => ({ id, user_id: 4, channel_user_id: 3, vod_id: 1, title: `Clip ${id}`, visibility: 'public', is_public: true, status: 'ready', auto_generated: false, duration_seconds: 20, view_count: 0, created_at: at(mins), thumbnail_url: `/t/clip-${id}.jpg`, playback_url: `https://media.test/c/${id}`, file_path: `clip-${id}.webm`, ...extra });
VODS = [
-- vod(1, 10, { view_count: 50 }), vod(2, 30, { view_count: 5, audio_only: true }), vod(3, 50, { view_count: 500 }), vod(4, 70),
++ vod(1, 10, { view_count: 50, watch_id: 'dQw4w9WgXcQ' }), vod(2, 30, { view_count: 5, audio_only: true }), vod(3, 50, { view_count: 500 }), vod(4, 70),
vod(5, 90, { visibility: 'private', is_public: false, title: 'SECRET VOD' }),
vod(6, 95, { visibility: 'unlisted', is_public: false, title: 'UNLISTED VOD' }),
vod(7, 100, { user_id: 9, title: 'Banned streamer VOD' }),
@@@ -204,9 +204,11 @@ server.on('listening', async () => {
assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && c.act.liveUserId == null), 'Community is read as nobody in particular');
const r = await get('/api/content/feed?limit=30');
assert.deepStrictEqual(findSecrets(r.json), []);
-- const allowed = new Set(['key', 'kind', 'id', 'href', 'title', 'created_at', 'views', 'likes', 'duration_seconds', 'thumbnail_url', 'preview_url', 'image_url', 'excerpt', 'paste_type', 'language', 'nsfw', 'ai', 'ai_label', 'moment_href', 'grade', 'stream_title', 'audio_only', 'channel', 'by']);
++ const allowed = new Set(['key', 'kind', 'id', 'watch_id', 'href', 'title', 'created_at', 'views', 'likes', 'duration_seconds', 'thumbnail_url', 'preview_url', 'image_url', 'excerpt', 'paste_type', 'language', 'nsfw', 'ai', 'ai_label', 'moment_href', 'grade', 'stream_title', 'audio_only', 'channel', 'by']);
for (const it of r.json.items) for (const k of Object.keys(it)) assert.ok(allowed.has(k), `unexpected field ${k} on ${it.key}`);
const v1 = r.json.items.find((i) => i.key === 'vod:1');
++ assert.strictEqual(v1.watch_id, 'dQw4w9WgXcQ', 'the card carries the watch id');
++ assert.strictEqual(v1.href, 'https://openvibe.video/watch?v=dQw4w9WgXcQ', 'and links the watch page, not the number');
assert.strictEqual(v1.thumbnail_url, `${media.MEDIA_PUBLIC_URL}/t/vod-1.jpg`, 'Media paths become absolute');
assert.strictEqual(v1.audio_only, false, 'a picture recording says so');
assert.strictEqual((await get('/api/content/feed?type=vods&limit=30')).json.items.find((i) => i.key === 'vod:2').audio_only, true, 'an audio-only recording carries the flag to the card');