openvibers/openvibe.mediahub Commits
Video: owner tools (edit, visibility, delete) on openvibe.video, made on Live as the signed-in person (#35)
Changes
@@@ -127,6 +127,13 @@ Live's `POST /api/views/:kind/:id`, so Live counts the view for the viewer's owncome from the loopback address). The CSP allows Media's hosts (openvibe.media, its shield edge.openvibe.media andthe presigned R2/B2 hosts it may redirect a file to) and Live's origin for the beacon.++**Owner tools.** A recording's channel, and a clip's maker or channel, see a "Manage" panel on the watch page: title,++description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends to++OpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so++Live's rules decide who may do what and its refusal is shown as it is; the cached reads of the item and of the lists++are dropped after a change. A private item is shown to its owner only (noindex, never cached). New clips are still cut++on Live.++The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link tothe OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its owncallback URI (`https://openvibe.video/auth/callback`); each host's session cookie is host-only, so signing in on one@@@ -137,6 +144,8 @@ does not sign in on the other.| `/` | anyone | what OpenVibe.Video is, then the latest recordings and clips || `/v/:id` | anyone | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips, add-to-playlist || `/c/:id` | anyone | a clip watch page: player, attribution, the full-recording link, add-to-playlist |++| `POST /v/:id/manage`, `POST /v/:id/delete` | the recording's channel (Live decides) | edit title, description and visibility; delete (a ticked box) |++| `POST /c/:id/manage`, `POST /c/:id/delete` | the clip's maker or channel (Live decides) | edit title and visibility; delete || `/@:username` | anyone | a channel's recordings (and clips), newest first, 24 per page || `/playlists` | the owner | their playlists: create, rename, change visibility, delete || `/playlists/:id` | anyone with the link | one playlist: public and unlisted are open by link (unlisted noindex), private only to the owner |@@@ -172,3 +172,20 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-item-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }.ovv-item-row--gone .ovv-item-title { color: var(--ovv-faint); font-weight: 500; }.ovv-playlist-head { padding: 8px 0 4px; }++++/* Owner tools: the Manage panel on a recording or clip its channel (or maker) owns. */++.ovv-owner { border-color: var(--ovv-line-strong); }++.ovv-owner h2 { display: flex; align-items: center; gap: 8px; }++.ovv-owner-form { display: grid; gap: 12px; margin-top: 10px; }++.ovv-field { display: grid; gap: 5px; }++.ovv-field > span { font-size: .82rem; font-weight: 700; color: var(--ovv-dim); }++.ovv-field input, .ovv-field textarea, .ovv-field select { font: inherit; width: 100%; box-sizing: border-box; min-height: 38px; padding: 7px 10px; border-radius: 9px; border: 1px solid var(--ovv-line-strong); background: var(--bg-input, var(--bg-secondary, #0f1729)); color: inherit; }++.ovv-field textarea { resize: vertical; min-height: 76px; }++.ovv-field input:focus-visible, .ovv-field textarea:focus-visible, .ovv-field select:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 1px; border-color: var(--ovv-accent); }++.ovv-owner-form .ovv-fine { margin: -4px 0 0; }++.ovv-owner-form .ovv-btn { justify-self: start; }++.ovv-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--ovv-line); }++.ovv-danger > summary { cursor: pointer; font-size: .88rem; font-weight: 700; color: var(--danger, #f87171); }++.ovv-danger > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 2px; border-radius: 4px; }++.ovv-owner-delete { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }++.ovv-watch--clip .ovv-owner { max-width: 640px; }@@@ -20,6 +20,15 @@ function isId(v) {return /^[1-9]\d*$/.test(String(v)) && Number.isSafeInteger(Number(v)) && Number(v) > 0;}++/** Live answered an owner call with a 4xx: its status and its own message, shown to the person. */++class LiveRefusedError extends Error {++ constructor(status, message) {++ super(message);++ this.name = 'LiveRefusedError';++ this.status = status;++ }++}++/** Live was down or answered nonsense: the pages turn this into a 502. */class LiveUnavailableError extends Error {constructor(message) {@@@ -82,7 +91,56 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consolereturn remember(url, body);}++ /**++ * A call made AS the signed-in person (their Network token as a Bearer; Live checks the issuer and decides what++ * they may do), never cached. 2xx → the JSON body; a 4xx → LiveRefusedError carrying Live's status and message++ * (a 401 lets the caller refresh the session and try once more); anything else → LiveUnavailableError.++ */++ async function asPerson(token, method, path, body) {++ let res;++ try {++ res = await fetchImpl(`${config.video.liveUrl}${path}`, {++ method,++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },++ body: body ? JSON.stringify(body) : undefined,++ credentials: 'omit',++ cache: 'no-store',++ signal: AbortSignal.timeout(TIMEOUT_MS * 2),++ });++ } catch (err) {++ log.warn('[OpenVibe.Video] Live did not answer an owner call', err && err.name);++ throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');++ }++ const data = await res.json().catch(() => null);++ if (res.ok) return data || {};++ if (res.status >= 400 && res.status < 500) {++ throw new LiveRefusedError(res.status, (data && typeof data.error === 'string' && data.error.slice(0, 200)) || 'OpenVibe.Live refused that.');++ }++ throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');++ }++++ /** After an owner's change: drop the cached reads of this item and every cached list, so pages show it at once. */++ function forget(kind, id) {++ const one = `${config.video.liveUrl}/api/${kind === 'clip' ? 'clips' : 'vods'}/${id}`;++ for (const key of [...cache.keys()]) {++ if (key === one || key.startsWith(`${one}/`) || key.includes('/api/vods?') || key.includes('/api/clips?') || key.includes('/api/content/feed?')) cache.delete(key);++ }++ }++return {++ /** The person's own view of one item (a private one they own or may moderate included); null when Live says 404. */++ async vodAs(token, id) {++ try { return await asPerson(token, 'GET', `/api/vods/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }++ },++ async clipAs(token, id) {++ try { return await asPerson(token, 'GET', `/api/clips/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }++ },++ updateVod: (token, id, fields) => asPerson(token, 'PUT', `/api/vods/${id}`, fields),++ deleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),++ setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),++ setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),++ deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),++ forget,feed({ type, limit, cursor = null }) {const qs = new URLSearchParams({ type, limit: String(limit) });// Live's feed pages by an opaque cursor (its `next`); only a plain token is passed on.@@@ -114,4 +172,4 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console};}--module.exports = { createLiveClient, LiveUnavailableError, isId, USERNAME_RE };++module.exports = { createLiveClient, LiveUnavailableError, LiveRefusedError, isId, USERNAME_RE };@@@ -25,7 +25,7 @@ const { asyncRouter } = require('../http/router');const { sameOrigin } = require('../http/principal');const { html, notice } = require('../render/html');const { send, asset, assetVersion, SITE_NAME } = require('./layout');--const { createLiveClient } = require('./live-client');++const { createLiveClient, LiveRefusedError } = require('./live-client');const { createChatClient } = require('./chat-client');const { createVideoApiRoutes } = require('./api');const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');@@@ -354,6 +354,63 @@ function createVideoRoutes(ctx) {}const visibilityLabel = (v) => (v === 'public' ? 'Public' : (v === 'unlisted' ? 'Unlisted' : 'Private'));++ // ── Owner tools ─────────────────────────────────────────++ // A recording's channel and a clip's maker or channel see a Manage panel. The panel only decides what to show:++ // every change is made on OpenVibe.Live AS the signed-in person (their Network token as a Bearer, through++ // sso.asViewer so an expired session is refreshed once), and Live decides whether they may make it.++ const VISIBILITIES = ['public', 'unlisted', 'private'];++ const sameName = (a, b) => Boolean(a && b && String(a).toLowerCase() === String(b).toLowerCase());++ const isStaff = (req) => ['admin', 'global_mod'].includes(String((req.viewer && req.viewer.role) || ''));++ function mayManage(req, kind, item) {++ if (!signedIn(req) || !item) return false;++ if (isStaff(req)) return true;++ const me = req.viewer.username;++ if (kind === 'vod') return sameName(me, item.username);++ return sameName(me, item.username) || sameName(me, item.source_streamer_username) || sameName(me, item.channel_username);++ }++ const asPerson = (req, res, fn) => ctx.sso.asViewer(req, res, fn);++ /** A signed-in person's own view of an item Live hides from everyone else (private), or null. */++ async function ownView(req, res, kind, id) {++ if (!signedIn(req)) return null;++ try {++ const data = await asPerson(req, res, (token) => (kind === 'vod' ? live.vodAs(token, id) : live.clipAs(token, id)));++ const item = data && (kind === 'vod' ? data.vod : data.clip);++ return item && mayManage(req, kind, item) ? data : null;++ } catch (err) {++ if (err instanceof LiveRefusedError) return null;++ throw err;++ }++ }++ function ownerRefused(req, res, err, back) {++ const refused = err instanceof LiveRefusedError;++ if (!refused && !(err && err.liveUnavailable)) throw err;++ const status = refused ? (err.status === 401 ? 401 : err.status === 404 ? 404 : 403) : 502;++ return page(req, res, {++ title: 'Not saved',++ cache: cache.htmlHeaders({ private: true }),++ body: html`<h1>That was not saved</h1>${notice(refused ? `OpenVibe.Live said: ${err.message}` : err.message, 'bad')}<p><a class="ovv-btn" href="${back}">Go back</a></p>`,++ }, status);++ }++ const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');++ const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);++ function ownerPanel(kind, id, item) {++ const what = kind === 'vod' ? 'recording' : 'clip';++ const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;++ return html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><h2 id="h-owner">Manage this ${what}</h2>++<form class="ovv-owner-form" method="post" action="${action}/manage">++<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="200" value="${item.title || ''}" required></label>++${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="2000">${item.description || ''}</textarea></label>` : ''}++<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(item.visibility || 'public')}</select></label>++<p class="ovv-fine">Public is listed and searchable; unlisted opens only by its link; private only for you and staff.</p>++<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>++</form>++<details class="ovv-danger"><summary>Delete this ${what}</summary>++<form class="ovv-owner-delete" method="post" action="${action}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>++</details>++${kind === 'vod' ? html`<p class="ovv-fine">New clips are still cut on <a href="${livePublic}/vod/${id}">OpenVibe.Live</a>.</p>` : ''}++</section>`;++ }++// The shell's navbar/footer scripts come from the site's own /shared copy, and the one// product stylesheet/script live in public/.r.use('/shared', ovServe.handler());@@@ -517,9 +574,15 @@ function createVideoRoutes(ctx) {const id = String(req.params.id || '');if (!live.isId(id)) return notFound(req, res);-- const data = await live.vod(id);++ let data = await live.vod(id);++ let ownOnly = false;++ if (!data || !data.vod) {++ data = await ownView(req, res, 'vod', id);++ ownOnly = true;++ }if (!data || !data.vod) return notFound(req, res);const vod = data.vod;++ const manageable = mayManage(req, 'vod', vod);const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];const segs = transcriptSegments(vod);@@@ -549,7 +612,6 @@ function createVideoRoutes(ctx) {const channelName = vod.display_name || vod.username || null;const localChannel = vod.username ? `/@${vod.username}` : null;const liveChannel = `${livePublic}/@${vod.username}`;-- const manage = `${livePublic}/vod/${id}`;const metaParts = [];if (vod.created_at) metaParts.push(html`<time datetime="${vod.created_at}">${dateLabel(vod.created_at)}</time>`);if (vod.duration_seconds != null) metaParts.push(durationLabel(vod.duration_seconds));@@@ -563,13 +625,15 @@ function createVideoRoutes(ctx) {const hlsAttr = hlsAttrs(vod);return page(req, res, {-- index: !unlisted,-- noindex: unlisted,++ index: !unlisted && !ownOnly,++ noindex: unlisted || ownOnly,++ ...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: vod.title || 'Stream recording',description: vod.description || vod.ai_overview || '',scripts: ['js/video.js'],body: html`<div class="ovv-watch"><div class="ovv-main">++${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(vod.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div><h1 class="ovv-title">${vod.title || 'Stream recording'}</h1><div class="ovv-byline">@@@ -584,11 +648,11 @@ ${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcri${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><h2 id="h-comments">Comments</h2>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.</p>`}${threadUrl ? html`<p class="ovv-comments-link"><a class="ovv-btn" href="${threadUrl}">Join the discussion on OpenVibe.Community</a></p>` : ''}</section>` : ''}</div><aside class="ovv-side">++${manageable ? ownerPanel('vod', id, vod) : ''}${up.html}${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}--<p class="ovv-manage"><a href="${manage}">Manage on OpenVibe.Live</a> <span class="ovv-fine">· owner tools (visibility, clips, delete) stay on Live for now</span></p></aside></div>`,});@@@ -599,9 +663,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 iconst id = String(req.params.id || '');if (!live.isId(id)) return notFound(req, res);-- const data = await live.clip(id);++ let data = await live.clip(id);++ let ownOnly = false;++ if (!data || !data.clip) {++ data = await ownView(req, res, 'clip', id);++ ownOnly = true;++ }if (!data || !data.clip) return notFound(req, res);const clip = data.clip;++ const manageable = mayManage(req, 'clip', clip);let threadUrl = null;try {@@@ -622,13 +692,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 iconst hlsAttr = hlsAttrs(clip);return page(req, res, {-- index: !unlisted,-- noindex: unlisted,++ index: !unlisted && !ownOnly,++ noindex: unlisted || ownOnly,++ ...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: clip.title || 'Clip',description: clip.ai_overview || '',scripts: ['js/video.js'],body: html`<div class="ovv-watch ovv-watch--clip"><div class="ovv-main">++${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div><h1 class="ovv-title">${clip.title || 'Clip'}</h1><div class="ovv-byline">@@@ -638,13 +710,91 @@ ${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_a</div>${up.html}${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><p class="ovv-fine">Written by AI from the clip.</p></section>` : ''}--<p class="ovv-manage"><a href="${livePublic}/clip/${id}">Manage on OpenVibe.Live</a></p>++${manageable ? ownerPanel('clip', id, clip) : ''}</div></div>`,});});// ── Channel page ────────────────────────────────────────++ // ── Owner changes (web forms, same-origin, as the person on Live) ──++ function ownerFields(b, kind) {++ const title = String(b.title || '').trim();++ const description = String(b.description || '').trim();++ const visibility = String(b.visibility || '');++ if (!title || title.length > 200) return { error: 'A title is 1 to 200 characters.' };++ if (kind === 'vod' && description.length > 2000) return { error: 'A description is at most 2,000 characters.' };++ if (!VISIBILITIES.includes(visibility)) return { error: 'Choose public, unlisted or private.' };++ return { title, description, visibility };++ }++ const fieldError = (req, res, text, back) => page(req, res, {++ title: 'Not saved', cache: cache.htmlHeaders({ private: true }),++ body: html`<h1>That was not saved</h1>${notice(text, 'bad')}<p><a class="ovv-btn" href="${back}">Go back</a></p>`,++ }, 422);++++ r.post('/v/:id/manage', mustSignIn('here', 'Sign in to manage your recording.'), async (req, res) => {++ const id = String(req.params.id || '');++ if (!live.isId(id)) return notFound(req, res);++ const back = `/v/${id}`;++ if (!sameSite(req, res, back)) return undefined;++ const f = ownerFields(req.body || {}, 'vod');++ if (f.error) return fieldError(req, res, f.error, back);++ try {++ await asPerson(req, res, (token) => live.updateVod(token, id, { title: f.title, description: f.description, visibility: f.visibility }));++ } catch (err) { return ownerRefused(req, res, err, back); }++ live.forget('vod', id);++ return res.redirect(303, `${back}?saved=1`);++ });++++ r.post('/v/:id/delete', mustSignIn('here', 'Sign in to manage your recording.'), async (req, res) => {++ const id = String(req.params.id || '');++ if (!live.isId(id)) return notFound(req, res);++ const back = `/v/${id}`;++ if (!sameSite(req, res, back)) return undefined;++ if ((req.body || {}).confirm !== '1') return fieldError(req, res, 'Tick the box to confirm the deletion.', back);++ try {++ await asPerson(req, res, (token) => live.deleteVod(token, id));++ } catch (err) { return ownerRefused(req, res, err, back); }++ live.forget('vod', id);++ return res.redirect(303, req.viewer.username && live.isUsername(req.viewer.username) ? `/@${req.viewer.username}?deleted=1` : '/?deleted=1');++ });++++ r.post('/c/:id/manage', mustSignIn('here', 'Sign in to manage your clip.'), async (req, res) => {++ const id = String(req.params.id || '');++ if (!live.isId(id)) return notFound(req, res);++ const back = `/c/${id}`;++ if (!sameSite(req, res, back)) return undefined;++ const f = ownerFields(req.body || {}, 'clip');++ if (f.error) return fieldError(req, res, f.error, back);++ // Two Live calls: the title (the clip's maker, the channel or staff) and the visibility (the channel or staff).++ // Only what changed is sent, so a maker who keeps the visibility is never refused for it.++ let current = null;++ try {++ const d = await asPerson(req, res, (token) => live.clipAs(token, id));++ current = d && d.clip;++ } catch (err) { return ownerRefused(req, res, err, back); }++ if (!current) return notFound(req, res);++ try {++ if (f.title !== String(current.title || '')) await asPerson(req, res, (token) => live.setClipTitle(token, id, f.title));++ if (f.visibility !== String(current.visibility || '')) await asPerson(req, res, (token) => live.setClipVisibility(token, id, f.visibility));++ } catch (err) { live.forget('clip', id); return ownerRefused(req, res, err, back); }++ live.forget('clip', id);++ return res.redirect(303, `${back}?saved=1`);++ });++++ r.post('/c/:id/delete', mustSignIn('here', 'Sign in to manage your clip.'), async (req, res) => {++ const id = String(req.params.id || '');++ if (!live.isId(id)) return notFound(req, res);++ const back = `/c/${id}`;++ if (!sameSite(req, res, back)) return undefined;++ if ((req.body || {}).confirm !== '1') return fieldError(req, res, 'Tick the box to confirm the deletion.', back);++ try {++ await asPerson(req, res, (token) => live.deleteClip(token, id));++ } catch (err) { return ownerRefused(req, res, err, back); }++ live.forget('clip', id);++ return res.redirect(303, req.viewer.username && live.isUsername(req.viewer.username) ? `/@${req.viewer.username}?deleted=1` : '/?deleted=1');++ });++r.get('/@:username', async (req, res) => {const username = String(req.params.username || '');if (!live.isUsername(username)) return notFound(req, res);@@@ -682,7 +832,7 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><index: true,title: `@${username}`,description: `Recordings and clips from @${username} on OpenVibe.Video.`,-- body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>++ body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header><section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++/**++ * OpenVibe.Video owner tools: a recording's channel (and a clip's maker or channel) gets a Manage panel on the watch++ * page; edits and deletions are made on OpenVibe.Live AS the signed-in person (their Network token as a Bearer), are++ * same-origin web forms, are validated before Live is called, and show Live's own refusal. A private item is shown++ * to its owner only (noindex, private), and Live's answer is re-read after a change (the read cache is dropped).++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const usernameOf = (auth) => {++ const tok = String(auth || '').replace(/^Bearer /, '');++ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString('utf8')).username || null; } catch { return null; }++};++++function startLive() {++ const vods = {++ 101: { id: 101, title: 'Kim stream', description: 'First', duration_seconds: 45, created_at: '2026-10-12T10:00:00.000Z', playback_url: 'https://openvibe.media/v/101', visibility: 'public', username: 'kim', display_name: 'Kim' },++ 103: { id: 103, title: 'Kim private', description: '', duration_seconds: 20, created_at: '2026-10-12T09:00:00.000Z', playback_url: 'https://openvibe.media/v/103', visibility: 'private', username: 'kim', display_name: 'Kim' },++ };++ const clips = {++ 201: { id: 201, vod_id: 101, title: 'Kim clip', start_time: 5, end_time: 15, duration_seconds: 10, playback_url: 'https://openvibe.media/c/201', visibility: 'public', username: 'kim', display_name: 'Kim' },++ };++ const calls = [];++ const server = http.createServer((req, res) => {++ const url = new URL(req.url, 'http://127.0.0.1');++ const who = usernameOf(req.headers.authorization);++ const json = (status, body) => { const t = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(t); };++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const body = raw ? JSON.parse(raw) : null;++ if (req.method !== 'GET') calls.push({ method: req.method, path: url.pathname, who, body });++ const m = /^\/api\/(vods|clips)\/(\d+)(\/title|\/visibility)?$/.exec(url.pathname);++ if (m) {++ const store = m[1] === 'vods' ? vods : clips;++ const item = store[m[2]];++ if (!item) return json(404, { error: 'not found' });++ const owns = who && who === item.username;++ if (req.method === 'GET') {++ if (item.visibility === 'private' && !owns) return json(404, { error: 'not found' });++ return json(200, m[1] === 'vods' ? { vod: item, clips: [] } : { clip: item });++ }++ if (!who) return json(401, { error: 'Authentication required' });++ if (!owns) return json(403, { error: 'Not authorized' });++ if (req.method === 'DELETE') { delete store[m[2]]; return json(200, { message: 'deleted' }); }++ Object.assign(item, body);++ return json(200, m[1] === 'vods' ? { vod: item } : { message: 'ok' });++ }++ if (url.pathname === '/api/vods') return json(200, { vods: Object.values(vods).filter((v) => v.visibility === 'public'), hasMore: false });++ if (url.pathname === '/api/clips') return json(200, { clips: Object.values(clips).filter((c) => c.visibility === 'public') });++ return json(404, { error: 'not found' });++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, vods, clips, calls, close: () => new Promise((r) => server.close(r)) })));++}++++function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {++ return new Promise((resolve, reject) => {++ const u = new URL(base + path);++ const h = { Host: 'openvibe.video' };++ if (cookie) h.cookie = cookie;++ if (same && method !== 'GET') h['sec-fetch-site'] = 'same-origin';++ let data;++ if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; h['content-length'] = data.length; }++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++++(async () => {++ const live = await startLive();++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url } });++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++ const kimCookie = t.signIn(kim);++ const samCookie = t.signIn(sam);++++ await check('only the channel sees the Manage panel', async () => {++ const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });++ assert.strictEqual(mine.status, 200);++ assert.ok(mine.text.includes('Manage this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');++ assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<option value="public" selected>'), 'prefilled');++ const other = await vreq(t.base, '/v/101', { cookie: samCookie });++ assert.ok(!other.text.includes('Manage this recording'), 'not for someone else');++ const anon = await vreq(t.base, '/v/101');++ assert.ok(!anon.text.includes('Manage this recording'), 'not signed out');++ });++++ await check('an edit is made on Live as the person, then the page shows it', async () => {++ await vreq(t.base, '/v/101'); // warm the read cache++ const r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim renamed', description: 'Second', visibility: 'unlisted' } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ assert.strictEqual(r.headers.location, '/v/101?saved=1');++ const call = live.calls.pop();++ assert.deepStrictEqual(call, { method: 'PUT', path: '/api/vods/101', who: 'kim', body: { title: 'Kim renamed', description: 'Second', visibility: 'unlisted' } });++ const after = await vreq(t.base, '/v/101?saved=1', { cookie: kimCookie });++ assert.ok(after.text.includes('Saved on OpenVibe.Live.'), 'a saved notice');++ assert.ok(after.text.includes('<h1 class="ovv-title">Kim renamed</h1>'), 'the cached read was dropped');++ });++++ await check('forms are validated, same-origin, and Live\'s refusal is shown', async () => {++ const before = live.calls.length;++ let r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: '', visibility: 'public' } });++ assert.strictEqual(r.status, 422);++ r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: 'x', visibility: 'friends' } });++ assert.strictEqual(r.status, 422);++ r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, same: false, form: { title: 'x', visibility: 'public' } });++ assert.strictEqual(r.status, 403, 'cross-site');++ r = await vreq(t.base, '/v/101/manage', { method: 'POST', form: { title: 'x', visibility: 'public' } });++ assert.strictEqual(r.status, 401, 'signed out');++ assert.strictEqual(live.calls.length, before, 'Live was never called');++ r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: samCookie, form: { title: 'Taken', visibility: 'public' } });++ assert.strictEqual(r.status, 403);++ assert.ok(r.text.includes('OpenVibe.Live said: Not authorized'), r.text.slice(0, 400));++ assert.strictEqual(live.vods[101].title, 'Kim renamed', 'nothing changed');++ });++++ await check('a private recording is shown to its owner only, never indexed', async () => {++ assert.strictEqual((await vreq(t.base, '/v/103')).status, 404);++ assert.strictEqual((await vreq(t.base, '/v/103', { cookie: samCookie })).status, 404);++ const r = await vreq(t.base, '/v/103', { cookie: kimCookie });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('Private: only you'), 'a private notice');++ assert.ok(r.text.includes('noindex'), 'noindex');++ assert.match(String(r.headers['cache-control']), /private/);++ assert.ok(r.text.includes('Manage this recording'), 'so the owner can make it public again');++ });++++ await check('a clip\'s maker changes only what changed; deletes need the box ticked', async () => {++ const n = live.calls.length;++ let r = await vreq(t.base, '/c/201/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim clip 2', visibility: 'public' } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ assert.deepStrictEqual(live.calls.slice(n).map((c) => `${c.method} ${c.path}`), ['PUT /api/clips/201/title'], 'the title only');++ r = await vreq(t.base, '/c/201/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim clip 2', visibility: 'unlisted' } });++ assert.deepStrictEqual(live.calls.slice(n + 1).map((c) => `${c.method} ${c.path}`), ['PUT /api/clips/201/visibility']);++ r = await vreq(t.base, '/c/201/delete', { method: 'POST', cookie: kimCookie, form: {} });++ assert.strictEqual(r.status, 422, 'no confirmation');++ r = await vreq(t.base, '/c/201/delete', { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.location, '/@kim?deleted=1');++ assert.ok(!live.clips[201], 'deleted on Live');++ });++++ await check('a recording deleted from Video is gone from its channel page', async () => {++ await vreq(t.base, '/@kim'); // warm the list cache++ const r = await vreq(t.base, '/v/101/delete', { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });++ assert.strictEqual(r.status, 303);++ const ch = await vreq(t.base, '/@kim?deleted=1', { cookie: kimCookie });++ assert.ok(ch.text.includes('Deleted on OpenVibe.Live.'), 'a deleted notice');++ assert.ok(!ch.text.includes('/v/101"'), 'the list cache was dropped');++ assert.strictEqual((await vreq(t.base, '/v/101')).status, 404);++ });++++ await t.close();++ await live.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -247,7 +247,7 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('Transcript'));assert.ok(r.text.includes('A public overview'));assert.ok(r.text.includes('/@streamer'));-- assert.ok(r.text.includes('Manage on OpenVibe.Live'));++ assert.ok(!r.text.includes('Manage this recording'), 'owner tools only for the channel (video-owner.test.js)');assert.ok(r.text.includes('Discuss on OpenVibe.Community'));assert.ok(r.text.includes('data-ovv-chapter'));assert.ok(r.text.includes('data-ovv-seek'));@@@ -262,7 +262,7 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('Watch in the full recording'));assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');assert.ok(r.text.includes('Discuss on OpenVibe.Community'));-- assert.ok(r.text.includes('Manage on OpenVibe.Live'));++ assert.ok(!r.text.includes('Manage this clip'), 'owner tools only for its maker or channel (video-owner.test.js)');});await check('an unlisted VOD is reachable but noindex', async () => {
@@ -127,6 +127,13 @@ Live's `POST /api/views/:kind/:id`, so Live counts the view for the viewer's own
@@ -127,6 +127,13 @@ Live's `POST /api/views/:kind/:id`, so Live counts the view for the viewer's own
come from the loopback address). The CSP allows Media's hosts (openvibe.media, its shield edge.openvibe.media and
come from the loopback address). The CSP allows Media's hosts (openvibe.media, its shield edge.openvibe.media and
the presigned R2/B2 hosts it may redirect a file to) and Live's origin for the beacon.
the presigned R2/B2 hosts it may redirect a file to) and Live's origin for the beacon.
+**Owner tools.** A recording's channel, and a clip's maker or channel, see a "Manage" panel on the watch page: title,
+description (recordings), visibility and delete. Each change is a same-origin web form that openvibe.video sends to
+OpenVibe.Live's own API as the signed-in person (their Network token as a Bearer, refreshed once if it expired), so
+Live's rules decide who may do what and its refusal is shown as it is; the cached reads of the item and of the lists
+are dropped after a change. A private item is shown to its owner only (noindex, never cached). New clips are still cut
+on Live.
+
The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link to
The VOD page also shows a chat replay of the stream (from OpenVibe.Chat) and read-only comments with a link to
the OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its own
the OpenVibe.Community thread. The video host signs in through the same Network SSO as the drive, with its own
callback URI (`https://openvibe.video/auth/callback`); each host's session cookie is host-only, so signing in on one
callback URI (`https://openvibe.video/auth/callback`); each host's session cookie is host-only, so signing in on one
@@ -137,6 +144,8 @@ does not sign in on the other.
@@ -137,6 +144,8 @@ does not sign in on the other.
| `/` | anyone | what OpenVibe.Video is, then the latest recordings and clips |
| `/` | anyone | what OpenVibe.Video is, then the latest recordings and clips |
| `/v/:id` | anyone | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips, add-to-playlist |
| `/v/:id` | anyone | a VOD watch page: player, AI chapters and captions, chat replay, comments, its clips, add-to-playlist |
| `/c/:id` | anyone | a clip watch page: player, attribution, the full-recording link, add-to-playlist |
| `/c/:id` | anyone | a clip watch page: player, attribution, the full-recording link, add-to-playlist |
+| `POST /v/:id/manage`, `POST /v/:id/delete` | the recording's channel (Live decides) | edit title, description and visibility; delete (a ticked box) |
+| `POST /c/:id/manage`, `POST /c/:id/delete` | the clip's maker or channel (Live decides) | edit title and visibility; delete |
| `/@:username` | anyone | a channel's recordings (and clips), newest first, 24 per page |
| `/@:username` | anyone | a channel's recordings (and clips), newest first, 24 per page |
| `/playlists` | the owner | their playlists: create, rename, change visibility, delete |
| `/playlists` | the owner | their playlists: create, rename, change visibility, delete |
| `/playlists/:id` | anyone with the link | one playlist: public and unlisted are open by link (unlisted noindex), private only to the owner |
| `/playlists/:id` | anyone with the link | one playlist: public and unlisted are open by link (unlisted noindex), private only to the owner |
@@ -172,3 +172,20 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -172,3 +172,20 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-item-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ovv-item-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ovv-item-row--gone .ovv-item-title { color: var(--ovv-faint); font-weight: 500; }
.ovv-item-row--gone .ovv-item-title { color: var(--ovv-faint); font-weight: 500; }
.ovv-playlist-head { padding: 8px 0 4px; }
.ovv-playlist-head { padding: 8px 0 4px; }
+
+/* Owner tools: the Manage panel on a recording or clip its channel (or maker) owns. */
+.ovv-owner { border-color: var(--ovv-line-strong); }
+.ovv-owner h2 { display: flex; align-items: center; gap: 8px; }
+.ovv-owner-form { display: grid; gap: 12px; margin-top: 10px; }
+.ovv-field { display: grid; gap: 5px; }
+.ovv-field > span { font-size: .82rem; font-weight: 700; color: var(--ovv-dim); }
+.ovv-field input, .ovv-field textarea, .ovv-field select { font: inherit; width: 100%; box-sizing: border-box; min-height: 38px; padding: 7px 10px; border-radius: 9px; border: 1px solid var(--ovv-line-strong); background: var(--bg-input, var(--bg-secondary, #0f1729)); color: inherit; }
+.ovv-field textarea { resize: vertical; min-height: 76px; }
+.ovv-field input:focus-visible, .ovv-field textarea:focus-visible, .ovv-field select:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 1px; border-color: var(--ovv-accent); }
+.ovv-owner-form .ovv-fine { margin: -4px 0 0; }
+.ovv-owner-form .ovv-btn { justify-self: start; }
+.ovv-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--ovv-line); }
+.ovv-danger > summary { cursor: pointer; font-size: .88rem; font-weight: 700; color: var(--danger, #f87171); }
+.ovv-danger > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 2px; border-radius: 4px; }
+.ovv-owner-delete { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }
+.ovv-watch--clip .ovv-owner { max-width: 640px; }
@@ -20,6 +20,15 @@ function isId(v) {
@@ -20,6 +20,15 @@ function isId(v) {
return /^[1-9]\d*$/.test(String(v)) && Number.isSafeInteger(Number(v)) && Number(v) > 0;
return /^[1-9]\d*$/.test(String(v)) && Number.isSafeInteger(Number(v)) && Number(v) > 0;
}
}
+/** Live answered an owner call with a 4xx: its status and its own message, shown to the person. */
+class LiveRefusedError extends Error {
+ constructor(status, message) {
+ super(message);
+ this.name = 'LiveRefusedError';
+ this.status = status;
+ }
+}
+
/** Live was down or answered nonsense: the pages turn this into a 502. */
/** Live was down or answered nonsense: the pages turn this into a 502. */
class LiveUnavailableError extends Error {
class LiveUnavailableError extends Error {
constructor(message) {
constructor(message) {
@@ -82,7 +91,56 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -82,7 +91,56 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
return remember(url, body);
return remember(url, body);
}
}
+ /**
+ * A call made AS the signed-in person (their Network token as a Bearer; Live checks the issuer and decides what
+ * they may do), never cached. 2xx → the JSON body; a 4xx → LiveRefusedError carrying Live's status and message
+ * (a 401 lets the caller refresh the session and try once more); anything else → LiveUnavailableError.
+ */
+ async function asPerson(token, method, path, body) {
+ let res;
+ try {
+ res = await fetchImpl(`${config.video.liveUrl}${path}`, {
+ method,
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ body: body ? JSON.stringify(body) : undefined,
+ credentials: 'omit',
+ cache: 'no-store',
+ signal: AbortSignal.timeout(TIMEOUT_MS * 2),
+ });
+ } catch (err) {
+ log.warn('[OpenVibe.Video] Live did not answer an owner call', err && err.name);
+ throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
+ }
+ const data = await res.json().catch(() => null);
+ if (res.ok) return data || {};
+ if (res.status >= 400 && res.status < 500) {
+ throw new LiveRefusedError(res.status, (data && typeof data.error === 'string' && data.error.slice(0, 200)) || 'OpenVibe.Live refused that.');
+ }
+ throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
+ }
+
+ /** After an owner's change: drop the cached reads of this item and every cached list, so pages show it at once. */
+ function forget(kind, id) {
+ const one = `${config.video.liveUrl}/api/${kind === 'clip' ? 'clips' : 'vods'}/${id}`;
+ for (const key of [...cache.keys()]) {
+ if (key === one || key.startsWith(`${one}/`) || key.includes('/api/vods?') || key.includes('/api/clips?') || key.includes('/api/content/feed?')) cache.delete(key);
+ }
+ }
+
return {
return {
+ /** The person's own view of one item (a private one they own or may moderate included); null when Live says 404. */
+ async vodAs(token, id) {
+ try { return await asPerson(token, 'GET', `/api/vods/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }
+ },
+ async clipAs(token, id) {
+ try { return await asPerson(token, 'GET', `/api/clips/${id}`); } catch (err) { if (err.status === 404) return null; throw err; }
+ },
+ updateVod: (token, id, fields) => asPerson(token, 'PUT', `/api/vods/${id}`, fields),
+ deleteVod: (token, id) => asPerson(token, 'DELETE', `/api/vods/${id}`),
+ setClipTitle: (token, id, title) => asPerson(token, 'PUT', `/api/clips/${id}/title`, { title }),
+ setClipVisibility: (token, id, visibility) => asPerson(token, 'PUT', `/api/clips/${id}/visibility`, { visibility }),
+ deleteClip: (token, id) => asPerson(token, 'DELETE', `/api/clips/${id}`),
+ forget,
feed({ type, limit, cursor = null }) {
feed({ type, limit, cursor = null }) {
const qs = new URLSearchParams({ type, limit: String(limit) });
const qs = new URLSearchParams({ type, limit: String(limit) });
// Live's feed pages by an opaque cursor (its `next`); only a plain token is passed on.
// Live's feed pages by an opaque cursor (its `next`); only a plain token is passed on.
@@ -114,4 +172,4 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -114,4 +172,4 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
};
};
}
}
-module.exports = { createLiveClient, LiveUnavailableError, isId, USERNAME_RE };
+module.exports = { createLiveClient, LiveUnavailableError, LiveRefusedError, isId, USERNAME_RE };
@@ -25,7 +25,7 @@ const { asyncRouter } = require('../http/router');
@@ -25,7 +25,7 @@ const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const { html, notice } = require('../render/html');
const { html, notice } = require('../render/html');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
-const { createLiveClient } = require('./live-client');
+const { createLiveClient, LiveRefusedError } = require('./live-client');
const { createChatClient } = require('./chat-client');
const { createChatClient } = require('./chat-client');
const { createVideoApiRoutes } = require('./api');
const { createVideoApiRoutes } = require('./api');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
@@ -354,6 +354,63 @@ function createVideoRoutes(ctx) {
@@ -354,6 +354,63 @@ function createVideoRoutes(ctx) {
}
}
const visibilityLabel = (v) => (v === 'public' ? 'Public' : (v === 'unlisted' ? 'Unlisted' : 'Private'));
const visibilityLabel = (v) => (v === 'public' ? 'Public' : (v === 'unlisted' ? 'Unlisted' : 'Private'));
+ // ── Owner tools ─────────────────────────────────────────
+ // A recording's channel and a clip's maker or channel see a Manage panel. The panel only decides what to show:
+ // every change is made on OpenVibe.Live AS the signed-in person (their Network token as a Bearer, through
+ // sso.asViewer so an expired session is refreshed once), and Live decides whether they may make it.
+ const VISIBILITIES = ['public', 'unlisted', 'private'];
+ const sameName = (a, b) => Boolean(a && b && String(a).toLowerCase() === String(b).toLowerCase());
+ const isStaff = (req) => ['admin', 'global_mod'].includes(String((req.viewer && req.viewer.role) || ''));
+ function mayManage(req, kind, item) {
+ if (!signedIn(req) || !item) return false;
+ if (isStaff(req)) return true;
+ const me = req.viewer.username;
+ if (kind === 'vod') return sameName(me, item.username);
+ return sameName(me, item.username) || sameName(me, item.source_streamer_username) || sameName(me, item.channel_username);
+ }
+ const asPerson = (req, res, fn) => ctx.sso.asViewer(req, res, fn);
+ /** A signed-in person's own view of an item Live hides from everyone else (private), or null. */
+ async function ownView(req, res, kind, id) {
+ if (!signedIn(req)) return null;
+ try {
+ const data = await asPerson(req, res, (token) => (kind === 'vod' ? live.vodAs(token, id) : live.clipAs(token, id)));
+ const item = data && (kind === 'vod' ? data.vod : data.clip);
+ return item && mayManage(req, kind, item) ? data : null;
+ } catch (err) {
+ if (err instanceof LiveRefusedError) return null;
+ throw err;
+ }
+ }
+ function ownerRefused(req, res, err, back) {
+ const refused = err instanceof LiveRefusedError;
+ if (!refused && !(err && err.liveUnavailable)) throw err;
+ const status = refused ? (err.status === 401 ? 401 : err.status === 404 ? 404 : 403) : 502;
+ return page(req, res, {
+ title: 'Not saved',
+ cache: cache.htmlHeaders({ private: true }),
+ body: html`<h1>That was not saved</h1>${notice(refused ? `OpenVibe.Live said: ${err.message}` : err.message, 'bad')}<p><a class="ovv-btn" href="${back}">Go back</a></p>`,
+ }, status);
+ }
+ const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');
+ const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);
+ function ownerPanel(kind, id, item) {
+ const what = kind === 'vod' ? 'recording' : 'clip';
+ const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;
+ return html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><h2 id="h-owner">Manage this ${what}</h2>
+<form class="ovv-owner-form" method="post" action="${action}/manage">
+<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="200" value="${item.title || ''}" required></label>
+${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="2000">${item.description || ''}</textarea></label>` : ''}
+<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(item.visibility || 'public')}</select></label>
+<p class="ovv-fine">Public is listed and searchable; unlisted opens only by its link; private only for you and staff.</p>
+<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>
+</form>
+<details class="ovv-danger"><summary>Delete this ${what}</summary>
+<form class="ovv-owner-delete" method="post" action="${action}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>
+</details>
+${kind === 'vod' ? html`<p class="ovv-fine">New clips are still cut on <a href="${livePublic}/vod/${id}">OpenVibe.Live</a>.</p>` : ''}
+</section>`;
+ }
+
// The shell's navbar/footer scripts come from the site's own /shared copy, and the one
// The shell's navbar/footer scripts come from the site's own /shared copy, and the one
// product stylesheet/script live in public/.
// product stylesheet/script live in public/.
r.use('/shared', ovServe.handler());
r.use('/shared', ovServe.handler());
@@ -517,9 +574,15 @@ function createVideoRoutes(ctx) {
@@ -517,9 +574,15 @@ function createVideoRoutes(ctx) {
const id = String(req.params.id || '');
const id = String(req.params.id || '');
if (!live.isId(id)) return notFound(req, res);
if (!live.isId(id)) return notFound(req, res);
- const data = await live.vod(id);
+ let data = await live.vod(id);
+ let ownOnly = false;
+ if (!data || !data.vod) {
+ data = await ownView(req, res, 'vod', id);
+ ownOnly = true;
+ }
if (!data || !data.vod) return notFound(req, res);
if (!data || !data.vod) return notFound(req, res);
const vod = data.vod;
const vod = data.vod;
+ const manageable = mayManage(req, 'vod', vod);
const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];
const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];
const segs = transcriptSegments(vod);
const segs = transcriptSegments(vod);
@@ -549,7 +612,6 @@ function createVideoRoutes(ctx) {
@@ -549,7 +612,6 @@ function createVideoRoutes(ctx) {
const channelName = vod.display_name || vod.username || null;
const channelName = vod.display_name || vod.username || null;
const localChannel = vod.username ? `/@${vod.username}` : null;
const localChannel = vod.username ? `/@${vod.username}` : null;
const liveChannel = `${livePublic}/@${vod.username}`;
const liveChannel = `${livePublic}/@${vod.username}`;
- const manage = `${livePublic}/vod/${id}`;
const metaParts = [];
const metaParts = [];
if (vod.created_at) metaParts.push(html`<time datetime="${vod.created_at}">${dateLabel(vod.created_at)}</time>`);
if (vod.created_at) metaParts.push(html`<time datetime="${vod.created_at}">${dateLabel(vod.created_at)}</time>`);
if (vod.duration_seconds != null) metaParts.push(durationLabel(vod.duration_seconds));
if (vod.duration_seconds != null) metaParts.push(durationLabel(vod.duration_seconds));
@@ -563,13 +625,15 @@ function createVideoRoutes(ctx) {
@@ -563,13 +625,15 @@ function createVideoRoutes(ctx) {
const hlsAttr = hlsAttrs(vod);
const hlsAttr = hlsAttrs(vod);
return page(req, res, {
return page(req, res, {
- index: !unlisted,
- noindex: unlisted,
+ index: !unlisted && !ownOnly,
+ noindex: unlisted || ownOnly,
+ ...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: vod.title || 'Stream recording',
title: vod.title || 'Stream recording',
description: vod.description || vod.ai_overview || '',
description: vod.description || vod.ai_overview || '',
scripts: ['js/video.js'],
scripts: ['js/video.js'],
body: html`<div class="ovv-watch">
body: html`<div class="ovv-watch">
<div class="ovv-main">
<div class="ovv-main">
+${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(vod.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>
<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(vod.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<div class="ovv-byline">
<div class="ovv-byline">
@@ -584,11 +648,11 @@ ${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcri
@@ -584,11 +648,11 @@ ${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcri
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><h2 id="h-comments">Comments</h2>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.</p>`}${threadUrl ? html`<p class="ovv-comments-link"><a class="ovv-btn" href="${threadUrl}">Join the discussion on OpenVibe.Community</a></p>` : ''}</section>` : ''}
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><h2 id="h-comments">Comments</h2>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.</p>`}${threadUrl ? html`<p class="ovv-comments-link"><a class="ovv-btn" href="${threadUrl}">Join the discussion on OpenVibe.Community</a></p>` : ''}</section>` : ''}
</div>
</div>
<aside class="ovv-side">
<aside class="ovv-side">
+${manageable ? ownerPanel('vod', id, vod) : ''}
${up.html}
${up.html}
${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}
${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}
${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}
${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown.</p>` : ''}</section>` : ''}
${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
-<p class="ovv-manage"><a href="${manage}">Manage on OpenVibe.Live</a> <span class="ovv-fine">· owner tools (visibility, clips, delete) stay on Live for now</span></p>
</aside>
</aside>
</div>`,
</div>`,
});
});
@@ -599,9 +663,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -599,9 +663,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
const id = String(req.params.id || '');
const id = String(req.params.id || '');
if (!live.isId(id)) return notFound(req, res);
if (!live.isId(id)) return notFound(req, res);
- const data = await live.clip(id);
+ let data = await live.clip(id);
+ let ownOnly = false;
+ if (!data || !data.clip) {
+ data = await ownView(req, res, 'clip', id);
+ ownOnly = true;
+ }
if (!data || !data.clip) return notFound(req, res);
if (!data || !data.clip) return notFound(req, res);
const clip = data.clip;
const clip = data.clip;
+ const manageable = mayManage(req, 'clip', clip);
let threadUrl = null;
let threadUrl = null;
try {
try {
@@ -622,13 +692,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -622,13 +692,15 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
const hlsAttr = hlsAttrs(clip);
const hlsAttr = hlsAttrs(clip);
return page(req, res, {
return page(req, res, {
- index: !unlisted,
- noindex: unlisted,
+ index: !unlisted && !ownOnly,
+ noindex: unlisted || ownOnly,
+ ...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: clip.title || 'Clip',
title: clip.title || 'Clip',
description: clip.ai_overview || '',
description: clip.ai_overview || '',
scripts: ['js/video.js'],
scripts: ['js/video.js'],
body: html`<div class="ovv-watch ovv-watch--clip">
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-main">
<div class="ovv-main">
+${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}
<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>
<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<div class="ovv-byline">
<div class="ovv-byline">
@@ -638,13 +710,91 @@ ${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_a
@@ -638,13 +710,91 @@ ${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_a
</div>
</div>
${up.html}
${up.html}
${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><p class="ovv-fine">Written by AI from the clip.</p></section>` : ''}
${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><p class="ovv-fine">Written by AI from the clip.</p></section>` : ''}
-<p class="ovv-manage"><a href="${livePublic}/clip/${id}">Manage on OpenVibe.Live</a></p>
+${manageable ? ownerPanel('clip', id, clip) : ''}
</div>
</div>
</div>`,
</div>`,
});
});
});
});
// ── Channel page ────────────────────────────────────────
// ── Channel page ────────────────────────────────────────
+ // ── Owner changes (web forms, same-origin, as the person on Live) ──
+ function ownerFields(b, kind) {
+ const title = String(b.title || '').trim();
+ const description = String(b.description || '').trim();
+ const visibility = String(b.visibility || '');
+ if (!title || title.length > 200) return { error: 'A title is 1 to 200 characters.' };
+ if (kind === 'vod' && description.length > 2000) return { error: 'A description is at most 2,000 characters.' };
+ if (!VISIBILITIES.includes(visibility)) return { error: 'Choose public, unlisted or private.' };
+ return { title, description, visibility };
+ }
+ const fieldError = (req, res, text, back) => page(req, res, {
+ title: 'Not saved', cache: cache.htmlHeaders({ private: true }),
+ body: html`<h1>That was not saved</h1>${notice(text, 'bad')}<p><a class="ovv-btn" href="${back}">Go back</a></p>`,
+ }, 422);
+
+ r.post('/v/:id/manage', mustSignIn('here', 'Sign in to manage your recording.'), async (req, res) => {
+ const id = String(req.params.id || '');
+ if (!live.isId(id)) return notFound(req, res);
+ const back = `/v/${id}`;
+ if (!sameSite(req, res, back)) return undefined;
+ const f = ownerFields(req.body || {}, 'vod');
+ if (f.error) return fieldError(req, res, f.error, back);
+ try {
+ await asPerson(req, res, (token) => live.updateVod(token, id, { title: f.title, description: f.description, visibility: f.visibility }));
+ } catch (err) { return ownerRefused(req, res, err, back); }
+ live.forget('vod', id);
+ return res.redirect(303, `${back}?saved=1`);
+ });
+
+ r.post('/v/:id/delete', mustSignIn('here', 'Sign in to manage your recording.'), async (req, res) => {
+ const id = String(req.params.id || '');
+ if (!live.isId(id)) return notFound(req, res);
+ const back = `/v/${id}`;
+ if (!sameSite(req, res, back)) return undefined;
+ if ((req.body || {}).confirm !== '1') return fieldError(req, res, 'Tick the box to confirm the deletion.', back);
+ try {
+ await asPerson(req, res, (token) => live.deleteVod(token, id));
+ } catch (err) { return ownerRefused(req, res, err, back); }
+ live.forget('vod', id);
+ return res.redirect(303, req.viewer.username && live.isUsername(req.viewer.username) ? `/@${req.viewer.username}?deleted=1` : '/?deleted=1');
+ });
+
+ r.post('/c/:id/manage', mustSignIn('here', 'Sign in to manage your clip.'), async (req, res) => {
+ const id = String(req.params.id || '');
+ if (!live.isId(id)) return notFound(req, res);
+ const back = `/c/${id}`;
+ if (!sameSite(req, res, back)) return undefined;
+ const f = ownerFields(req.body || {}, 'clip');
+ if (f.error) return fieldError(req, res, f.error, back);
+ // Two Live calls: the title (the clip's maker, the channel or staff) and the visibility (the channel or staff).
+ // Only what changed is sent, so a maker who keeps the visibility is never refused for it.
+ let current = null;
+ try {
+ const d = await asPerson(req, res, (token) => live.clipAs(token, id));
+ current = d && d.clip;
+ } catch (err) { return ownerRefused(req, res, err, back); }
+ if (!current) return notFound(req, res);
+ try {
+ if (f.title !== String(current.title || '')) await asPerson(req, res, (token) => live.setClipTitle(token, id, f.title));
+ if (f.visibility !== String(current.visibility || '')) await asPerson(req, res, (token) => live.setClipVisibility(token, id, f.visibility));
+ } catch (err) { live.forget('clip', id); return ownerRefused(req, res, err, back); }
+ live.forget('clip', id);
+ return res.redirect(303, `${back}?saved=1`);
+ });
+
+ r.post('/c/:id/delete', mustSignIn('here', 'Sign in to manage your clip.'), async (req, res) => {
+ const id = String(req.params.id || '');
+ if (!live.isId(id)) return notFound(req, res);
+ const back = `/c/${id}`;
+ if (!sameSite(req, res, back)) return undefined;
+ if ((req.body || {}).confirm !== '1') return fieldError(req, res, 'Tick the box to confirm the deletion.', back);
+ try {
+ await asPerson(req, res, (token) => live.deleteClip(token, id));
+ } catch (err) { return ownerRefused(req, res, err, back); }
+ live.forget('clip', id);
+ return res.redirect(303, req.viewer.username && live.isUsername(req.viewer.username) ? `/@${req.viewer.username}?deleted=1` : '/?deleted=1');
+ });
+
r.get('/@:username', async (req, res) => {
r.get('/@:username', async (req, res) => {
const username = String(req.params.username || '');
const username = String(req.params.username || '');
if (!live.isUsername(username)) return notFound(req, res);
if (!live.isUsername(username)) return notFound(req, res);
@@ -682,7 +832,7 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><
@@ -682,7 +832,7 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><
index: true,
index: true,
title: `@${username}`,
title: `@${username}`,
description: `Recordings and clips from @${username} on OpenVibe.Video.`,
description: `Recordings and clips from @${username} on OpenVibe.Video.`,
- body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
+ body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,
${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+/**
+ * OpenVibe.Video owner tools: a recording's channel (and a clip's maker or channel) gets a Manage panel on the watch
+ * page; edits and deletions are made on OpenVibe.Live AS the signed-in person (their Network token as a Bearer), are
+ * same-origin web forms, are validated before Live is called, and show Live's own refusal. A private item is shown
+ * to its owner only (noindex, private), and Live's answer is re-read after a change (the read cache is dropped).
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const usernameOf = (auth) => {
+ const tok = String(auth || '').replace(/^Bearer /, '');
+ try { return JSON.parse(Buffer.from(tok.split('.')[1], 'base64url').toString('utf8')).username || null; } catch { return null; }
+};
+
+function startLive() {
+ const vods = {
+ 101: { id: 101, title: 'Kim stream', description: 'First', duration_seconds: 45, created_at: '2026-10-12T10:00:00.000Z', playback_url: 'https://openvibe.media/v/101', visibility: 'public', username: 'kim', display_name: 'Kim' },
+ 103: { id: 103, title: 'Kim private', description: '', duration_seconds: 20, created_at: '2026-10-12T09:00:00.000Z', playback_url: 'https://openvibe.media/v/103', visibility: 'private', username: 'kim', display_name: 'Kim' },
+ };
+ const clips = {
+ 201: { id: 201, vod_id: 101, title: 'Kim clip', start_time: 5, end_time: 15, duration_seconds: 10, playback_url: 'https://openvibe.media/c/201', visibility: 'public', username: 'kim', display_name: 'Kim' },
+ };
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ const who = usernameOf(req.headers.authorization);
+ const json = (status, body) => { const t = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(t); };
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const body = raw ? JSON.parse(raw) : null;
+ if (req.method !== 'GET') calls.push({ method: req.method, path: url.pathname, who, body });
+ const m = /^\/api\/(vods|clips)\/(\d+)(\/title|\/visibility)?$/.exec(url.pathname);
+ if (m) {
+ const store = m[1] === 'vods' ? vods : clips;
+ const item = store[m[2]];
+ if (!item) return json(404, { error: 'not found' });
+ const owns = who && who === item.username;
+ if (req.method === 'GET') {
+ if (item.visibility === 'private' && !owns) return json(404, { error: 'not found' });
+ return json(200, m[1] === 'vods' ? { vod: item, clips: [] } : { clip: item });
+ }
+ if (!who) return json(401, { error: 'Authentication required' });
+ if (!owns) return json(403, { error: 'Not authorized' });
+ if (req.method === 'DELETE') { delete store[m[2]]; return json(200, { message: 'deleted' }); }
+ Object.assign(item, body);
+ return json(200, m[1] === 'vods' ? { vod: item } : { message: 'ok' });
+ }
+ if (url.pathname === '/api/vods') return json(200, { vods: Object.values(vods).filter((v) => v.visibility === 'public'), hasMore: false });
+ if (url.pathname === '/api/clips') return json(200, { clips: Object.values(clips).filter((c) => c.visibility === 'public') });
+ return json(404, { error: 'not found' });
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, vods, clips, calls, close: () => new Promise((r) => server.close(r)) })));
+}
+
+function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(base + path);
+ const h = { Host: 'openvibe.video' };
+ if (cookie) h.cookie = cookie;
+ if (same && method !== 'GET') h['sec-fetch-site'] = 'same-origin';
+ let data;
+ if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; h['content-length'] = data.length; }
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+
+(async () => {
+ const live = await startLive();
+ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url } });
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+ const kimCookie = t.signIn(kim);
+ const samCookie = t.signIn(sam);
+
+ await check('only the channel sees the Manage panel', async () => {
+ const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });
+ assert.strictEqual(mine.status, 200);
+ assert.ok(mine.text.includes('Manage this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');
+ assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<option value="public" selected>'), 'prefilled');
+ const other = await vreq(t.base, '/v/101', { cookie: samCookie });
+ assert.ok(!other.text.includes('Manage this recording'), 'not for someone else');
+ const anon = await vreq(t.base, '/v/101');
+ assert.ok(!anon.text.includes('Manage this recording'), 'not signed out');
+ });
+
+ await check('an edit is made on Live as the person, then the page shows it', async () => {
+ await vreq(t.base, '/v/101'); // warm the read cache
+ const r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim renamed', description: 'Second', visibility: 'unlisted' } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ assert.strictEqual(r.headers.location, '/v/101?saved=1');
+ const call = live.calls.pop();
+ assert.deepStrictEqual(call, { method: 'PUT', path: '/api/vods/101', who: 'kim', body: { title: 'Kim renamed', description: 'Second', visibility: 'unlisted' } });
+ const after = await vreq(t.base, '/v/101?saved=1', { cookie: kimCookie });
+ assert.ok(after.text.includes('Saved on OpenVibe.Live.'), 'a saved notice');
+ assert.ok(after.text.includes('<h1 class="ovv-title">Kim renamed</h1>'), 'the cached read was dropped');
+ });
+
+ await check('forms are validated, same-origin, and Live\'s refusal is shown', async () => {
+ const before = live.calls.length;
+ let r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: '', visibility: 'public' } });
+ assert.strictEqual(r.status, 422);
+ r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: 'x', visibility: 'friends' } });
+ assert.strictEqual(r.status, 422);
+ r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, same: false, form: { title: 'x', visibility: 'public' } });
+ assert.strictEqual(r.status, 403, 'cross-site');
+ r = await vreq(t.base, '/v/101/manage', { method: 'POST', form: { title: 'x', visibility: 'public' } });
+ assert.strictEqual(r.status, 401, 'signed out');
+ assert.strictEqual(live.calls.length, before, 'Live was never called');
+ r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: samCookie, form: { title: 'Taken', visibility: 'public' } });
+ assert.strictEqual(r.status, 403);
+ assert.ok(r.text.includes('OpenVibe.Live said: Not authorized'), r.text.slice(0, 400));
+ assert.strictEqual(live.vods[101].title, 'Kim renamed', 'nothing changed');
+ });
+
+ await check('a private recording is shown to its owner only, never indexed', async () => {
+ assert.strictEqual((await vreq(t.base, '/v/103')).status, 404);
+ assert.strictEqual((await vreq(t.base, '/v/103', { cookie: samCookie })).status, 404);
+ const r = await vreq(t.base, '/v/103', { cookie: kimCookie });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('Private: only you'), 'a private notice');
+ assert.ok(r.text.includes('noindex'), 'noindex');
+ assert.match(String(r.headers['cache-control']), /private/);
+ assert.ok(r.text.includes('Manage this recording'), 'so the owner can make it public again');
+ });
+
+ await check('a clip\'s maker changes only what changed; deletes need the box ticked', async () => {
+ const n = live.calls.length;
+ let r = await vreq(t.base, '/c/201/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim clip 2', visibility: 'public' } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ assert.deepStrictEqual(live.calls.slice(n).map((c) => `${c.method} ${c.path}`), ['PUT /api/clips/201/title'], 'the title only');
+ r = await vreq(t.base, '/c/201/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim clip 2', visibility: 'unlisted' } });
+ assert.deepStrictEqual(live.calls.slice(n + 1).map((c) => `${c.method} ${c.path}`), ['PUT /api/clips/201/visibility']);
+ r = await vreq(t.base, '/c/201/delete', { method: 'POST', cookie: kimCookie, form: {} });
+ assert.strictEqual(r.status, 422, 'no confirmation');
+ r = await vreq(t.base, '/c/201/delete', { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.location, '/@kim?deleted=1');
+ assert.ok(!live.clips[201], 'deleted on Live');
+ });
+
+ await check('a recording deleted from Video is gone from its channel page', async () => {
+ await vreq(t.base, '/@kim'); // warm the list cache
+ const r = await vreq(t.base, '/v/101/delete', { method: 'POST', cookie: kimCookie, form: { confirm: '1' } });
+ assert.strictEqual(r.status, 303);
+ const ch = await vreq(t.base, '/@kim?deleted=1', { cookie: kimCookie });
+ assert.ok(ch.text.includes('Deleted on OpenVibe.Live.'), 'a deleted notice');
+ assert.ok(!ch.text.includes('/v/101"'), 'the list cache was dropped');
+ assert.strictEqual((await vreq(t.base, '/v/101')).status, 404);
+ });
+
+ await t.close();
+ await live.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -247,7 +247,7 @@ function countOccurrences(haystack, needle) {
@@ -247,7 +247,7 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Transcript'));
assert.ok(r.text.includes('Transcript'));
assert.ok(r.text.includes('A public overview'));
assert.ok(r.text.includes('A public overview'));
assert.ok(r.text.includes('/@streamer'));
assert.ok(r.text.includes('/@streamer'));
- assert.ok(r.text.includes('Manage on OpenVibe.Live'));
+ assert.ok(!r.text.includes('Manage this recording'), 'owner tools only for the channel (video-owner.test.js)');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('data-ovv-chapter'));
assert.ok(r.text.includes('data-ovv-chapter'));
assert.ok(r.text.includes('data-ovv-seek'));
assert.ok(r.text.includes('data-ovv-seek'));
@@ -262,7 +262,7 @@ function countOccurrences(haystack, needle) {
@@ -262,7 +262,7 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Watch in the full recording'));
assert.ok(r.text.includes('Watch in the full recording'));
assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');
assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
- assert.ok(r.text.includes('Manage on OpenVibe.Live'));
+ assert.ok(!r.text.includes('Manage this clip'), 'owner tools only for its maker or channel (video-owner.test.js)');
});
});
await check('an unlisted VOD is reachable but noindex', async () => {
await check('an unlisted VOD is reachable but noindex', async () => {