openvibers/openvibe.live Commits

Watch addresses for the episode, recap and agent recording links (#803)

OP OpenVibers committed 2 days ago · 61c429eb8c · 10 files, +40 −20

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex cc19bf59..284e8b91 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -143,7 +143,7 @@ From the dashboard:
## Recordings and clips are watched on OpenVibe.Video
--Every recording and clip has its page on OpenVibe.Video (`https://openvibe.video/v/<id>`, `/c/<id>`;
++Every recording and clip has its page on OpenVibe.Video (`https://openvibe.video/watch?v=<watch_id>`; the older `/v/<id>` and `/c/<id>` answer 301 there;
`server/web/video-url.js`, `VIDEO_PUBLIC_URL` overrides the origin): the player, AI chapters and captions, the chat
replay, comments, clipping, and the owner's visibility and delete, for private items too (to their owner). The
captions name what was heard as well as what was said: `GET /api/vods/:id/sounds` gives cues like "[laughter]",
ddiff --git a/public/js/agent-stream.js b/public/js/agent-stream.js
iindex 2db38927..52086d00 100644
---- a/public/js/agent-stream.js
++++ b/public/js/agent-stream.js
@@@ -220,7 +220,8 @@
section.hidden = false;
const list = $('agent-chapter-list');
list.replaceChildren();
-- const vodBase = out.vod_id ? `https://openvibe.video/v/${encodeURIComponent(out.vod_id)}` : null;
++ const watch = /^[A-Za-z0-9_-]{11}$/.test(String(out.watch_id || '')) ? out.watch_id : null;
++ const vodBase = watch ? `https://openvibe.video/watch?v=${encodeURIComponent(watch)}` : out.vod_id ? `https://openvibe.video/v/${encodeURIComponent(out.vod_id)}` : null;
for (const chapter of out.chapters) {
const li = document.createElement('li');
li.className = 'agent-chapter';
@@@ -288,10 +289,11 @@
try {
const made = await api(`/streams/agent/${encodeURIComponent(stream.id)}/clips`, { method: 'POST', body: { step_id: step.id } });
const clipId = made && made.clip ? (made.clip.clip_id || made.clip.id) : null;
++ const clipWatch = made && made.clip && /^[A-Za-z0-9_-]{11}$/.test(String(made.clip.watch_id || '')) ? made.clip.watch_id : null;
button.textContent = clipId ? 'Clip made' : 'Clip queued';
if (clipId) {
const a = document.createElement('a');
-- a.href = `https://openvibe.video/c/${encodeURIComponent(clipId)}`;
++ a.href = clipWatch ? `https://openvibe.video/watch?v=${encodeURIComponent(clipWatch)}` : `https://openvibe.video/c/${encodeURIComponent(clipId)}`;
a.textContent = 'Open the clip';
a.style.marginLeft = '8px';
row.appendChild(a);
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
iindex 74ad5ee8..f073fca6 100644
---- a/server/agent-descriptor.js
++++ b/server/agent-descriptor.js
@@@ -17,8 +17,8 @@ module.exports = {
// What an id in a page's address is, and where it goes (AGT-4; server/web/page-status.js, video-handover.js).
stableIds: [
{ kind: 'channel', url: 'https://openvibe.live/@{username}', id: 'the streamer\'s OpenVibe username (never a bare /{username}); a renamed channel\'s old name answers 301 to the current one', gone: 'a name no one has answers 404' },
-- { kind: 'recording', url: 'https://openvibe.live/vod/{id}', id: 'OpenVibe.Media\'s recording id; answers 301 to its watch page, https://openvibe.video/v/{id}, whose manifest has the rule' },
-- { kind: 'clip', url: 'https://openvibe.live/clip/{id}', id: 'OpenVibe.Media\'s clip id; answers 301 to https://openvibe.video/c/{id}' },
++ { kind: 'recording', url: 'https://openvibe.live/vod/{id}', id: 'OpenVibe.Media\'s recording id; answers 301 to its watch page, https://openvibe.video/watch?v={watch_id}, whose manifest has the rule (an older /v/{id} also answers 301)' },
++ { kind: 'clip', url: 'https://openvibe.live/clip/{id}', id: 'OpenVibe.Media\'s clip id; answers 301 to https://openvibe.video/watch?v={watch_id} (an older /c/{id} also answers 301)' },
],
},
};
ddiff --git a/server/recap/recap.js b/server/recap/recap.js
iindex cdf89093..f4763ab8 100644
---- a/server/recap/recap.js
++++ b/server/recap/recap.js
@@@ -82,7 +82,7 @@ async function gather(streamId) {
const media = require('../media-client');
const out = await media.listVods({ stream_id: streamId, limit: 3 }).catch(() => null);
const vods = (out?.vods || []).filter(v => v.status === 'ready' && (v.duration_seconds || v.duration));
-- if (vods.length) { const v = vods.sort((a, b) => (b.duration_seconds || b.duration || 0) - (a.duration_seconds || a.duration || 0))[0]; vod = { id: v.id, thumbnail_url: v.thumbnail_url || null, duration_seconds: v.duration_seconds || v.duration || 0 }; }
++ if (vods.length) { const v = vods.sort((a, b) => (b.duration_seconds || b.duration || 0) - (a.duration_seconds || a.duration || 0))[0]; vod = { id: v.id, watch_id: v.watch_id || null, thumbnail_url: v.thumbnail_url || null, duration_seconds: v.duration_seconds || v.duration || 0 }; }
const co = await media.listClips({ stream_id: streamId, limit: 12 }).catch(() => null);
clips = (co?.clips || (Array.isArray(co) ? co : [])).filter(c => c && (c.is_public == null || Number(c.is_public) === 1))
.sort((a, b) => (Number(b.view_count) || 0) - (Number(a.view_count) || 0)).slice(0, 6)
ddiff --git a/server/rundown/read.js b/server/rundown/read.js
iindex 4357cdcb..6a81dce4 100644
---- a/server/rundown/read.js
++++ b/server/rundown/read.js
@@@ -349,8 +349,9 @@ async function episode(streamId, { viewer = null } = {}) {
const e = await db.get(`SELECT ${EPISODE_COLS} FROM rundown_episodes r ${AUDIENCE_JOINS('r')} WHERE r.stream_id = @id AND r.status = 'live'`, { id });
const stream = await db.get('SELECT id, user_id, title, is_live, started_at, ended_at FROM streams WHERE id = ?', [id]);
if (!stream) return null;
-- // Until Media's event lands, the episode page re-checks the recording (cached 60 s).
-- await visibility.recheck(id);
++ // Until Media's event lands, the episode page re-checks the recording (cached 60 s). The same Media row carries
++ // the watch id the episode's chapters and Report's links are addressed by.
++ const recheck = await visibility.recheck(id);
const eff = await visibility.effective(id);
const ownerUserId = e ? e.user_id : stream.user_id;
if (!sharing.maySee(eff, { ownerUserId, viewer: v })) return null;
@@@ -370,6 +371,7 @@ async function episode(streamId, { viewer = null } = {}) {
const eps = e ? await episodesOut([e], v.key) : [];
const episodeOut = eps[0] || null;
if (episodeOut) {
++ episodeOut.watch_id = (recheck && recheck.watchId) || null;
episodeOut.following_vod = eff.following_vod;
episodeOut.following_vod_label = eff.following_vod ? visibility.mediaWord(eff.vod) : null;
episodeOut.audience = eff.audience;
ddiff --git a/server/rundown/visibility.js b/server/rundown/visibility.js
iindex aadb991e..00f21f52 100644
---- a/server/rundown/visibility.js
++++ b/server/rundown/visibility.js
@@@ -155,21 +155,23 @@ async function recheck(streamId) {
const id = Number(streamId);
if (!Number.isSafeInteger(id) || id <= 0) return null;
const hit = _rechecked.get(id);
-- if (hit && Date.now() - hit.at < RECHECK_MS) return hit.audience;
++ if (hit && Date.now() - hit.at < RECHECK_MS) return { audience: hit.audience, watchId: hit.watchId || null };
const vodId = await vodIdFor(id);
if (!vodId) return null;
-- let audience = null, members = null;
++ let audience = null, members = null, watchId = null;
try {
const vod = await require('../media-client').getVod(vodId);
// Media's `audience` is the share.v1 answer (people included); `visibility` keeps old servers working.
if (vod && (vod.audience || vod.visibility)) audience = mediaAudience(vod.audience || vod.visibility);
if (vod && Array.isArray(vod.members)) members = vod.members;
else if (vod && Array.isArray(vod.share_members)) members = vod.share_members;
++ // The same row carries the id openvibe.video addresses the watch page by; the episode hands it to Report.
++ if (vod && /^[A-Za-z0-9_-]{11}$/.test(String(vod.watch_id || ''))) watchId = String(vod.watch_id);
} catch { /* Media unreachable: the last thing we heard stays */ }
-- if (!audience) return null;
-- _rechecked.set(id, { at: Date.now(), audience });
-- await noteVodVisibility(id, audience, members);
-- return audience;
++ if (!audience && !watchId) return null;
++ _rechecked.set(id, { at: Date.now(), audience, watchId });
++ if (audience) await noteVodVisibility(id, audience, members);
++ return { audience, watchId };
}
/** Forget the cached re-check (a visibility change Live applied itself). */
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex 0b8c214b..61d0360c 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -164,7 +164,7 @@ async function llmsTxt() {
markdown: true,
base: b,
summary: LLMS_SUMMARY,
-- details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source recording at the moment it came from, on OpenVibe.Video (https://openvibe.video/v/<id>?t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
++ details: 'People\'s work and AI-made material are kept apart everywhere on this site. The Content pages list what people made; AI Moments list what the platform\'s AI derived from streams. Every AI item is labelled AI-generated, credited to no person, marked noindex,follow and made canonical to the source recording at the moment it came from, on OpenVibe.Video (https://openvibe.video/watch?v=<watch_id>&t=<seconds>). In the JSON feeds, AI items carry "ai": true and an "ai_label".',
sections: [
{ title: 'Going live from a browser (no OBS)', links: [
{ title: 'How to go live in your browser', url: `${b}/docs/go-live-in-your-browser`, note: `open ${b}, sign in, press Go Live, allow camera and microphone, start. No software, no follower minimum.` },
@@@ -176,8 +176,8 @@ async function llmsTxt() {
{ title: 'Content', url: `${b}/content`, note: `VODs, the clips people took, and pastes people wrote. JSON: ${b}/api/content/feed (cursor paging; ?type=vods|clips|pastes, ?sort=new|top)` },
// A real page, not a template (SEO-34: /@<username> as a link answered 404): every channel is at the same shape.
{ title: 'Channel pages (for example @goosely)', url: `${b}/@goosely`, note: `every channel is at ${b}/@<username>, its videos in pages (?page=2)` },
-- { title: 'Recording pages', url: 'https://openvibe.video/v/<id>', note: 'the canonical watch page on OpenVibe.Video, with chapters, captions and chat replay; a moment: https://openvibe.video/v/<id>?t=<seconds> (Live\'s /vod/<id> redirects there)' },
-- { title: 'Clip pages', url: 'https://openvibe.video/c/<id>', note: 'on OpenVibe.Video (Live\'s /clip/<id> redirects there)' },
++ { title: 'Recording pages', url: 'https://openvibe.video/watch?v=<watch_id>', note: 'the canonical watch page on OpenVibe.Video, with chapters, captions and chat replay; a moment: https://openvibe.video/watch?v=<watch_id>&t=<seconds> (the numbered /v/<id> and Live\'s /vod/<id> answer 301 there)' },
++ { title: 'Clip pages', url: 'https://openvibe.video/watch?v=<watch_id>', note: 'on OpenVibe.Video (the numbered /c/<id> and Live\'s /clip/<id> answer 301 there)' },
{ title: 'Pastes', url: 'https://openvibe.community/p/<slug>', note: 'pastes live on OpenVibe.Community (Live\'s /p/<slug> redirects there)' },
{ title: 'Chat', url: `${b}/chat` },
{ title: 'The Rundown', url: `${b}/rundown`, note: `what streamers talked about, written up: topics, takes, episodes, and the UTC day summary and timeline at ${b}/api/rundown/day/:date (or /today)` },
ddiff --git a/test/llms-txt.test.js b/test/llms-txt.test.js
iindex d77facf4..e79723db 100644
---- a/test/llms-txt.test.js
++++ b/test/llms-txt.test.js
@@@ -50,7 +50,7 @@ async function main() {
await check('names the public pages and the JSON behind them', () => {
for (const u of ['https://openvibe.live/content', 'https://openvibe.live/moments', 'https://openvibe.live/@<username>',
-- 'https://openvibe.video/v/<id>?t=<seconds>', 'https://openvibe.video/c/<id>', 'https://openvibe.live/api/content/feed',
++ 'https://openvibe.video/watch?v=<watch_id>&t=<seconds>', 'https://openvibe.video/watch?v=<watch_id>', 'https://openvibe.live/api/content/feed',
'https://openvibe.live/api/content/moments', 'https://openvibe.community/p/<slug>', 'https://openvibe.live/sitemap.xml']) {
assert.ok(text.includes(u), `missing ${u}`);
}
@@@ -85,7 +85,7 @@ async function main() {
}
assert.ok(text.includes('[Home](https://openvibe.live/index.md)'));
assert.ok(text.includes('[The broadcaster](https://openvibe.live/broadcast) (HTML)'), 'the broadcaster has no twin');
-- assert.ok(text.includes('(https://openvibe.live/@goosely.md)') && text.includes('(https://openvibe.video/v/<id>)'), 'a channel twin; another site keeps its link');
++ assert.ok(text.includes('(https://openvibe.live/@goosely.md)') && text.includes('(https://openvibe.video/watch?v=<watch_id>)'), 'a channel twin; another site keeps its link');
assert.ok(/\n## Optional\n\n[\s\S]*\[Sitemap\]\(https:\/\/openvibe\.live\/sitemap\.xml\)/.test(text) && text.lastIndexOf('\n## ') === text.indexOf('\n## Optional'), 'Optional is the last section');
});
ddiff --git a/test/recap.test.js b/test/recap.test.js
iindex 9076ed37..996e1fbb 100644
---- a/test/recap.test.js
++++ b/test/recap.test.js
@@@ -27,7 +27,13 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
await db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'oops', 'irl', 'webrtc', ts(9), ts(5), 240, 1]);
const pend = (await recap.pending()).map(r => r.id);
assert.deepStrictEqual(pend, [sid], 'only the long-enough, settled stream is pending');
++ // Media's row carries the watch id the report links the recording by (the numbered /v/<id> address still answers 301).
++ const media = require('../server/media-client');
++ const listVods = media.listVods;
++ media.listVods = async () => ({ vods: [{ id: 77, watch_id: 'dQw4w9WgXcQ', status: 'ready', duration_seconds: 3600, thumbnail_url: null }] });
const r = await recap.buildRecap(sid);
++ media.listVods = listVods;
++ assert.strictEqual(r.vod.watch_id, 'dQw4w9WgXcQ', 'the recap keeps the watch id Media minted');
assert.ok(r && r.write && r.write.headline, 'has a write-up');
assert.strictEqual(r.ai, false, 'AI off → template');
assert.strictEqual(r.stream.peak_viewers, 9);
ddiff --git a/test/rundown-audiences.test.js b/test/rundown-audiences.test.js
iindex e0ab97d2..65c3e318 100644
---- a/test/rundown-audiences.test.js
++++ b/test/rundown-audiences.test.js
@@@ -18,6 +18,7 @@ const read = require('../server/rundown/read');
const sharing = require('../server/rundown/sharing');
const visibility = require('../server/rundown/visibility');
const del = require('../server/rundown/delete');
++const media = require('../server/media-client');
const SUB_GOOSE = 'usr_01HQZX9K3M5N7P8R9T0V1W2XYZ';
const SUB_WATCH = 'usr_01HQZX9K3M5N7P8R9T0V1W2XZA';
@@@ -54,7 +55,14 @@ const setAudience = async (a) => { await db.run("UPDATE shares SET audience = ?
const topics = await read.topics({});
assert.ok(topics.topics.some((t) => t.slug === 'fridges' && t.takes >= 1), 'the topic is there with its count');
assert.ok(await read.topic('fridges', { viewer: null }), 'the topic page opens');
-- assert.ok(await read.episode(streamId, { viewer: null }), 'the episode page opens for anybody');
++ // Media's VOD row carries the watch id every link to the recording is addressed by.
++ const getVod = media.getVod;
++ media.getVod = async (id) => ({ id: Number(id), watch_id: 'dQw4w9WgXcQ', visibility: 'public' });
++ const episode = await read.episode(streamId, { viewer: null });
++ media.getVod = getVod;
++ visibility.forget(streamId);
++ assert.ok(episode, 'the episode page opens for anybody');
++ assert.strictEqual(episode.episode.watch_id, 'dQw4w9WgXcQ', 'the episode carries the recording\'s watch id');
}
// ── Only me: the streamer still sees it, nobody else does ──