openvibers/openvibe.live Commits
Link recordings and clips at their watch id, YouTube's shape (#676)
Changes
@@@ -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)),@@@ -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),@@@ -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({@@@ -202,8 +202,8 @@ async function _pageMeta(routePath, { page = 1, query = null } = {}) {// Homeif (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) {@@@ -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',@@@ -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}`;}@@@ -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');@@@ -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');
@@ -123,7 +123,7 @@ async function vodItem(v) {
@@ -123,7 +123,7 @@ async function vodItem(v) {
if (banned(owner)) return null;
if (banned(owner)) return null;
// Content is what people made: the card carries no AI overview (the VOD page still has it).
// Content is what people made: the card carries no AI overview (the VOD page still has it).
return {
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',
title: clean(v.title, 140) || 'Stream recording',
created_at: iso(v.created_at), views: num(v.view_count),
created_at: iso(v.created_at), views: num(v.view_count),
duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
@@ -146,7 +146,7 @@ async function clipItem(c, { ai }) {
@@ -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 { /* */ } }
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;
const by = !ai && creator && channelOwner && creator.id !== channelOwner.id ? person(creator) : null;
return {
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',
title: clean(c.title, 140) || 'Clip',
created_at: iso(c.created_at), views: num(c.view_count),
created_at: iso(c.created_at), views: num(c.view_count),
duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
@@ -80,7 +80,7 @@ async function documentFor(kind, row) {
@@ -80,7 +80,7 @@ async function documentFor(kind, row) {
const doc = {
const doc = {
owner: 'live', type: kind, id: String(row.id), deleted: false, visibility: 'public',
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.
// 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),
title: clean(row.title || fallbackTitle, 500),
summary,
summary,
body: [clean(row.description, 4000), clean(ai.overview, 8000), ai.transcript].filter(Boolean).join('\n').slice(0, 40000),
body: [clean(row.description, 4000), clean(ai.overview, 8000), ai.transcript].filter(Boolean).join('\n').slice(0, 40000),
@@ -99,10 +99,10 @@ async function heroMedia() {
@@ -99,10 +99,10 @@ async function heroMedia() {
try {
try {
const pool = await _mediaContent();
const pool = await _mediaContent();
for (const v of pool.vods) {
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) {
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) {
for (const p of pool.pastes) {
items.push({
items.push({
@@ -202,8 +202,8 @@ async function _pageMeta(routePath, { page = 1, query = null } = {}) {
@@ -202,8 +202,8 @@ async function _pageMeta(routePath, { page = 1, query = null } = {}) {
// Home
// Home
if (p === '/') return await _homeMeta();
if (p === '/') return await _homeMeta();
// List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)
// 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 === '/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 === '/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 })));
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) {
@@ -321,7 +321,7 @@ async function _channelMeta(username, page = 1) {
if (vods.length) {
if (vods.length) {
ld.push({
ld.push({
'@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s videos${page > 1 ? `, page ${page}` : ''}`,
'@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 }] : [])]));
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) {
@@ -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 += `<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) => ({
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(' · '),
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.'));
})), 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 (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) {
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>`);
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) {
if (acct.followLinks.length) {
@@ -564,8 +564,8 @@ async function _homeMeta() {
@@ -564,8 +564,8 @@ async function _homeMeta() {
const ownerName = async (row, id) => row.display_name || row.username || _nameOf(await _channelOwner(id));
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 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 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>` : '';
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) {
@@ -692,7 +692,7 @@ async function _sourceMoment(vodId, seconds) {
if (!v || !_pub(v)) return null;
if (!v || !_pub(v)) return null;
const t = Math.max(0, Math.floor(Number(seconds) || 0));
const t = Math.max(0, Math.floor(Number(seconds) || 0));
// The moment on the recording's canonical page, OpenVibe.Video (web/video-url.js).
// 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) {
async function _pasteMeta(slug) {
@@ -71,7 +71,7 @@ function publicBenchPoll(row) {
@@ -71,7 +71,7 @@ function publicBenchPoll(row) {
// storage keys, owner subjects, AI state). Copied through an allow-list, so a field a source adds
// 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.
// later never reaches the public feed by accident.
const FEED_ITEM_FIELDS = {
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',
views: 'number', likes: 'number', duration_seconds: 'number',
thumbnail_url: 'string', preview_url: 'string', image_url: 'string',
thumbnail_url: 'string', preview_url: 'string', image_url: 'string',
excerpt: 'string', paste_type: 'string', language: 'string', nsfw: 'boolean',
excerpt: 'string', paste_type: 'string', language: 'string', nsfw: 'boolean',
@@ -8,15 +8,23 @@
@@ -8,15 +8,23 @@
* videoUrl('vod', 12) → 'https://openvibe.video/v/12'
* videoUrl('vod', 12) → 'https://openvibe.video/v/12'
* videoUrl('clip', 7) → 'https://openvibe.video/c/7'
* videoUrl('clip', 7) → 'https://openvibe.video/c/7'
* videoUrl('vod', 12, 95) → 'https://openvibe.video/v/12?t=95' (a moment)
* 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).
* VIDEO_PUBLIC_URL overrides the origin (development, a restore drill).
*/
*/
const BASE = String(process.env.VIDEO_PUBLIC_URL || 'https://openvibe.video').replace(/\/+$/, '');
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);
const n = Number(id);
if (!Number.isSafeInteger(n) || n <= 0) return null;
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}`;
return `${BASE}/${kind === 'clip' ? 'c' : 'v'}/${n}${at}`;
}
}
@@ -20,4 +20,9 @@ assert.match(status, /\/vod\/:id\s+\/clip\/:id\s+a numeric id 301s to OpenVibe\.
@@ -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');
const { videoUrl } = require('../server/web/video-url');
assert.strictEqual(videoUrl('vod', 12), 'https://openvibe.video/v/12');
assert.strictEqual(videoUrl('vod', 12), 'https://openvibe.video/v/12');
assert.strictEqual(videoUrl('clip', 12), 'https://openvibe.video/c/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');
console.log('agent stable ids: ok');
@@ -48,7 +48,7 @@ let VODS = [], CLIPS = [];
@@ -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 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 });
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 = [
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(5, 90, { visibility: 'private', is_public: false, title: 'SECRET VOD' }),
vod(6, 95, { visibility: 'unlisted', is_public: false, title: 'UNLISTED VOD' }),
vod(6, 95, { visibility: 'unlisted', is_public: false, title: 'UNLISTED VOD' }),
vod(7, 100, { user_id: 9, title: 'Banned streamer VOD' }),
vod(7, 100, { user_id: 9, title: 'Banned streamer VOD' }),
@@ -204,9 +204,11 @@ server.on('listening', async () => {
@@ -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');
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');
const r = await get('/api/content/feed?limit=30');
assert.deepStrictEqual(findSecrets(r.json), []);
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}`);
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');
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.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(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');
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');