openvibers/openvibe.report Commits
Link recordings and chapters by their watch id (#188)
Changes
@@@ -494,22 +494,32 @@ ${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-relreturn layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}`, canonical: `${LIVE}/rundown/t/${encodeURIComponent(t.slug || '')}` });}--/** A chapter's moment in the recording on OpenVibe.Video (the same link Live's own Rundown draws). */--function chapterHref(vodId, sec) {++/**++ * A recording's or clip's moment on OpenVibe.Video, in YouTube's shape: /watch?v=<watch id> (and &t=<seconds>).++ * The watch id is Media's; without one the numbered /v/<id> address still answers 301 to the watch page.++ */++function videoHref(vodId, sec, watchId = null) {++ const watch = /^[A-Za-z0-9_-]{11}$/.test(String(watchId || '')) ? String(watchId) : null;++ const t = sec != null && Number.isFinite(Number(sec)) && Number(sec) > 0 ? Math.floor(Number(sec)) : null;++ if (watch) return `https://openvibe.video/watch?v=${encodeURIComponent(watch)}${t ? `&t=${t}` : ''}`;const id = String(vodId == null ? '' : vodId).trim();if (!/^\d{1,16}$/.test(id)) return null;-- const t = Math.max(0, Math.floor(Number(sec) || 0));return `https://openvibe.video/v/${encodeURIComponent(id)}${t ? `?t=${t}` : ''}`;}++function chapterHref(vodId, sec, watchId = null) {++ return videoHref(vodId, sec, watchId);++}++function episode({ episode: d, citations = [] } = {}) {const e = d.episode || {}, s = d.stream || {};const segs = (d.segments || []).slice(0, 60);// S7: the chapters are the recording's moments, linked into OpenVibe.Video; a chapter with no recording stays text.const vod = e.vod_id || s.vod_id || null;++ const watch = e.watch_id || s.watch_id || null;const chapters = (Array.isArray(e.chapters) ? e.chapters : []).slice(0, 40);const chaptersHTML = chapters.length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Chapters</h2><ol class="rp-chapters">${chapters.map((c) => {-- const href = vod ? chapterHref(vod, c.sec) : null;++ const href = (vod || watch) ? chapterHref(vod, c.sec, watch) : null;const stamp = `<span class="rp-stamp">${esc(String(Math.floor((Number(c.sec) || 0) / 60)).padStart(2, '0'))}:${esc(String((Number(c.sec) || 0) % 60).padStart(2, '0'))}</span>`;return `<li>${href ? `<a href="${esc(href)}">${stamp} ${esc(c.title)}</a>` : `${stamp} ${esc(c.title)}`}</li>`;}).join('')}</ol></section>` : '';@@@ -535,7 +545,8 @@ function recap({ recap: d } = {}) {const r = d.recap || d;const s = r.stream || {}, u = r.streamer || {}, v = r.viewers || {}, c = r.chat || {}, love = r.love || {}, w = r.write || {}, vod = r.vod;const takes = (r.takes || []).filter((t) => t && t.title).sort((a, b) => (n(a.sec) ?? 1e9) - (n(b.sec) ?? 1e9)).slice(0, 8);-- const at = (sec) => (vod && vod.id && n(sec) != null ? `https://openvibe.video/v/${encodeURIComponent(vod.id)}?t=${Math.max(0, Math.floor(Number(sec)))}` : null);++ const at = (sec) => (vod && (vod.id || vod.watch_id) && n(sec) != null ? videoHref(vod.id, sec, vod.watch_id) : null);++ const watching = vod ? videoHref(vod.id, null, vod.watch_id) : null;const body = `<header class="rp-page-h rp-recap-h"><p class="rp-kicker">After-show report</p><h1>${esc(w.headline || s.title || 'Stream')}</h1><p class="rp-meta">${kit.mark({ name: who(u), src: u.avatar_url, size: 28 })} <a href="https://openvibe.live/@${encodeURIComponent(u.username || '')}">@${esc(u.username || '')}</a>${s.title && w.headline ? ` · ${esc(s.title)}` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${n(s.duration_seconds) ? ` · ${esc(mins(s.duration_seconds))}` : ''}</p>${w.summary ? `<p class="rp-summary">${esc(w.summary)}</p><p class="rp-meta">${AI}</p>` : ''}</header>@@@ -550,7 +561,7 @@ ${tile('Length', n(s.duration_seconds) ? esc(mins(s.duration_seconds)) : null)}${curve(v.curve) ? `<section aria-labelledby="rp-cv-h"><h2 id="rp-cv-h">Minute by minute</h2>${curve(v.curve)}</section>` : ''}${takes.length ? `<section aria-labelledby="rp-rt-h"><h2 id="rp-rt-h">The moments</h2><ol class="rp-moments">${takes.map((t) => `<li>${at(t.sec) ? `<a class="rp-at" href="${esc(at(t.sec))}">${kit.icon('play')}<span>${esc(mins(t.sec) || '0 min')}</span></a>` : ''}<span><b>${esc(t.title)}</b>${t.topic ? `<a class="rp-kind" href="/rundown/t/${encodeURIComponent(t.slug || '')}">${esc(t.topic)}</a>` : ''}</span></li>`).join('')}</ol><p class="rp-meta">${AI}</p></section>` : ''}${(c.top || []).length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Most active in chat</h2><ul class="rp-chips">${c.top.slice(0, 8).map((x) => `<li><span>${esc(x.display_name || x.username)} <b>${exact(x.n)}</b></span></li>`).join('')}</ul></section>` : ''}--<p class="rp-act">${vod && vod.id ? `<a class="rp-btn" href="https://openvibe.video/v/${encodeURIComponent(vod.id)}">Watch the recording</a>` : ''} <a class="rp-btn is-quiet" href="/c/${encodeURIComponent(u.username || '')}">More from ${esc(who(u))}</a>${s.id ? ` <a class="rp-btn is-quiet" href="/rundown/e/${encodeURIComponent(s.id)}">In The Rundown</a>` : ''}</p>`;++<p class="rp-act">${watching ? `<a class="rp-btn" href="${esc(watching)}">Watch the recording</a>` : ''} <a class="rp-btn is-quiet" href="/c/${encodeURIComponent(u.username || '')}">More from ${esc(who(u))}</a>${s.id ? ` <a class="rp-btn is-quiet" href="/rundown/e/${encodeURIComponent(s.id)}">In The Rundown</a>` : ''}</p>`;return layout({ title: `${w.headline || s.title}: after-show report`, description: w.summary || `How ${who(u)}'s stream went, in numbers.`, body, active: '/rundown', path: `/after-show/${encodeURIComponent(s.id || '')}` });}@@@ -77,11 +77,16 @@ const report = (over = {}) => ({const topic = views.topic({ topic: { topic: { title: 'Login page', kind: 'tech' }, takes: [] }, citations: [{ id: 'rps_01JAN2C3D4E5F6G7H8J9K0MNPS', task: { title: 'Fix the login page' }, summary: 'Done', outcome: 'done' }] });assert.match(topic, /Agents that worked on this/);assert.match(topic, /Fix the login page/);-- const episode = views.episode({ episode: { episode: { title: 'An episode', vod_id: 4242, chapters: [{ sec: 95, title: 'The login bug' }, { sec: 600, title: 'Tests pass' }] }, stream: {} } });-- assert.match(episode, /https:\/\/openvibe\.video\/v\/4242\?t=95/);++ const episode = views.episode({ episode: { episode: { title: 'An episode', vod_id: 4242, watch_id: 'dQw4w9WgXcQ', chapters: [{ sec: 95, title: 'The login bug' }, { sec: 600, title: 'Tests pass' }] }, stream: {} } });++ assert.match(episode, /https:\/\/openvibe\.video\/watch\?v=dQw4w9WgXcQ&t=95/, 'a chapter is a moment on the watch page');assert.match(episode, /01:35/);++ const numbered = views.episode({ episode: { episode: { title: 'An older episode', vod_id: 4242, chapters: [{ sec: 95, title: 'The login bug' }] }, stream: {} } });++ assert.match(numbered, /https:\/\/openvibe\.video\/v\/4242\?t=95/, 'a row without a watch id keeps the numbered address, which answers 301');const noVod = views.episode({ episode: { episode: { title: 'No recording', chapters: [{ sec: 5, title: 'A chapter' }] }, stream: {} } });assert.ok(!/openvibe\.video/.test(noVod), 'no recording: a chapter is text, never a broken link');++ const recap = views.recap({ recap: { stream: { id: 1, title: 'A stream', started_at: '2026-11-01T10:00:00Z' }, streamer: { username: 'ann' }, viewers: {}, chat: {}, love: {}, write: { headline: 'A stream', summary: 'It went well.' }, vod: { id: 77, watch_id: 'dQw4w9WgXcQ' }, takes: [{ sec: 95, title: 'A moment' }] } });++ assert.match(recap, /href="https:\/\/openvibe\.video\/watch\?v=dQw4w9WgXcQ"/, 'the recording button is the watch page');++ assert.match(recap, /href="https:\/\/openvibe\.video\/watch\?v=dQw4w9WgXcQ&t=95"/, 'a take is a moment on the watch page');});} finally { await t.close(); }
@@ -494,22 +494,32 @@ ${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel
@@ -494,22 +494,32 @@ ${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel
return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}`, canonical: `${LIVE}/rundown/t/${encodeURIComponent(t.slug || '')}` });
return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}`, canonical: `${LIVE}/rundown/t/${encodeURIComponent(t.slug || '')}` });
}
}
-/** A chapter's moment in the recording on OpenVibe.Video (the same link Live's own Rundown draws). */
-function chapterHref(vodId, sec) {
+/**
+ * A recording's or clip's moment on OpenVibe.Video, in YouTube's shape: /watch?v=<watch id> (and &t=<seconds>).
+ * The watch id is Media's; without one the numbered /v/<id> address still answers 301 to the watch page.
+ */
+function videoHref(vodId, sec, watchId = null) {
+ const watch = /^[A-Za-z0-9_-]{11}$/.test(String(watchId || '')) ? String(watchId) : null;
+ const t = sec != null && Number.isFinite(Number(sec)) && Number(sec) > 0 ? Math.floor(Number(sec)) : null;
+ if (watch) return `https://openvibe.video/watch?v=${encodeURIComponent(watch)}${t ? `&t=${t}` : ''}`;
const id = String(vodId == null ? '' : vodId).trim();
const id = String(vodId == null ? '' : vodId).trim();
if (!/^\d{1,16}$/.test(id)) return null;
if (!/^\d{1,16}$/.test(id)) return null;
- const t = Math.max(0, Math.floor(Number(sec) || 0));
return `https://openvibe.video/v/${encodeURIComponent(id)}${t ? `?t=${t}` : ''}`;
return `https://openvibe.video/v/${encodeURIComponent(id)}${t ? `?t=${t}` : ''}`;
}
}
+function chapterHref(vodId, sec, watchId = null) {
+ return videoHref(vodId, sec, watchId);
+}
+
function episode({ episode: d, citations = [] } = {}) {
function episode({ episode: d, citations = [] } = {}) {
const e = d.episode || {}, s = d.stream || {};
const e = d.episode || {}, s = d.stream || {};
const segs = (d.segments || []).slice(0, 60);
const segs = (d.segments || []).slice(0, 60);
// S7: the chapters are the recording's moments, linked into OpenVibe.Video; a chapter with no recording stays text.
// S7: the chapters are the recording's moments, linked into OpenVibe.Video; a chapter with no recording stays text.
const vod = e.vod_id || s.vod_id || null;
const vod = e.vod_id || s.vod_id || null;
+ const watch = e.watch_id || s.watch_id || null;
const chapters = (Array.isArray(e.chapters) ? e.chapters : []).slice(0, 40);
const chapters = (Array.isArray(e.chapters) ? e.chapters : []).slice(0, 40);
const chaptersHTML = chapters.length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Chapters</h2><ol class="rp-chapters">${chapters.map((c) => {
const chaptersHTML = chapters.length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Chapters</h2><ol class="rp-chapters">${chapters.map((c) => {
- const href = vod ? chapterHref(vod, c.sec) : null;
+ const href = (vod || watch) ? chapterHref(vod, c.sec, watch) : null;
const stamp = `<span class="rp-stamp">${esc(String(Math.floor((Number(c.sec) || 0) / 60)).padStart(2, '0'))}:${esc(String((Number(c.sec) || 0) % 60).padStart(2, '0'))}</span>`;
const stamp = `<span class="rp-stamp">${esc(String(Math.floor((Number(c.sec) || 0) / 60)).padStart(2, '0'))}:${esc(String((Number(c.sec) || 0) % 60).padStart(2, '0'))}</span>`;
return `<li>${href ? `<a href="${esc(href)}">${stamp} ${esc(c.title)}</a>` : `${stamp} ${esc(c.title)}`}</li>`;
return `<li>${href ? `<a href="${esc(href)}">${stamp} ${esc(c.title)}</a>` : `${stamp} ${esc(c.title)}`}</li>`;
}).join('')}</ol></section>` : '';
}).join('')}</ol></section>` : '';
@@ -535,7 +545,8 @@ function recap({ recap: d } = {}) {
@@ -535,7 +545,8 @@ function recap({ recap: d } = {}) {
const r = d.recap || d;
const r = d.recap || d;
const s = r.stream || {}, u = r.streamer || {}, v = r.viewers || {}, c = r.chat || {}, love = r.love || {}, w = r.write || {}, vod = r.vod;
const s = r.stream || {}, u = r.streamer || {}, v = r.viewers || {}, c = r.chat || {}, love = r.love || {}, w = r.write || {}, vod = r.vod;
const takes = (r.takes || []).filter((t) => t && t.title).sort((a, b) => (n(a.sec) ?? 1e9) - (n(b.sec) ?? 1e9)).slice(0, 8);
const takes = (r.takes || []).filter((t) => t && t.title).sort((a, b) => (n(a.sec) ?? 1e9) - (n(b.sec) ?? 1e9)).slice(0, 8);
- const at = (sec) => (vod && vod.id && n(sec) != null ? `https://openvibe.video/v/${encodeURIComponent(vod.id)}?t=${Math.max(0, Math.floor(Number(sec)))}` : null);
+ const at = (sec) => (vod && (vod.id || vod.watch_id) && n(sec) != null ? videoHref(vod.id, sec, vod.watch_id) : null);
+ const watching = vod ? videoHref(vod.id, null, vod.watch_id) : null;
const body = `<header class="rp-page-h rp-recap-h"><p class="rp-kicker">After-show report</p><h1>${esc(w.headline || s.title || 'Stream')}</h1>
const body = `<header class="rp-page-h rp-recap-h"><p class="rp-kicker">After-show report</p><h1>${esc(w.headline || s.title || 'Stream')}</h1>
<p class="rp-meta">${kit.mark({ name: who(u), src: u.avatar_url, size: 28 })} <a href="https://openvibe.live/@${encodeURIComponent(u.username || '')}">@${esc(u.username || '')}</a>${s.title && w.headline ? ` · ${esc(s.title)}` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${n(s.duration_seconds) ? ` · ${esc(mins(s.duration_seconds))}` : ''}</p>
<p class="rp-meta">${kit.mark({ name: who(u), src: u.avatar_url, size: 28 })} <a href="https://openvibe.live/@${encodeURIComponent(u.username || '')}">@${esc(u.username || '')}</a>${s.title && w.headline ? ` · ${esc(s.title)}` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${n(s.duration_seconds) ? ` · ${esc(mins(s.duration_seconds))}` : ''}</p>
${w.summary ? `<p class="rp-summary">${esc(w.summary)}</p><p class="rp-meta">${AI}</p>` : ''}</header>
${w.summary ? `<p class="rp-summary">${esc(w.summary)}</p><p class="rp-meta">${AI}</p>` : ''}</header>
@@ -550,7 +561,7 @@ ${tile('Length', n(s.duration_seconds) ? esc(mins(s.duration_seconds)) : null)}
@@ -550,7 +561,7 @@ ${tile('Length', n(s.duration_seconds) ? esc(mins(s.duration_seconds)) : null)}
${curve(v.curve) ? `<section aria-labelledby="rp-cv-h"><h2 id="rp-cv-h">Minute by minute</h2>${curve(v.curve)}</section>` : ''}
${curve(v.curve) ? `<section aria-labelledby="rp-cv-h"><h2 id="rp-cv-h">Minute by minute</h2>${curve(v.curve)}</section>` : ''}
${takes.length ? `<section aria-labelledby="rp-rt-h"><h2 id="rp-rt-h">The moments</h2><ol class="rp-moments">${takes.map((t) => `<li>${at(t.sec) ? `<a class="rp-at" href="${esc(at(t.sec))}">${kit.icon('play')}<span>${esc(mins(t.sec) || '0 min')}</span></a>` : ''}<span><b>${esc(t.title)}</b>${t.topic ? `<a class="rp-kind" href="/rundown/t/${encodeURIComponent(t.slug || '')}">${esc(t.topic)}</a>` : ''}</span></li>`).join('')}</ol><p class="rp-meta">${AI}</p></section>` : ''}
${takes.length ? `<section aria-labelledby="rp-rt-h"><h2 id="rp-rt-h">The moments</h2><ol class="rp-moments">${takes.map((t) => `<li>${at(t.sec) ? `<a class="rp-at" href="${esc(at(t.sec))}">${kit.icon('play')}<span>${esc(mins(t.sec) || '0 min')}</span></a>` : ''}<span><b>${esc(t.title)}</b>${t.topic ? `<a class="rp-kind" href="/rundown/t/${encodeURIComponent(t.slug || '')}">${esc(t.topic)}</a>` : ''}</span></li>`).join('')}</ol><p class="rp-meta">${AI}</p></section>` : ''}
${(c.top || []).length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Most active in chat</h2><ul class="rp-chips">${c.top.slice(0, 8).map((x) => `<li><span>${esc(x.display_name || x.username)} <b>${exact(x.n)}</b></span></li>`).join('')}</ul></section>` : ''}
${(c.top || []).length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Most active in chat</h2><ul class="rp-chips">${c.top.slice(0, 8).map((x) => `<li><span>${esc(x.display_name || x.username)} <b>${exact(x.n)}</b></span></li>`).join('')}</ul></section>` : ''}
-<p class="rp-act">${vod && vod.id ? `<a class="rp-btn" href="https://openvibe.video/v/${encodeURIComponent(vod.id)}">Watch the recording</a>` : ''} <a class="rp-btn is-quiet" href="/c/${encodeURIComponent(u.username || '')}">More from ${esc(who(u))}</a>${s.id ? ` <a class="rp-btn is-quiet" href="/rundown/e/${encodeURIComponent(s.id)}">In The Rundown</a>` : ''}</p>`;
+<p class="rp-act">${watching ? `<a class="rp-btn" href="${esc(watching)}">Watch the recording</a>` : ''} <a class="rp-btn is-quiet" href="/c/${encodeURIComponent(u.username || '')}">More from ${esc(who(u))}</a>${s.id ? ` <a class="rp-btn is-quiet" href="/rundown/e/${encodeURIComponent(s.id)}">In The Rundown</a>` : ''}</p>`;
return layout({ title: `${w.headline || s.title}: after-show report`, description: w.summary || `How ${who(u)}'s stream went, in numbers.`, body, active: '/rundown', path: `/after-show/${encodeURIComponent(s.id || '')}` });
return layout({ title: `${w.headline || s.title}: after-show report`, description: w.summary || `How ${who(u)}'s stream went, in numbers.`, body, active: '/rundown', path: `/after-show/${encodeURIComponent(s.id || '')}` });
}
}
@@ -77,11 +77,16 @@ const report = (over = {}) => ({
@@ -77,11 +77,16 @@ const report = (over = {}) => ({
const topic = views.topic({ topic: { topic: { title: 'Login page', kind: 'tech' }, takes: [] }, citations: [{ id: 'rps_01JAN2C3D4E5F6G7H8J9K0MNPS', task: { title: 'Fix the login page' }, summary: 'Done', outcome: 'done' }] });
const topic = views.topic({ topic: { topic: { title: 'Login page', kind: 'tech' }, takes: [] }, citations: [{ id: 'rps_01JAN2C3D4E5F6G7H8J9K0MNPS', task: { title: 'Fix the login page' }, summary: 'Done', outcome: 'done' }] });
assert.match(topic, /Agents that worked on this/);
assert.match(topic, /Agents that worked on this/);
assert.match(topic, /Fix the login page/);
assert.match(topic, /Fix the login page/);
- const episode = views.episode({ episode: { episode: { title: 'An episode', vod_id: 4242, chapters: [{ sec: 95, title: 'The login bug' }, { sec: 600, title: 'Tests pass' }] }, stream: {} } });
- assert.match(episode, /https:\/\/openvibe\.video\/v\/4242\?t=95/);
+ const episode = views.episode({ episode: { episode: { title: 'An episode', vod_id: 4242, watch_id: 'dQw4w9WgXcQ', chapters: [{ sec: 95, title: 'The login bug' }, { sec: 600, title: 'Tests pass' }] }, stream: {} } });
+ assert.match(episode, /https:\/\/openvibe\.video\/watch\?v=dQw4w9WgXcQ&t=95/, 'a chapter is a moment on the watch page');
assert.match(episode, /01:35/);
assert.match(episode, /01:35/);
+ const numbered = views.episode({ episode: { episode: { title: 'An older episode', vod_id: 4242, chapters: [{ sec: 95, title: 'The login bug' }] }, stream: {} } });
+ assert.match(numbered, /https:\/\/openvibe\.video\/v\/4242\?t=95/, 'a row without a watch id keeps the numbered address, which answers 301');
const noVod = views.episode({ episode: { episode: { title: 'No recording', chapters: [{ sec: 5, title: 'A chapter' }] }, stream: {} } });
const noVod = views.episode({ episode: { episode: { title: 'No recording', chapters: [{ sec: 5, title: 'A chapter' }] }, stream: {} } });
assert.ok(!/openvibe\.video/.test(noVod), 'no recording: a chapter is text, never a broken link');
assert.ok(!/openvibe\.video/.test(noVod), 'no recording: a chapter is text, never a broken link');
+ const recap = views.recap({ recap: { stream: { id: 1, title: 'A stream', started_at: '2026-11-01T10:00:00Z' }, streamer: { username: 'ann' }, viewers: {}, chat: {}, love: {}, write: { headline: 'A stream', summary: 'It went well.' }, vod: { id: 77, watch_id: 'dQw4w9WgXcQ' }, takes: [{ sec: 95, title: 'A moment' }] } });
+ assert.match(recap, /href="https:\/\/openvibe\.video\/watch\?v=dQw4w9WgXcQ"/, 'the recording button is the watch page');
+ assert.match(recap, /href="https:\/\/openvibe\.video\/watch\?v=dQw4w9WgXcQ&t=95"/, 'a take is a moment on the watch page');
});
});
} finally { await t.close(); }
} finally { await t.close(); }