openvibers/openvibe.live Commits
Watch addresses for the episode, recap and agent recording links (#803)
Changes
@@@ -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 chatreplay, comments, clipping, and the owner's visibility and delete, for private items too (to their owner). Thecaptions name what was heard as well as what was said: `GET /api/vods/:id/sounds` gives cues like "[laughter]",@@@ -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);@@@ -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)' },],},};@@@ -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)@@@ -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;@@@ -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). */@@@ -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)` },@@@ -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');});@@@ -27,7 +27,13 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().repawait 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);@@@ -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 ──
@@ -143,7 +143,7 @@ From the dashboard:
@@ -143,7 +143,7 @@ From the dashboard:
## Recordings and clips are watched on OpenVibe.Video
## 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
`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
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]",
captions name what was heard as well as what was said: `GET /api/vods/:id/sounds` gives cues like "[laughter]",
@@ -220,7 +220,8 @@
@@ -220,7 +220,8 @@
section.hidden = false;
section.hidden = false;
const list = $('agent-chapter-list');
const list = $('agent-chapter-list');
list.replaceChildren();
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) {
for (const chapter of out.chapters) {
const li = document.createElement('li');
const li = document.createElement('li');
li.className = 'agent-chapter';
li.className = 'agent-chapter';
@@ -288,10 +289,11 @@
@@ -288,10 +289,11 @@
try {
try {
const made = await api(`/streams/agent/${encodeURIComponent(stream.id)}/clips`, { method: 'POST', body: { step_id: step.id } });
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 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';
button.textContent = clipId ? 'Clip made' : 'Clip queued';
if (clipId) {
if (clipId) {
const a = document.createElement('a');
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.textContent = 'Open the clip';
a.style.marginLeft = '8px';
a.style.marginLeft = '8px';
row.appendChild(a);
row.appendChild(a);
@@ -17,8 +17,8 @@ module.exports = {
@@ -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).
// What an id in a page's address is, and where it goes (AGT-4; server/web/page-status.js, video-handover.js).
stableIds: [
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: '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)' },
],
],
},
},
};
};
@@ -82,7 +82,7 @@ async function gather(streamId) {
@@ -82,7 +82,7 @@ async function gather(streamId) {
const media = require('../media-client');
const media = require('../media-client');
const out = await media.listVods({ stream_id: streamId, limit: 3 }).catch(() => null);
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));
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);
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))
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)
.sort((a, b) => (Number(b.view_count) || 0) - (Number(a.view_count) || 0)).slice(0, 6)
@@ -349,8 +349,9 @@ async function episode(streamId, { viewer = null } = {}) {
@@ -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 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]);
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;
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 eff = await visibility.effective(id);
const ownerUserId = e ? e.user_id : stream.user_id;
const ownerUserId = e ? e.user_id : stream.user_id;
if (!sharing.maySee(eff, { ownerUserId, viewer: v })) return null;
if (!sharing.maySee(eff, { ownerUserId, viewer: v })) return null;
@@ -370,6 +371,7 @@ async function episode(streamId, { viewer = null } = {}) {
@@ -370,6 +371,7 @@ async function episode(streamId, { viewer = null } = {}) {
const eps = e ? await episodesOut([e], v.key) : [];
const eps = e ? await episodesOut([e], v.key) : [];
const episodeOut = eps[0] || null;
const episodeOut = eps[0] || null;
if (episodeOut) {
if (episodeOut) {
+ episodeOut.watch_id = (recheck && recheck.watchId) || null;
episodeOut.following_vod = eff.following_vod;
episodeOut.following_vod = eff.following_vod;
episodeOut.following_vod_label = eff.following_vod ? visibility.mediaWord(eff.vod) : null;
episodeOut.following_vod_label = eff.following_vod ? visibility.mediaWord(eff.vod) : null;
episodeOut.audience = eff.audience;
episodeOut.audience = eff.audience;
@@ -155,21 +155,23 @@ async function recheck(streamId) {
@@ -155,21 +155,23 @@ async function recheck(streamId) {
const id = Number(streamId);
const id = Number(streamId);
if (!Number.isSafeInteger(id) || id <= 0) return null;
if (!Number.isSafeInteger(id) || id <= 0) return null;
const hit = _rechecked.get(id);
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);
const vodId = await vodIdFor(id);
if (!vodId) return null;
if (!vodId) return null;
- let audience = null, members = null;
+ let audience = null, members = null, watchId = null;
try {
try {
const vod = await require('../media-client').getVod(vodId);
const vod = await require('../media-client').getVod(vodId);
// Media's `audience` is the share.v1 answer (people included); `visibility` keeps old servers working.
// 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 && (vod.audience || vod.visibility)) audience = mediaAudience(vod.audience || vod.visibility);
if (vod && Array.isArray(vod.members)) members = vod.members;
if (vod && Array.isArray(vod.members)) members = vod.members;
else if (vod && Array.isArray(vod.share_members)) members = vod.share_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 */ }
} 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). */
/** Forget the cached re-check (a visibility change Live applied itself). */
@@ -164,7 +164,7 @@ async function llmsTxt() {
@@ -164,7 +164,7 @@ async function llmsTxt() {
markdown: true,
markdown: true,
base: b,
base: b,
summary: LLMS_SUMMARY,
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: [
sections: [
{ title: 'Going live from a browser (no OBS)', links: [
{ 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.` },
{ 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() {
@@ -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)` },
{ 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.
// 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: '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: '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: '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)` },
{ 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)` },
@@ -50,7 +50,7 @@ async function main() {
@@ -50,7 +50,7 @@ async function main() {
await check('names the public pages and the JSON behind them', () => {
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>',
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']) {
'https://openvibe.live/api/content/moments', 'https://openvibe.community/p/<slug>', 'https://openvibe.live/sitemap.xml']) {
assert.ok(text.includes(u), `missing ${u}`);
assert.ok(text.includes(u), `missing ${u}`);
}
}
@@ -85,7 +85,7 @@ async function main() {
@@ -85,7 +85,7 @@ async function main() {
}
}
assert.ok(text.includes('[Home](https://openvibe.live/index.md)'));
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('[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');
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');
});
});
@@ -27,7 +27,13 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
@@ -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]);
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);
const pend = (await recap.pending()).map(r => r.id);
assert.deepStrictEqual(pend, [sid], 'only the long-enough, settled stream is pending');
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);
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.ok(r && r.write && r.write.headline, 'has a write-up');
assert.strictEqual(r.ai, false, 'AI off → template');
assert.strictEqual(r.ai, false, 'AI off → template');
assert.strictEqual(r.stream.peak_viewers, 9);
assert.strictEqual(r.stream.peak_viewers, 9);
@@ -18,6 +18,7 @@ const read = require('../server/rundown/read');
@@ -18,6 +18,7 @@ const read = require('../server/rundown/read');
const sharing = require('../server/rundown/sharing');
const sharing = require('../server/rundown/sharing');
const visibility = require('../server/rundown/visibility');
const visibility = require('../server/rundown/visibility');
const del = require('../server/rundown/delete');
const del = require('../server/rundown/delete');
+const media = require('../server/media-client');
const SUB_GOOSE = 'usr_01HQZX9K3M5N7P8R9T0V1W2XYZ';
const SUB_GOOSE = 'usr_01HQZX9K3M5N7P8R9T0V1W2XYZ';
const SUB_WATCH = 'usr_01HQZX9K3M5N7P8R9T0V1W2XZA';
const SUB_WATCH = 'usr_01HQZX9K3M5N7P8R9T0V1W2XZA';
@@ -54,7 +55,14 @@ const setAudience = async (a) => { await db.run("UPDATE shares SET audience = ?
@@ -54,7 +55,14 @@ const setAudience = async (a) => { await db.run("UPDATE shares SET audience = ?
const topics = await read.topics({});
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(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.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 ──
// ── Only me: the streamer still sees it, nobody else does ──