openvibers/openvibe.mediahub Commits

Watch pages: YouTube's /watch?v=<watch id> for recordings, clips and uploads (#316)

OP OpenVibers committed 6 days ago · 3977f7ba19 · 20 files, +486 −81

Every video on openvibe.video now has a short, stable, unguessable watch id
(11 URL-safe base64 characters, minted once by OpenVibe.Media, one id space
across VODs and clips) and its canonical page is /watch?v=<id> (&t=<seconds>
for a moment). The old /v/:id, /c/:id and /u/:id answer 301 to it, carrying
the query over.

An upload is a Media VOD, so its watch id is copied from Media into
video_uploads.watch_id (migration 0024_watch_ids.sql: a nullable column and a
partial unique index; old rows are backfilled in a batch at boot). Canonical
links, JSON-LD, the share sheet, the sitemap, llms.txt, IndexNow, the Markdown
twins, the menu card and cards built from data all name the watch page.

test/video-watch-ids.test.js covers the backfill (once, distinct ids), old to
301 to new with t kept, and canonical/share/sitemap/twin at /watch?v=.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9f2e316..26b3529 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
++- YouTube-shaped watch addresses (2026-11-07, the owner: "on openvibe.video we should copy the style of YouTube… /watch,
++ and then like the actual video ID"). Every recording, clip and upload now has a short, stable, unguessable watch id —
++ 11 characters of URL-safe base64, minted once by OpenVibe.Media (one id space across VODs and clips) — and its page is
++ `/watch?v=<id>` (with `&t=<seconds>` for a moment). The old `/v/:id`, `/c/:id` and `/u/:id` answer 301 to it, carrying
++ the query over. An upload's id is copied from its Media VOD into `video_uploads.watch_id` (migration
++ `0024_watch_ids.sql`: a nullable column and a partial unique index; rows written before it are backfilled in a batch at
++ boot). Canonical links, JSON-LD, the share sheet, the sitemap, llms.txt, IndexNow, the Markdown twins, the menu card
++ and every card built from data name the watch page. Tests: `test/video-watch-ids.test.js` (backfill once and distinct,
++ old → 301 → new with `t` kept, canonical/share/sitemap/twin at `/watch?v=`).
++
- The drive has a bin, and the API has the drive's full controls (2026-11-07). Deleting a file — from its page, its
card, or `DELETE /api/v1/files/:id` — puts it in the bin for 30 days instead of deleting it: its share links stop at
once, its bytes stay in OpenVibe.Media, and it still counts against storage. `/files/bin` in the sidebar (with a
ddiff --git a/migrations/0024_watch_ids.sql b/migrations/0024_watch_ids.sql
nnew file mode 100644
iindex 0000000..025392a
---- /dev/null
++++ b/migrations/0024_watch_ids.sql
@@@ -0,0 +1,14 @@
++-- phase: expand
++-- A watch id for every video on openvibe.video (plan track U, 2026-11-07): 11 characters of URL-safe base64 from 8
++-- random bytes, YouTube's shape, so a watch page is openvibe.video/watch?v=<watch id> instead of /u/<vup_…>, which
++-- anyone could walk. Media mints the id for every VOD and clip; an upload is a Media VOD in this tenant, so its watch
++-- id is the VOD's, copied here once so a page, a card and the sitemap can name it without asking Media for every row.
++--
++-- Nullable on purpose: rows written before this release have none, and they are filled in batches afterwards
++-- (uploads.backfillWatchIds, at boot, only where watch_id IS NULL) so the migration itself takes no table rewrite and
++-- holds no lock over the whole table. A row with no id yet still answers at /u/<id>, which Media fills on the way.
++-- Additive only: an older release never reads or writes the column.
++ALTER TABLE video_uploads ADD COLUMN IF NOT EXISTS watch_id text COLLATE "C";
++
++-- The lookup is by id alone and must be one index probe. Partial, so the backfill's many NULLs index nothing.
++CREATE UNIQUE INDEX IF NOT EXISTS ux_video_uploads_watch_id ON video_uploads (watch_id) WHERE watch_id IS NOT NULL;
ddiff --git a/server/app.js b/server/app.js
iindex d03bd06..3fdd900 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -82,6 +82,9 @@ async function createApp(opts = {}) {
const picsDescribe = opts.picsDescribe || require('./pics/describe').createPicDescriber({ s, media, config, fetchImpl, log });
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
const videoUploads = require('./video/uploads').createUploads({ s, media, config, log, events: eventsOutbox, sharing });
++ // Uploads written before their watch ids (migration 0024): fill them a batch at a time in the background, so a card
++ // and a page can name /watch?v=<id> without asking Media for every row. A Media refusal only leaves them for later.
++ if (media.enabled) videoUploads.backfillWatchIds({ limit: 200, log }).catch((err) => log.warn('[MediaHub] watch id backfill:', err.message));
const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
const videoCuts = require('./video/cut').createCuts({ s, media, uploads: videoUploads, videoAi, config, log, spawnImpl: opts.spawnImpl });
const syncKeys = createSyncKeys({ s, config });
ddiff --git a/server/media/client.js b/server/media/client.js
iindex de7f983..268daed 100644
---- a/server/media/client.js
++++ b/server/media/client.js
@@@ -89,6 +89,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
}
const vods = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/vods`;
++ const watch = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/watch`;
const VOD_EXT = { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/x-matroska': 'mkv' };
return {
@@@ -112,6 +113,12 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
/** Close the file: Media remuxes, probes, makes the thumbnail and announces it. A big file takes minutes. */
vodComplete: (vodId) => call(`/${Number(vodId)}/complete`, { root: vods(), method: 'POST', json: {}, timeoutMs: 30 * 60_000 }),
vodGet: (vodId) => call(`/${Number(vodId)}`, { root: vods() }),
++ /**
++ * Which VOD or clip a watch id names ({ kind: 'vod' | 'clip', id }), the id openvibe.video addresses a page
++ * by. One id space across Media's VODs and clips; a 404 means no row holds it. The caller then reads the item
++ * through its own scoped read, exactly as it did by the numeric id.
++ */
++ watchResolve: (watchId) => call(`/${encodeURIComponent(String(watchId || ''))}`, { root: watch() }),
/** A signed URL to read the VOD's file for `ttlS` seconds (private ones too): what OpenVibe.AI reads it through. */
vodSignedUrl: (vodId, ttlS = 3600) => call(`/${Number(vodId)}/signed-url?ttl=${Number(ttlS)}`, { root: vods() }),
vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),
ddiff --git a/server/video/agent-pages.js b/server/video/agent-pages.js
iindex 7734d69..f4442be 100644
---- a/server/video/agent-pages.js
++++ b/server/video/agent-pages.js
@@@ -1,18 +1,18 @@
'use strict';
// The Markdown twins of openvibe.video's public pages (openvibe-shared/agent-layer): the home and the
--// watch pages of a recording (/v/:id) and a clip (/c/:id).
++// watch pages of a recording or clip, at /watch?v=<watch id> (and the numbered /v/:id, /c/:id pages it still answers).
//
// A twin is the page's own render: the video router answers the request in-process, signed out, so a
// twin cannot drift from the page a browser gets and never carries a signed-in person's view. A page
// that is not public (a private recording, a missing item) has no twin.
--const TWIN_PATH_RE = /^\/(?:v|c)\/\d+$/;
++const TWIN_PATH_RE = /^(?:\/(?:v|c)\/\d+|\/watch)$/;
/** A request as an anonymous browser makes it: no session, no cookies, the video host. */
function anonymousRequest(path, search, host) {
const url = path + search;
return {
method: 'GET', url, originalUrl: url, path,
-- params: {}, query: {},
++ params: {}, query: Object.fromEntries(new URLSearchParams(String(search || '').replace(/^\?/, ''))),
headers: { accept: 'text/html', host },
cookies: {}, body: undefined, protocol: 'https', secure: true,
ip: '127.0.0.1', ips: ['127.0.0.1'], hostname: host,
ddiff --git a/server/video/cut.js b/server/video/cut.js
iindex b9771ad..9ab0dac 100644
---- a/server/video/cut.js
++++ b/server/video/cut.js
@@@ -327,7 +327,7 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
return {
enabled, MODES, request, sweep, recover, kick, byId,
-- forUpload: async (row) => (await s.db.many('SELECT * FROM video_cuts WHERE upload_id = $1 ORDER BY created_at DESC LIMIT 10', [row.id]))
++ forUpload: async (row) => (await s.db.many('SELECT c.*, r.watch_id AS result_watch_id FROM video_cuts c LEFT JOIN video_uploads r ON r.id = c.result_upload_id WHERE c.upload_id = $1 ORDER BY c.created_at DESC LIMIT 10', [row.id]))
.map((c) => ({ ...c, ranges: typeof c.ranges === 'string' ? JSON.parse(c.ranges) : c.ranges })),
highlightsPlan: async (row) => planHighlights(videoAi ? await videoAi.forUpload(row) : null, Number(row.duration_seconds)),
};
ddiff --git a/server/video/indexnow.js b/server/video/indexnow.js
iindex 326b4cd..e0cddb1 100644
---- a/server/video/indexnow.js
++++ b/server/video/indexnow.js
@@@ -36,7 +36,8 @@ function createVideoIndexNow({ config, live, indexnow, log = console, intervalMs
for (const it of pages.flatMap((p) => (p && Array.isArray(p.items) ? p.items : []))) {
const id = Number(it && it.id);
if (!Number.isSafeInteger(id) || id <= 0) continue;
-- const path = `${it.kind === 'clip' ? '/c/' : '/v/'}${id}`;
++ // The page's watch address (YouTube's shape); a feed item without an id yet keeps its numbered page.
++ const path = it.watch_id ? `/watch?v=${it.watch_id}` : `${it.kind === 'clip' ? '/c/' : '/v/'}${id}`;
if (seen.has(path)) continue;
seen.add(path);
if (!primed) continue;
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex 3671346..7561abc 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -56,7 +56,8 @@ function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
-- const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}`;
++ // A watch page's address is /watch?v=<watch id>, so its canonical cannot drop the query (the default below does).
++ const canonical = o.canonical || `${String(o.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}`;
const crumbs = (o.crumbs || []).length
? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
: '';
ddiff --git a/server/video/menu-card.js b/server/video/menu-card.js
iindex e269ff9..c271f20 100644
---- a/server/video/menu-card.js
++++ b/server/video/menu-card.js
@@@ -74,7 +74,7 @@ function cardFrom(items, at = Date.now()) {
const item = {
title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),
meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'view' : 'views'} this week`,
-- url: `${BASE}/${it.kind === 'clip' ? 'c' : 'v'}/${Number(it.id)}`,
++ url: `${BASE}${it.watch_id ? `/watch?v=${it.watch_id}` : `/${it.kind === 'clip' ? 'c' : 'v'}/${Number(it.id)}`}`,
};
const image = imageOf(it);
if (image) item.image = image;
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex e25c7e5..3e99c77 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -8,8 +8,9 @@
* / what OpenVibe.Video is: who is live now, the latest uploads, the month's most watched,
* the latest recordings and clips, categories and channels, how it works, questions
* /category/:slug a category's recordings (OpenVibe.Search's facets over Live's recordings)
-- * /v/:id a VOD watch page (player, AI chapters and captions, its clips)
-- * /c/:id a clip watch page
++ * /watch?v=<id> the watch page of a recording, clip or upload (YouTube's shape, one root for all three)
++ * /v/:id a VOD's old address: 301 to /watch?v=<watch id> (and /c/:id, /u/:id likewise)
++ * /c/:id a clip's old address: 301 to its watch page
* /@:username a channel's recordings (and clips), newest first
* /v/:id/captions.vtt the AI transcript as WebVTT captions, with what was heard ("[laughter]")
* /v/:id/chapters.vtt the AI moments as WebVTT chapters
@@@ -205,7 +206,7 @@ async function pipsOf(vod, live) {
from: Number(p.from), to: Number(p.to), offset: Math.max(0, Number(p.offset) || 0),
x: unit(p.x, 0, 1, 0.72), y: unit(p.y, 0, 1, 0.7), w: unit(p.w, 0.08, 0.6, 0.25), muted: p.muted !== false,
title: String(p.title || pv.title || 'Picture-in-picture').slice(0, 120),
-- href: `/v/${String(p.vod_id)}${Number(p.offset) >= 1 ? `?t=${Math.floor(Number(p.offset))}` : ''}`,
++ href: `${pv && pv.watch_id ? `/watch?v=${pv.watch_id}` : `/v/${String(p.vod_id)}`}${Number(p.offset) >= 1 ? `${pv && pv.watch_id ? '&' : '?'}t=${Math.floor(Number(p.offset))}` : ''}`,
});
}
return out;
@@@ -267,6 +268,8 @@ const isoTime = (v) => { const t = Date.parse(String(v || '')); return Number.is
*/
function videoLd({ base, path, item, title, description, chapterList, partOf }) {
const pageUrl = `${base}${path}`;
++ // A watch page's path already carries ?v=<id>, so a moment is a further parameter, not a second '?'.
++ const cut = pageUrl.includes('?') ? '&' : '?';
const thumb = httpsUrl(item.thumbnail_url);
const channel = item.display_name || item.username;
const parts = (chapterList || []).map((m, i, all) => ({
@@@ -274,7 +277,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })
name: String(m.description || `Chapter ${i + 1}`).slice(0, 110),
startOffset: m.offset_seconds,
...(all[i + 1] ? { endOffset: all[i + 1].offset_seconds } : (Number(item.duration_seconds) > m.offset_seconds ? { endOffset: Math.floor(Number(item.duration_seconds)) } : {})),
-- url: `${pageUrl}?t=${m.offset_seconds}`,
++ url: `${pageUrl}${cut}t=${m.offset_seconds}`,
}));
return {
...seo.jsonLd.video({
@@@ -586,7 +589,7 @@ function feedCard(item, { views = false } = {}) {
function vodCard(v) {
return card({
-- href: `/v/${v.id}`,
++ href: v.watch_id ? `/watch?v=${v.watch_id}` : `/v/${v.id}`,
labels: labelsUi.inForce(v),
owner: v.username || null,
thumb: httpsUrl(v.thumbnail_url),
@@@ -597,10 +600,17 @@ function vodCard(v) {
});
}
++/** A watch page's address: /watch?v=<watch id> when the item has one, else the numbered page it redirects from. */
++function watchHref(kind, id, watchId) {
++ if (watchId) return `/watch?v=${watchId}`;
++ if (kind === 'upload') return `/u/${id}`;
++ return `/${kind === 'clip' ? 'c' : 'v'}/${id}`;
++}
++
function clipCard(c) {
const dur = c.duration_seconds != null ? c.duration_seconds : c.duration;
return card({
-- href: `/c/${c.id}`,
++ href: c.watch_id ? `/watch?v=${c.watch_id}` : `/c/${c.id}`,
kind: 'clip',
badge: c.auto_generated ? 'AI clip' : null,
labels: labelsUi.inForce(c),
@@@ -796,7 +806,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
-- const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton });
++ const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton, renderVod, renderClip });
// ── Downloads: /v/:id/download/:file and /c/:id/download/:file (server/video/downloads.js) ──
mountDownloads(r, { live, chat, ownView, asPerson, notFound, base, httpsUrl, log, fetchImpl: ctx.fetchImpl || globalThis.fetch,
@@@ -827,7 +837,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
if (kind === 'upload') {
const row = await ctx.videoUploads.bySeq(itemId);
if (!row || row.status !== 'ready' || row.state !== 'active' || row.visibility === 'private') return null;
-- return { href: `/u/${row.id}`, title: row.title, thumb: httpsUrl(row.thumbnail_url), channel: row.owner_username || null, duration: row.duration_seconds, icon: 'fa-film' };
++ return { href: watchHref('upload', row.id, row.watch_id), title: row.title, thumb: httpsUrl(row.thumbnail_url), channel: row.owner_username || null, duration: row.duration_seconds, icon: 'fa-film' };
}
let info = null;
try {
@@@ -836,7 +846,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
} catch { info = null; }
if (!info) return null;
return {
-- href: kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`,
++ href: watchHref(kind, itemId, info.watch_id),
title: info.title || (kind === 'clip' ? 'Clip' : 'Stream recording'),
thumb: httpsUrl(info.thumbnail_url),
channel: info.username || info.display_name || null,
@@@ -849,9 +859,14 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
async function itemHref(kind, itemId) {
if (kind === 'upload') {
const row = await ctx.videoUploads.bySeq(itemId);
-- return row ? `/u/${row.id}` : null;
++ return row ? watchHref('upload', row.id, row.watch_id) : null;
}
-- return kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`;
++ try {
++ const data = kind === 'vod' ? await live.vod(itemId) : await live.clip(itemId);
++ const info = data && (kind === 'vod' ? data.vod : data.clip);
++ if (info) return watchHref(kind, itemId, info.watch_id);
++ } catch { /* a numbered page still redirects */ }
++ return `/${kind === 'vod' ? 'v' : 'c'}/${itemId}`;
}
async function upNext(req, kind, id) {
@@@ -866,7 +881,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
const nextId = Number(next.item_id);
const nextHref = await itemHref(next.kind, nextId);
if (!nextHref) return { html: '', nextUrl: null };
-- const nextUrl = `${nextHref}?list=${encodeURIComponent(pl.id)}`;
++ const nextUrl = `${nextHref}${nextHref.includes('?') ? '&' : '?'}list=${encodeURIComponent(pl.id)}`;
const info = await itemInfo(next.kind, nextId);
const label = (info && info.title) || `${next.kind === 'vod' ? 'VOD' : next.kind === 'clip' ? 'Clip' : 'Video'} ${nextId}`;
return {
@@@ -1013,7 +1028,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
? `Watch ${n(counts.vods)} stream recordings and ${n(counts.clips)} clips from OpenVibe.Live, or upload and record your own videos, with AI captions and chapters.`
: 'Watch stream recordings and clips from OpenVibe.Live, or upload and record your own videos, with AI captions and chapters.';
const faq = HOME_FAQ(config.videoUploads);
-- const listed = vodItems.map((it) => ({ name: it.title || 'Stream recording', url: `${base}/v/${it.id}` }));
++ const listed = vodItems.map((it) => ({ name: it.title || 'Stream recording', url: `${base}${it.watch_id ? `/watch?v=${it.watch_id}` : `/v/${it.id}`}` }));
return page(req, res, {
index: true,
title: 'OpenVibe.Video: watch streams again and share your videos',
@@@ -1089,7 +1104,7 @@ ${channels.length ? html`<section class="ovv-section" id="channels" aria-labelle
crumbs: [{ label: 'Home', href: '/' }, { label: 'Categories', href: '/#categories' }, { label: cat.label }],
jsonLd: [
seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Video', url: `${base}/` }, { name: cat.label, url: `${base}/category/${slug}` }]),
-- seo.jsonLd.itemList(title, items.map((it) => ({ name: it.title || 'Stream recording', url: `${base}/v/${it.id}` })), { url: `${base}/category/${slug}` }),
++ seo.jsonLd.itemList(title, items.map((it) => ({ name: it.title || 'Stream recording', url: `${base}${it.watch_id ? `/watch?v=${it.watch_id}` : `/v/${it.id}`}` })), { url: `${base}/category/${slug}` }),
],
body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true"><i class="fa-solid ${cat.icon}"></i></span><div><h1>${cat.label}</h1><p class="ovv-meta">${cat.blurb}${count ? ` ${n(count.count)} ${count.count === 1 ? 'recording' : 'recordings'}.` : ''}</p></div></header>
<section class="ovv-section" aria-label="${title}">${grid(items.map((it) => feedCard(it)), 'Nothing in this category yet.')}</section>
@@@ -1189,9 +1204,49 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
return sendVtt(res, renderChaptersVtt(mems, data.vod.duration_seconds));
});
++ /** The recording a numbered address names: public, or the signed-in owner's own view. Null (and 404) when neither. */
++ async function vodForRedirect(req, res, id) {
++ if (!live.isId(id)) { notFound(req, res); return null; }
++ let data = await live.vod(id);
++ if (!data || !data.vod) data = await ownView(req, res, 'vod', id);
++ if (!data || !data.vod) { notFound(req, res); return null; }
++ return data;
++ }
++ /** The clip a numbered address names: public, or the signed-in owner's own view. Null (and 404) when neither. */
++ async function clipForRedirect(req, res, id) {
++ if (!live.isId(id)) { notFound(req, res); return null; }
++ let data = await live.clip(id);
++ if (!data || !data.clip) data = await ownView(req, res, 'clip', id);
++ if (!data || !data.clip) { notFound(req, res); return null; }
++ return data;
++ }
++ /**
++ * 301 to /watch?v=<watch id>, carrying the query over as it was (?t= a moment, ?list= a playlist, ?saved=1 a
++ * notice), so an old address answers the same page and the same moment the new one does.
++ */
++ function watchRedirect(res, watchId, query) {
++ const qs = new URLSearchParams();
++ for (const [k, v] of Object.entries(query || {})) {
++ if (k === 'v' || v == null) continue;
++ for (const x of (Array.isArray(v) ? v : [v])) qs.append(k, String(x));
++ }
++ const tail = qs.toString();
++ return res.redirect(301, `/watch?v=${watchId}${tail ? `&${tail}` : ''}`);
++ }
++
// ── VOD watch page ──────────────────────────────────────
++ // A numbered address answers 301 to the watch address, so an old link, sitemap or share still lands on the page.
r.get('/v/:id', async (req, res) => {
const id = String(req.params.id || '');
++ const data = await vodForRedirect(req, res, id);
++ if (!data) return undefined;
++ if (!data.vod.watch_id) return renderVod(req, res, id);
++ return watchRedirect(res, data.vod.watch_id, req.query);
++ });
++
++ /** A recording's watch page, rendered (as /watch?v=<watch id>, or by its number until it has an id). */
++ async function renderVod(req, res, id) {
++ id = String(id || '');
if (!live.isId(id)) return notFound(req, res);
let data = await live.vod(id);
@@@ -1202,6 +1257,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
}
if (!data || !data.vod) return notFound(req, res);
const vod = data.vod;
++ // The page's one address: /watch?v=<watch id> (the numbered page redirects here until an id exists).
++ const watchPath = vod.watch_id ? `/watch?v=${vod.watch_id}` : `/v/${id}`;
// A part of a stream recorded in parts, once the parts play as one video (Media's recording): its page is the whole
// video's, at the second this part starts (owner, 2026-10-27: "a single vod … that seeks just fine").
const rec = vod.recording;
@@@ -1287,7 +1344,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
if (channelName) moreItems.push({ href: liveChannel, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });
if (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });
// Chat replay, chapters and the transcript, beside the player.
-- const side = companion({ kind: 'vod', id, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });
++ const side = companion({ kind: 'vod', id, watchId: vod.watch_id || null, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });
return page(req, res, {
index: !unlisted && !ownOnly && !thin,
noindex: unlisted || ownOnly || thin,
@@@ -1295,8 +1352,9 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
styles: ['kit.css'],
description: about,
++ canonical: `${base}${watchPath}`,
...(ownOnly || vod.is_recording ? {} : {
-- jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
++ jsonLd: videoLd({ base, path: watchPath, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
scripts: ['js/video.js'],
@@@ -1317,7 +1375,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
--<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ url: `${base}/v/${id}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
</div>
@@@ -1339,11 +1397,20 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
</aside>
</div>`,
});
-- });
++ }
// ── Clip watch page ─────────────────────────────────────
r.get('/c/:id', async (req, res) => {
const id = String(req.params.id || '');
++ const data = await clipForRedirect(req, res, id);
++ if (!data) return undefined;
++ if (!data.clip.watch_id) return renderClip(req, res, id);
++ return watchRedirect(res, data.clip.watch_id, req.query);
++ });
++
++ /** A clip's watch page, rendered (as /watch?v=<watch id>, or by its number until it has an id). */
++ async function renderClip(req, res, id) {
++ id = String(id || '');
if (!live.isId(id)) return notFound(req, res);
let data = await live.clip(id);
@@@ -1354,6 +1421,8 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
}
if (!data || !data.clip) return notFound(req, res);
const clip = data.clip;
++ // The page's one address: /watch?v=<watch id> (the numbered page redirects here until an id exists).
++ const watchPath = clip.watch_id ? `/watch?v=${clip.watch_id}` : `/c/${id}`;
if (clip && clip.ai_overview) clip.ai_overview = String(clip.ai_overview).trim() || null;
const manageable = mayManage(req, 'clip', clip);
@@@ -1379,7 +1448,9 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
// A clip Media is still cutting (or failed to cut) has no file to play yet.
const cutting = Boolean(clip.status) && clip.status !== 'ready';
if (cutting) live.forgetItem('clip', id);
-- const fullRecording = clip.vod_id ? `/v/${clip.vod_id}${clip.start_time != null ? `?t=${clip.start_time}` : ''}` : null;
++ // The recording's watch page (its id, not its number), from the moment this clip starts.
++ const sourceHref = sourceVod && sourceVod !== 'down' && sourceVod.watch_id ? `/watch?v=${sourceVod.watch_id}` : (clip.vod_id ? `/v/${clip.vod_id}` : null);
++ const fullRecording = sourceHref ? `${sourceHref}${clip.start_time != null ? `${sourceHref.includes('?') ? '&' : '?'}t=${clip.start_time}` : ''}` : null;
const sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const attribution = clip.auto_generated ? `AI clip from ${sourceName}'s stream` : `Made by ${madeBy}`;
@@@ -1411,7 +1482,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
if (clip.source_streamer_username) moreItems.push({ href: `${livePublic}/@${clip.source_streamer_username}`, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });
if (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });
// A clip's transcript, when it has one, beside the player as on a recording.
-- const side = companion({ kind: 'clip', id, chat: null, chapters: [], transcript: segs, long: false });
++ const side = companion({ kind: 'clip', id, watchId: clip.watch_id || null, chat: null, chapters: [], transcript: segs, long: false });
return page(req, res, {
index: !unlisted && !ownOnly && !aiClip,
@@@ -1420,8 +1491,9 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
styles: ['kit.css'],
description: clip.ai_overview || factualDescription('clip', clip),
++ canonical: `${base}${watchPath}`,
...(ownOnly || aiClip ? {} : {
-- jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),
++ jsonLd: videoLd({ base, path: watchPath, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: sourceVod && sourceVod !== 'down' && sourceVod.watch_id ? `/watch?v=${sourceVod.watch_id}` : (clip.vod_id ? `/v/${clip.vod_id}` : null) }),
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
scripts: ['js/video.js'],
@@@ -1441,7 +1513,7 @@ ${clipCredit}
<div class="ovv-head-row">
<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
--<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ url: `${base}/c/${id}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
</div>
@@@ -1454,7 +1526,7 @@ ${up.html}
</aside>
</div>`,
});
-- });
++ }
// ── Channel page ────────────────────────────────────────
// ── Owner changes (web forms, same-origin, as the person on Live) ──
@@@ -1716,7 +1788,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
const first = items[0];
const firstHref = first ? await itemHref(first.kind, Number(first.item_id)) : null;
-- const playAllHref = firstHref ? `${firstHref}?list=${encodeURIComponent(pl.id)}` : null;
++ const playAllHref = firstHref ? `${firstHref}${firstHref.includes('?') ? '&' : '?'}list=${encodeURIComponent(pl.id)}` : null;
return page(req, res, {
index: pl.visibility === 'public',
@@@ -1843,9 +1915,11 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
const video = sitemapVideo(it);
// The same thumbnail as an <image:image> on the video's own entry (SEO-17): an image entry needs a URL
// this site really serves, and sitemapVideo only builds one from a thumbnail that exists and is https.
-- return { loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`, lastmod: day(it.created_at), ...(video ? { videos: [video], images: [video.thumbnail_loc] } : {}) };
++ // The address is the watch page (?v=<watch id>); a feed item without an id yet keeps its numbered page.
++ const at = it.watch_id ? `/watch?v=${it.watch_id}` : `${it.kind === 'clip' ? '/c/' : '/v/'}${Number(it.id)}`;
++ return { loc: `${base}${at}`, lastmod: day(it.created_at), ...(video ? { videos: [video], images: [video.thumbnail_loc] } : {}) };
}),
-- ...uploaded.map((x) => ({ loc: `${base}/u/${x.id}`, lastmod: day(x.updated_at) })),
++ ...uploaded.map((x) => ({ loc: `${base}${x.watch_id ? `/watch?v=${x.watch_id}` : `/u/${x.id}`}`, lastmod: day(x.updated_at) })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
].map((u) => (u.lastmod ? u : { ...u, lastmod: undefined }));
sitemapCache = { at: Date.now(), xml: seo.sitemapXml(urls) };
@@@ -1867,7 +1941,7 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
]);
const vodItems = (vods && vods.items) || [];
const clipItems = (clips && clips.items) || [];
-- const links = (items, kind) => items.map((it) => ({ title: it.title || (kind === 'vod' ? 'Recording' : 'Clip'), url: `${base}/${kind === 'vod' ? 'v' : 'c'}/${it.id}`, note: String(it.description || it.ai_summary || it.summary || '').replace(/\s+/g, ' ').trim().slice(0, 600) }));
++ const links = (items, kind) => items.map((it) => ({ title: it.title || (kind === 'vod' ? 'Recording' : 'Clip'), url: `${base}${it.watch_id ? `/watch?v=${it.watch_id}` : `/${kind === 'vod' ? 'v' : 'c'}/${it.id}`}`, note: String(it.description || it.ai_summary || it.summary || '').replace(/\s+/g, ' ').trim().slice(0, 600) }));
return {
name: SITE_NAME,
// AGT-5: each of Video's own pages is linked at its Markdown twin (openvibe-shared 4.108.0 leaves files and
ddiff --git a/server/video/search-index.js b/server/video/search-index.js
iindex 575d9c5..679318f 100644
---- a/server/video/search-index.js
++++ b/server/video/search-index.js
@@@ -28,7 +28,7 @@ function documentFor(row, { videoUrl, ai = null }) {
const transcript = ai && ai.status === 'done' && Array.isArray(ai.segments) ? ai.segments.map((x) => x.text).join(' ') : '';
return {
visibility: 'public',
-- canonical_url: `${videoUrl}/u/${row.id}`,
++ canonical_url: `${videoUrl}${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}`,
title: String(row.title || 'Video').slice(0, 500),
summary: String(row.description || overview || (channel ? `A video by @${channel} on OpenVibe.Video.` : 'A video on OpenVibe.Video.')).slice(0, 4000),
body: [row.title, row.description, overview, transcript.slice(0, 20000)].filter(Boolean).join('\n').slice(0, 48000),
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 4af6971..17f47c4 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -98,7 +98,7 @@ function createUploadApi({ ctx, config }) {
if (!row) return undefined;
try {
const out = await up.complete(row);
-- return res.status(202).json({ video: { id: out.id, status: out.status, url: `/u/${out.id}` } });
++ return res.status(202).json({ video: { id: out.id, status: out.status, url: out.watch_id ? `/watch?v=${out.watch_id}` : `/u/${out.id}` } });
} catch (err) { return problem(res, err); }
});
@@@ -107,7 +107,7 @@ function createUploadApi({ ctx, config }) {
id: c.id, mode: c.mode, status: c.status, progress: Number(c.progress) || 0, error: c.error || null,
source_seconds: c.source_seconds == null ? null : Number(c.source_seconds), kept_seconds: c.kept_seconds == null ? null : Number(c.kept_seconds),
removed_count: c.removed_count == null ? null : Number(c.removed_count),
-- result: c.result_upload_id && c.status === 'done' ? { id: c.result_upload_id, url: `/u/${c.result_upload_id}` } : null,
++ result: c.result_upload_id && c.status === 'done' ? { id: c.result_upload_id, url: c.result_watch_id ? `/watch?v=${c.result_watch_id}` : `/u/${c.result_upload_id}` } : null,
created_at: c.created_at, finished_at: c.finished_at || null,
});
r.post('/:id/cuts', express.json({ limit: '4kb' }), async (req, res) => {
@@@ -131,7 +131,7 @@ function createUploadApi({ ctx, config }) {
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
-- const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton } = h;
++ const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton, renderVod, renderClip } = h;
const up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
@@@ -148,7 +148,7 @@ function mountUploadPages(r, h) {
if (c.status === 'done') {
const shorter = c.source_seconds && c.kept_seconds ? html` · ${durationLabel(c.source_seconds)} → ${durationLabel(c.kept_seconds)}` : '';
const count = c.removed_count ? html` · ${c.removed_count} pause${c.removed_count === 1 ? '' : 's'} out` : '';
-- return html`<li>${what}${shorter}${count} · ${c.result_upload_id ? html`<a href="/u/${c.result_upload_id}">open the copy</a>` : 'done'}</li>`;
++ return html`<li>${what}${shorter}${count} · ${c.result_upload_id ? html`<a href="${c.result_watch_id ? `/watch?v=${c.result_watch_id}` : `/u/${c.result_upload_id}`}">open the copy</a>` : 'done'}</li>`;
}
if (c.status === 'failed') return html`<li>${what} · not made: ${c.error || 'something went wrong'}</li>`;
const pct = c.status === 'rendering' ? html` ${Math.round(Number(c.progress || 0) * 100)}%` : '';
@@@ -226,23 +226,77 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
body: html`<h1>Your videos</h1>
<p><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></p>
${fresh.length ? html`<table class="ovv-table"><thead><tr><th scope="col">Video</th><th scope="col">State</th><th scope="col">Length</th><th scope="col">Views</th><th scope="col">Uploaded</th></tr></thead><tbody>
--${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${statusText(x)}</td><td>${x.duration_seconds ? durationLabel(x.duration_seconds) : ''}</td><td>${Number(x.views) || 0}</td><td>${dateLabel(x.created_at) || ''}</td></tr>`)}
++${fresh.map((x) => html`<tr><td><a href="${x.watch_id ? `/watch?v=${x.watch_id}` : `/u/${x.id}`}">${x.title}</a></td><td>${statusText(x)}</td><td>${x.duration_seconds ? durationLabel(x.duration_seconds) : ''}</td><td>${Number(x.views) || 0}</td><td>${dateLabel(x.created_at) || ''}</td></tr>`)}
</tbody></table>` : html`<p class="muted">Nothing uploaded yet.</p>`}`,
});
});
++ // The numbered address answers 301 to the watch address, so an old link or card still lands on the video.
r.get('/u/:id', async (req, res) => {
-- let row = await up.byId(req.params.id);
++ let row = await visibleUpload(req, res, String(req.params.id || ''));
++ if (!row) return undefined;
++ if (row.status === 'processing') row = await up.refresh(row);
++ const watchId = row.watch_id || await up.ensureWatchId(row);
++ if (!watchId) return renderUpload(req, res, row);
++ const tail = queryTail(req.query);
++ return res.redirect(301, `/watch?v=${watchId}${tail}`);
++ });
++
++ /** The upload a request may see (its owner, staff, a share's people, the public), or null (and 404). */
++ async function visibleUpload(req, res, id) {
++ const row = await up.byId(id);
const who = whoOf(req);
-- if (!up.canSee(row, who)) return notFound(req, res);
++ if (!up.canSee(row, who)) { notFound(req, res); return null; }
// Specific people and Only me: the share row decides, beside the old column. A row written before the
// sharing model (an import, a cut) does not exist yet: the column still decides, failing closed for private.
-- const own = who.requester && row.owner === who.requester;
++ const own = Boolean(who.requester && row.owner === who.requester);
if (!own && !who.staff && ctx.sharing) {
const share = await ctx.sharing.shareOf('video', row.id).catch(() => null);
-- if (share && !await ctx.sharing.canView(req.viewer, 'video', row.id).catch(() => false)) return notFound(req, res);
++ if (share && !await ctx.sharing.canView(req.viewer, 'video', row.id).catch(() => false)) { notFound(req, res); return null; }
}
-- if (row.status === 'processing') row = await up.refresh(row);
++ return row;
++ }
++
++ /** A query as the tail of /watch?v=<id> (its own ?v= is never carried): ?t= a moment, ?list= a playlist. */
++ function queryTail(query) {
++ const qs = new URLSearchParams();
++ for (const [k, v] of Object.entries(query || {})) {
++ if (k === 'v') continue;
++ for (const x of (Array.isArray(v) ? v : [v])) if (x != null) qs.append(k, String(x));
++ }
++ const s = qs.toString();
++ return s ? `&${s}` : '';
++ }
++
++ /**
++ * One watch root for recordings, clips and uploads: /watch?v=<watch id>. Media resolves an id to its VOD or clip
++ * (one id space across the two); an upload of this tenant is its Media VOD, so it renders the upload page, and a
++ * Live recording or clip renders as it did on /v/:id or /c/:id. A local row answers even when Media is unreachable.
++ */
++ r.get('/watch', async (req, res) => {
++ const watchId = String(req.query.v || '');
++ if (!/^[A-Za-z0-9_-]{11}$/.test(watchId)) return notFound(req, res);
++ let row = await up.byWatchId(watchId);
++ let ref = row ? { kind: 'vod', id: Number(row.media_vod_id) } : null;
++ if (!ref) {
++ const out = await ctx.media.watchResolve(watchId);
++ if (out.ok && out.data && (out.data.kind === 'vod' || out.data.kind === 'clip') && Number.isSafeInteger(Number(out.data.id))) ref = out.data;
++ }
++ if (!ref) return notFound(req, res);
++ if (ref.kind === 'clip') return h.renderClip(req, res, String(ref.id));
++ row = row || await up.byMediaVod(Number(ref.id));
++ if (row) {
++ const visible = await visibleUpload(req, res, row.id);
++ if (!visible) return undefined;
++ return renderUpload(req, res, visible);
++ }
++ return h.renderVod(req, res, String(ref.id));
++ });
++
++ /** An upload's watch page, rendered (as /watch?v=<watch id>, or by its /u/<id> until it has an id). */
++ async function renderUpload(req, res, row) {
++ const who = whoOf(req);
++ const own = Boolean(who.requester && row.owner === who.requester);
const manage = own || who.staff;
const ready = row.status === 'ready';
const vod = ready ? await up.playback(row) : null;
@@@ -261,6 +315,8 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
views: Number(row.views) || 0,
});
const thumb = httpsUrl(row.thumbnail_url);
++ // The page's one address: /watch?v=<watch id> (its /u/<id> redirects here until an id exists).
++ const watchPath = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
// Playlists (kind upload, by seq): a ready video anyone else may watch can be saved, and ?list= plays the next one.
const saveForm = ready && row.state === 'active' && row.visibility !== 'private' && playlistForm ? await playlistForm(req, 'upload', row.seq) : '';
@@@ -287,6 +343,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
title: row.title,
styles: ['kit.css'],
description: about,
++ canonical: `${ctx.config.video.url}${watchPath}`,
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
scripts: ready ? ['js/video.js'] : [],
player: !!(ready && vod),
@@@ -295,7 +352,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
${req.query.saved === '1' ? notice('Saved.', 'ok') : ''}${req.query.reported === '1' ? notice('Thanks: the report reached OpenVibe staff.', 'ok') : ''}
${row.state === 'hidden' ? notice('Hidden after reports: only you and OpenVibe staff can see it until staff decide.', 'warn') : ''}
${row.visibility === 'private' && ready ? notice('Private: only you (and OpenVibe staff) can see this video.', 'info') : ''}
--${original && original.state !== 'removed' ? html`<p class="ovv-fine ovv-edited-from">An edited copy of <a href="/u/${original.id}">${original.title}</a>, made by the AI cut.</p>` : ''}
++${original && original.state !== 'removed' ? html`<p class="ovv-fine ovv-edited-from">An edited copy of <a href="${original.watch_id ? `/watch?v=${original.watch_id}` : `/u/${original.id}`}">${original.title}</a>, made by the AI cut.</p>` : ''}
${ready && vod ? html`<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${thumb ? html` poster="${`${thumb}?w=1280&fmt=webp`}" data-ov-thumb="${`${thumb}?w=32&fmt=webp`}" data-ov-ambient` : ''} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${hlsAttrs(vod)}>${tracks}</video></div>`
: html`<div class="ovv-player-wrap ovv-processing" role="status">${row.status === 'processing' ? html`<p><i class="fa-solid fa-gear fa-spin" aria-hidden="true"></i> Processing: OpenVibe.Media is making it playable. This page reloads by itself.</p>`
: row.status === 'failed' ? html`<p>This upload could not be processed. ${row.error || ''}</p>`
@@@ -306,7 +363,7 @@ ${what}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
</div>
--<div class="ovv-byline-actions">${saveForm}${raw(shareButton({ url: `${ctx.config.video.url}/u/${row.id}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}</div>
++<div class="ovv-byline-actions">${saveForm}${raw(shareButton({ url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}</div>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
@@@ -326,7 +383,7 @@ ${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><
<form class="ovv-owner-delete" method="post" action="/u/${row.id}/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></section>` : ''}
${cutsOn ? cutPanel(row, cuts, cutBusy, reel) : ''}
--${aiDone && ai.chapters.length > 1 ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${ai.chapters.map((c) => html`<li><a href="/u/${row.id}?t=${c.start}" data-ovv-chapter="${c.start}"><span class="ovv-time">${vttTime(c.start).slice(0, 8)}</span><span>${c.title}</span></a></li>`)}</ol><p class="ovv-fine">From the video's highlights (AI).</p></section>` : ''}
++${aiDone && ai.chapters.length > 1 ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${ai.chapters.map((c) => html`<li><a href="${watchPath}?t=${c.start}" data-ovv-chapter="${c.start}"><span class="ovv-time">${vttTime(c.start).slice(0, 8)}</span><span>${c.title}</span></a></li>`)}</ol><p class="ovv-fine">From the video's highlights (AI).</p></section>` : ''}
${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report this video</summary>
<form method="post" action="/u/${row.id}/report">
<fieldset><legend>What is wrong?</legend>${Object.entries(REASON_TEXT).map(([k, t]) => html`<label class="ovv-radio"><input type="radio" name="reason" value="${k}" required> ${t}</label>`)}</fieldset>
@@@ -336,7 +393,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
</aside>
</div>`,
});
-- });
++ }
// The AI's transcript and chapters as WebVTT tracks, for whoever may see the video.
async function aiOf(req) {
@@@ -364,7 +421,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
await up.update(row, { title: b.title === undefined ? undefined : b.title, description: b.description, visibility: b.visibility === undefined ? undefined : b.visibility });
} catch (err) {
if (!(err instanceof UploadError)) throw err;
-- return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back</a></p>` }, err.status);
++ return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>${err.detail}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}">Back</a></p>` }, err.status);
}
if (ctx.videoSearch) ctx.videoSearch.kick();
return res.redirect(303, `/u/${row.id}?saved=1`);
@@@ -379,7 +436,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
await ctx.videoCuts.request(row, who, String((req.body || {}).mode || 'pauses'));
} catch (err) {
if (!(err instanceof UploadError)) throw err;
-- return page(req, res, { title: 'Not started', body: html`<h1>The cut did not start</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back to the video</a></p>` }, err.status);
++ return page(req, res, { title: 'Not started', body: html`<h1>The cut did not start</h1><p>${err.detail}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}">Back to the video</a></p>` }, err.status);
}
return res.redirect(303, `/u/${row.id}#h-cut`);
});
@@@ -404,7 +461,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
await up.report(row, { reporter: signedIn(req) ? ownerOf(req) : up.addressId(req.ip), reason: String(b.reason || ''), note: b.note });
} catch (err) {
if (!(err instanceof UploadError)) throw err;
-- return page(req, res, { title: 'Not sent', body: html`<h1>Not sent</h1><p>${err.detail}</p><p><a href="/u/${row.id}">Back</a></p>` }, err.status);
++ return page(req, res, { title: 'Not sent', body: html`<h1>Not sent</h1><p>${err.detail}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}">Back</a></p>` }, err.status);
}
if (ctx.videoSearch) ctx.videoSearch.kick();
return res.redirect(303, `/u/${row.id}?reported=1`);
@@@ -413,7 +470,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
/** A card for an upload (home, channel). */
return function uploadCard(x) {
return card({
-- href: `/u/${x.id}`, thumb: httpsUrl(x.thumbnail_url), title: x.title,
++ href: x.watch_id ? `/watch?v=${x.watch_id}` : `/u/${x.id}`, thumb: httpsUrl(x.thumbnail_url), title: x.title,
channel: x.owner_username || null, channelHref: x.owner_username ? `/@${x.owner_username}` : null,
duration: x.duration_seconds ? durationLabel(x.duration_seconds) : null,
date: dateLabel(x.ready_at || x.created_at) || null, iso: x.ready_at || x.created_at || null,
ddiff --git a/server/video/uploads.js b/server/video/uploads.js
iindex 5d7fa3a..76ae369 100644
---- a/server/video/uploads.js
++++ b/server/video/uploads.js
@@@ -2,7 +2,8 @@
/**
* Uploads on openvibe.video: a signed-in person uploads a video; it becomes an OpenVibe.Media VOD in MediaHub's tenant,
-- * so it gets what a Live recording gets (probe, thumbnail, seekable file, HLS timeline), and a watch page at /u/<id>.
++ * so it gets what a Live recording gets (probe, thumbnail, seekable file, HLS timeline), and a watch page at
++ * /watch?v=<watch id> (its /u/<id> answers 301 there).
*
* start(who, { title, description, visibility, size, contentType }) → the row, and Media's VOD created for it
* part(row, n, buffer) the n-th part, in order (Media appends it to one continuous file)
@@@ -51,9 +52,27 @@ function createUploads({ s, media, config, log = console, events = null, sharing
const addressId = (ip) => `ip:${crypto.createHmac('sha256', addressKey).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
const running = new Map(); // upload id → the completion in flight in this process
++ const WATCH_ID_RE = /^[A-Za-z0-9_-]{11}$/;
const byId = (id) => (/^vup_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id)) ? s.db.maybe('SELECT * FROM video_uploads WHERE id = $1', [String(id)]) : null);
/** An upload by its playlist number (seq, migration 0010). */
const bySeq = (seq) => (/^[1-9]\d{0,15}$/.test(String(seq)) ? s.db.maybe('SELECT * FROM video_uploads WHERE seq = $1', [Number(seq)]) : null);
++ /** An upload by the watch id its Media VOD holds (migration 0024). */
++ const byWatchId = (watchId) => (WATCH_ID_RE.test(String(watchId || '')) ? s.db.maybe('SELECT * FROM video_uploads WHERE watch_id = $1', [String(watchId)]) : null);
++ /** An upload by the Media VOD it uploads into. */
++ const byMediaVod = (vodId) => (Number.isSafeInteger(Number(vodId)) && Number(vodId) > 0 ? s.db.maybe('SELECT * FROM video_uploads WHERE media_vod_id = $1', [Number(vodId)]) : null);
++ /** A Media answer's watch id ({ watch_id } at the top, or under vod), or null. */
++ const watchIdOf = (data) => {
++ const v = data && (data.vod || data);
++ return v && WATCH_ID_RE.test(String(v.watch_id || '')) ? String(v.watch_id) : null;
++ };
++ /**
++ * The watch id Media minted for a VOD just created. Media's create answers only `{ id }`, so the id is read back
++ * once; a read that fails leaves it null and `backfillWatchIds` fills it later. Not fatal: the upload continues.
++ */
++ async function watchIdAfterCreate(vodId) {
++ const out = await media.vodGet(Number(vodId)).catch(() => ({ ok: false }));
++ return out.ok ? watchIdOf(out.data) : null;
++ }
const patchRow = async (id, patch) => {
const cols = Object.keys(patch);
await s.db.query(`UPDATE video_uploads SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);
@@@ -102,9 +121,10 @@ function createUploads({ s, media, config, log = console, events = null, sharing
if (!vod.ok || !vod.data || vod.data.id == null) throw new UploadError(502, 'upload.media', `OpenVibe.Media could not start the upload (${vod.code || vod.status}).`);
const id = s.newId('vup');
const now = s.iso();
-- await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)`,
-- [id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, size, PART_SIZE, Math.ceil(size / PART_SIZE), now]);
++ const watchId = await watchIdAfterCreate(vod.data.id);
++ await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at, watch_id)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12, $13)`,
++ [id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, size, PART_SIZE, Math.ceil(size / PART_SIZE), now, watchId]);
if (sharing) await sharing.register(who.requester, 'video', id, visibility);
return await byId(id);
}
@@@ -126,9 +146,10 @@ function createUploads({ s, media, config, log = console, events = null, sharing
if (!vod.ok || !vod.data || vod.data.id == null) throw new UploadError(502, 'upload.media', `OpenVibe.Media could not start the recording (${vod.code || vod.status}).`);
const id = s.newId('vup');
const now = s.iso();
-- await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 0, $9, $9)`,
-- [id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, now]);
++ const watchId = await watchIdAfterCreate(vod.data.id);
++ await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at, watch_id)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 0, $9, $9, $10)`,
++ [id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, now, watchId]);
if (sharing) await sharing.register(who.requester, 'video', id, visibility);
return await byId(id);
}
@@@ -215,14 +236,17 @@ function createUploads({ s, media, config, log = console, events = null, sharing
const subject = String(meta.owner_subject || '');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) return null;
const known = await s.db.maybe('SELECT * FROM video_uploads WHERE media_vod_id = $1', [vodId]);
-- if (known) return known;
++ if (known) {
++ if (!known.watch_id) await rememberWatchId(known.id, watchIdOf(vod));
++ return known;
++ }
const now = s.iso();
const title = cleanLine(vod.title, 120) || `Recording, ${now.slice(0, 16).replace('T', ' ')} UTC`;
const size = Number(vod.file_size) > 0 ? Math.round(Number(vod.file_size)) : 0;
-- const row = await s.db.maybe(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, status, created_at, updated_at)
-- VALUES ($1, $2, NULL, $3, '', 'private', $4, 'video/mp4', $5, 0, 0, 'processing', $6, $6)
++ const row = await s.db.maybe(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, status, created_at, updated_at, watch_id)
++ VALUES ($1, $2, NULL, $3, '', 'private', $4, 'video/mp4', $5, 0, 0, 'processing', $6, $6, $7)
ON CONFLICT (media_vod_id) WHERE media_vod_id IS NOT NULL DO NOTHING RETURNING *`,
-- [s.newId('vup'), `user:${subject}`, title, vodId, size, now]);
++ [s.newId('vup'), `user:${subject}`, title, vodId, size, now, watchIdOf(vod)]);
if (!row) return await s.db.maybe('SELECT * FROM video_uploads WHERE media_vod_id = $1', [vodId]);
log.log(`[MediaHub] a record-only recording is ${row.id} (Media VOD ${vodId})`);
return await absorb(row, vod);
@@@ -274,9 +298,43 @@ function createUploads({ s, media, config, log = console, events = null, sharing
return { recorded: true, hidden: hide || row.state === 'hidden' };
}
++ /** Store a row's watch id once (only where it has none), so a card and a page name it without asking Media. */
++ async function rememberWatchId(id, watchId) {
++ if (!watchId) return null;
++ await s.db.maybe('UPDATE video_uploads SET watch_id = $2 WHERE id = $1 AND watch_id IS NULL', [id, watchId]);
++ return watchId;
++ }
++
++ /** A row's watch id, reading and storing Media's when it has none yet (a row written before migration 0024). */
++ async function ensureWatchId(row) {
++ if (!row) return null;
++ if (row.watch_id) return row.watch_id;
++ if (!row.media_vod_id) return null;
++ const out = await media.vodGet(row.media_vod_id).catch(() => ({ ok: false }));
++ return rememberWatchId(row.id, out.ok ? watchIdOf(out.data) : null);
++ }
++
++ /**
++ * Fill the watch ids of uploads that have none, a bounded batch at a time, reading each row's Media VOD. Only rows
++ * written before migration 0024 need it; `remaining` says how many are left, so a caller can loop. Never throws for
++ * a Media refusal: a row that cannot be read is left for the next pass.
++ */
++ async function backfillWatchIds({ limit = 100, log = null } = {}) {
++ const rows = await s.db.many('SELECT id, media_vod_id FROM video_uploads WHERE watch_id IS NULL AND media_vod_id IS NOT NULL ORDER BY id LIMIT $1', [Math.max(1, Number(limit) || 100)]);
++ let filled = 0;
++ for (const row of rows) {
++ const out = await media.vodGet(row.media_vod_id).catch(() => ({ ok: false }));
++ const watchId = out.ok ? watchIdOf(out.data) : null;
++ if (watchId && await rememberWatchId(row.id, watchId)) filled++;
++ }
++ const remaining = Number(await s.db.value('SELECT count(*) FROM video_uploads WHERE watch_id IS NULL AND media_vod_id IS NOT NULL')) || 0;
++ if (log && filled) log.log(`[MediaHub] ${filled} upload watch id(s) filled; ${remaining} left`);
++ return { filled, remaining };
++ }
++
return {
PART_SIZE, REASONS, UploadError, kindOf, addressId, canSee,
-- byId, bySeq, start, part, complete, refresh, update, remove, report, startRecording, chunk, importRecording, fillUsername,
++ byId, bySeq, byMediaVod, byWatchId, start, part, complete, refresh, update, remove, report, startRecording, chunk, importRecording, fillUsername, ensureWatchId, backfillWatchIds,
mine: (owner, limit = 60) => s.db.many("SELECT * FROM video_uploads WHERE owner = $1 AND state <> 'removed' ORDER BY id DESC LIMIT $2", [owner, limit]),
latest: (limit = 12) => s.db.many("SELECT * FROM video_uploads WHERE visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $1", [limit]),
channel: (username, limit = 48) => s.db.many("SELECT * FROM video_uploads WHERE lower(owner_username) = lower($1) AND visibility = 'public' AND status = 'ready' AND state = 'active' ORDER BY id DESC LIMIT $2", [String(username), limit]),
ddiff --git a/server/video/watch-ui.js b/server/video/watch-ui.js
iindex 4738049..6a94d72 100644
---- a/server/video/watch-ui.js
++++ b/server/video/watch-ui.js
@@@ -159,7 +159,10 @@ function downloadsFor(kind, id, what) {
* null; `chapters` = [{ offset_seconds, description }]; `transcript` = [{ start, text }]; `long` = the video is an
* hour or longer. Null when there is none of the three.
*/
--function companion({ kind, id, chat, chapters, transcript, long }) {
++function companion({ kind, id, watchId = null, chat, chapters, transcript, long }) {
++ // A chapter is a moment on the item's watch page: /watch?v=<watch id>&t=<seconds>, or the numbered page until it has
++ // an id (which redirects). The downloads below stay the numeric routes the files are served on.
++ const at = watchId ? `/watch?v=${watchId}` : `/${kind === 'clip' ? 'c' : 'v'}/${id}`;
const tabs = [];
if (chat && chat.rows.length) {
const total = chat.rows.length + chat.leftOut;
@@@ -173,7 +176,7 @@ function companion({ kind, id, chat, chapters, transcript, long }) {
tabs.push({
key: 'chapters', tab: 'Chapters', title: 'Chapters', count: chapters.length,
note: 'Made by AI from the stream',
-- body: html`<ol class="ovv-tp-body ovv-chapter-list">${chapters.map((m) => html`<li><a href="/${kind === 'clip' ? 'c' : 'v'}/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}" title="${m.description}"><span class="ovv-time">${clockOf(m.offset_seconds, long)}</span><span class="ovv-chapter-title">${m.description}</span></a></li>`)}</ol>`,
++ body: html`<ol class="ovv-tp-body ovv-chapter-list">${chapters.map((m) => html`<li><a href="${at}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}" title="${m.description}"><span class="ovv-time">${clockOf(m.offset_seconds, long)}</span><span class="ovv-chapter-title">${m.description}</span></a></li>`)}</ol>`,
});
}
if (transcript && transcript.length) {
ddiff --git a/test/helpers/media.js b/test/helpers/media.js
iindex efeafca..2a791aa 100644
---- a/test/helpers/media.js
++++ b/test/helpers/media.js
@@@ -41,6 +41,8 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const deletedVods = [];
const vodState = { duration: 42.5, completeFails: false };
let vodSeq = 0;
++ // The id a row answers to at openvibe.video/watch?v=: 11 URL-safe base64 characters, minted once per VOD.
++ const watchIdOf = (id) => crypto.createHash('sha256').update(`watch:${id}`).digest('base64url').slice(0, 11);
const state = { down: false, tokenRejects: false };
let seq = 0;
@@@ -116,9 +118,15 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
if (path.startsWith('/api/v1/')) {
if (state.tokenRejects || req.headers.authorization !== `Bearer ${SERVICE_TOKEN}`) return problem(res, 401, 'auth.required', 'Authentication required');
const parts = path.split('/').filter(Boolean); // api v1 :app vods [:id] [chunks|complete]
++ if (parts[3] === 'watch') {
++ // GET /api/v1/:app/watch/:watch_id → which item it is (a VOD here), or 404. One id space, as Media has.
++ const wanted = String(parts[4] || '');
++ const vod = [...vods.values()].find((v) => !v.deleted && v.watch_id === wanted);
++ return vod ? json(res, 200, { kind: 'vod', id: vod.id }) : problem(res, 404, 'media.watch.not_found', 'No such watch id');
++ }
if (parts[3] !== 'vods') return problem(res, 404, 'route.not_found', 'no such route');
const vodPublicOf = (v) => ({
-- id: v.id, title: v.title, visibility: v.visibility, user_id: v.user_id, is_recording: v.status !== 'ready',
++ id: v.id, watch_id: v.watch_id, title: v.title, visibility: v.visibility, user_id: v.user_id, is_recording: v.status !== 'ready',
duration_seconds: v.status === 'ready' ? vodState.duration : 0, file_size: v.bytes.length,
file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,
hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,
@@@ -129,7 +137,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const body = JSON.parse(raw.toString('utf8') || '{}');
const id = ++vodSeq;
if (body.user_id != null && !/^\d+$/.test(String(body.user_id))) return problem(res, 500, 'vod.create', 'invalid input syntax for type bigint'); // as Media's column refuses it
-- vods.set(id, { id, title: body.title, visibility: body.visibility, user_id: body.user_id, meta: body.meta, bytes: Buffer.alloc(0), types: [], status: 'recording' });
++ vods.set(id, { id, watch_id: watchIdOf(id), title: body.title, visibility: body.visibility, user_id: body.user_id, meta: body.meta, bytes: Buffer.alloc(0), types: [], status: 'recording' });
return json(res, 201, { id });
}
const v = vods.get(Number(parts[4]));
ddiff --git a/test/video-ai.test.js b/test/video-ai.test.js
iindex b6cbe37..7f35b67 100644
---- a/test/video-ai.test.js
++++ b/test/video-ai.test.js
@@@ -37,7 +37,12 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body } = {}) {
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', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, text, json: () => JSON.parse(text) }); });
++ res.on('end', () => {
++ // A numbered watch page answers 301 to /watch?v=<id>: a GET follows it, as a browser does.
++ if (res.statusCode === 301 && method === 'GET' && res.headers.location) { vreq(base, res.headers.location, { cookie }).then(resolve, reject); return; }
++ const text = Buffer.concat(chunks).toString('utf8');
++ resolve({ status: res.statusCode, text, json: () => JSON.parse(text) });
++ });
});
req.on('error', reject);
if (data) req.write(data);
ddiff --git a/test/video-cut.test.js b/test/video-cut.test.js
iindex 8de09eb..460423f 100644
---- a/test/video-cut.test.js
++++ b/test/video-cut.test.js
@@@ -144,10 +144,11 @@ function probe(buf) {
});
await check('the pages: the original lists the cut with a link; the copy names its original; a copy is not cut again', async () => {
++ const copied = await s.db.maybe('SELECT watch_id FROM video_uploads WHERE id = $1', [copyId]);
const orig = await t.get(`/u/${A}`, { as: kim, headers: V });
-- assert.ok(orig.text.includes('Pauses cut') && orig.text.includes(`href="/u/${copyId}"`) && orig.text.includes('1 pause out'));
-- const copy = await t.get(`/u/${copyId}`, { as: kim, headers: V });
-- assert.ok(copy.text.includes('An edited copy of') && copy.text.includes(`href="/u/${A}"`));
++ assert.ok(orig.text.includes('Pauses cut') && orig.text.includes(`href="/watch?v=${copied.watch_id}"`) && orig.text.includes('1 pause out'));
++ const copyPage = await t.get(`/watch?v=${copied.watch_id}`, { as: kim, headers: V });
++ assert.ok(copyPage.text.includes('An edited copy of') && copyPage.text.includes(`href="/u/${A}"`));
await s.db.exec("UPDATE video_uploads SET status = 'ready', duration_seconds = 9.5 WHERE id = $1", [copyId]);
const r = await t.get(`/api/v1/videos/${copyId}/cuts`, { as: kim, method: 'POST', json: { mode: 'pauses' }, headers: V });
assert.deepStrictEqual([r.status, r.json().code], [409, 'cut.is_copy']);
ddiff --git a/test/video-search-index.test.js b/test/video-search-index.test.js
iindex 68f2d5c..d274705 100644
---- a/test/video-search-index.test.js
++++ b/test/video-search-index.test.js
@@@ -54,6 +54,7 @@ function startSearch() {
[id, o.title || 'Trip to the coast', o.description || 'Waves and wind.', o.visibility || 'public', o.status || 'ready', o.state || 'active']);
const ids = { pub: 'vup_01J0000000000000000000000A', priv: 'vup_01J0000000000000000000000B', proc: 'vup_01J0000000000000000000000C', hidden: 'vup_01J0000000000000000000000D' };
await insert(ids.pub);
++ await s.db.exec("UPDATE video_uploads SET watch_id = $2 WHERE id = $1", [ids.pub, 'E'.repeat(11)]);
await insert(ids.priv, { visibility: 'private' });
await insert(ids.proc, { status: 'processing' });
await insert(ids.hidden, { state: 'hidden' });
@@@ -69,7 +70,7 @@ function startSearch() {
assert.strictEqual(r.upserted, 1, JSON.stringify(r));
assert.deepStrictEqual([...search.docs.keys()], [ids.pub]);
const doc = search.docs.get(ids.pub);
-- assert.strictEqual(doc.canonical_url, `https://openvibe.video/u/${ids.pub}`);
++ assert.strictEqual(doc.canonical_url, `https://openvibe.video/watch?v=${'E'.repeat(11)}`);
assert.strictEqual(doc.title, 'Trip to the coast');
assert.deepStrictEqual(doc.facets, { kind: 'upload', channel: 'kim', minutes: 2 });
assert.strictEqual(doc.revision, 1);
ddiff --git a/test/video-uploads.test.js b/test/video-uploads.test.js
iindex d3a65a7..b66b69e 100644
---- a/test/video-uploads.test.js
++++ b/test/video-uploads.test.js
@@@ -37,7 +37,12 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
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', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, headers: res.headers, text, json: () => JSON.parse(text) }); });
++ res.on('end', () => {
++ // A numbered watch page answers 301 to /watch?v=<id>: a GET follows it, as a browser does.
++ if (res.statusCode === 301 && method === 'GET' && res.headers.location) { vreq(base, res.headers.location, { cookie }).then(resolve, reject); return; }
++ const text = Buffer.concat(chunks).toString('utf8');
++ resolve({ status: res.statusCode, headers: res.headers, text, json: () => JSON.parse(text) });
++ });
});
req.on('error', reject);
if (data) req.write(data);
@@@ -87,7 +92,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
assert.strictEqual(vod.meta.owner_subject, kim.subject, 'the uploader is in the VOD\'s meta');
const done1 = await vreq(t.base, `/api/v1/videos/${id}/complete`, { method: 'POST', cookie: kimCookie });
assert.strictEqual(done1.status, 202);
-- assert.strictEqual(done1.json().video.url, `/u/${id}`);
++ assert.strictEqual(done1.json().video.url, `/watch?v=${[...t.media.vods.values()][0].watch_id}`);
});
await check('the watch page plays it from Media, and it is on the channel, the home page and the sitemap', async () => {
@@@ -104,9 +109,10 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
assert.ok(page.text.includes('<a class="ovv-channel-name" href="/@kim">@kim</a>'));
assert.ok(page.text.includes('Report this video'), 'others may report it');
assert.ok(!page.text.includes('Manage this video'));
-- assert.ok((await vreq(t.base, '/')).text.includes(`href="/u/${id}"`), 'the home page');
-- assert.ok((await vreq(t.base, '/@kim')).text.includes(`href="/u/${id}"`), 'the channel');
-- assert.ok((await vreq(t.base, '/sitemap.xml')).text.includes(`https://openvibe.video/u/${id}`), 'the sitemap');
++ const wid = [...t.media.vods.values()][0].watch_id;
++ assert.ok((await vreq(t.base, '/')).text.includes(`href="/watch?v=${wid}"`), 'the home page');
++ assert.ok((await vreq(t.base, '/@kim')).text.includes(`href="/watch?v=${wid}"`), 'the channel');
++ assert.ok((await vreq(t.base, '/sitemap.xml')).text.includes(`https://openvibe.video/watch?v=${wid}`), 'the sitemap');
const mine = await vreq(t.base, '/me/videos', { cookie: kimCookie });
assert.ok(mine.text.includes('Trip to the coast') && mine.text.includes('Public'));
});
@@@ -122,8 +128,9 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
const listUrl = saved.headers.location;
const listId = listUrl.split('/').pop();
const list = await vreq(t.base, listUrl, { cookie: samCookie });
-- assert.ok(list.text.includes(`href="/u/${id}"`) && list.text.includes('Trip to the coast'), 'the playlist shows the upload');
-- assert.ok(list.text.includes(`href="/u/${id}?list=${listId}"`), 'Play all starts it');
++ const wid = [...t.media.vods.values()][0].watch_id;
++ assert.ok(list.text.includes(`href="/watch?v=${wid}"`) && list.text.includes('Trip to the coast'), 'the playlist shows the upload');
++ assert.ok(list.text.includes(`href="/watch?v=${wid}&amp;list=${listId}"`), 'Play all starts it');
await vreq(t.base, `/playlists/${listId}/visibility`, { method: 'POST', cookie: samCookie, form: { visibility: 'public' } });
await vreq(t.base, '/playlists/add', { method: 'POST', cookie: samCookie, form: { kind: 'vod', id: '101', playlist: listId } });
const inList = await vreq(t.base, `/u/${id}?list=${listId}`);
ddiff --git a/test/video-watch-ids.test.js b/test/video-watch-ids.test.js
nnew file mode 100644
iindex 0000000..7d2a019
---- /dev/null
++++ b/test/video-watch-ids.test.js
@@@ -0,0 +1,155 @@
++'use strict';
++/**
++ * Watch ids on openvibe.video (plan track U): one root, /watch?v=<watch id>, for recordings, clips and uploads. The old
++ * numbered addresses answer 301 to it (the moment and the playlist carried over), an upload's id is stored once and
++ * backfilled in a batch, and the watch page names itself as canonical and in its share sheet.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++const VOD_WATCH = 'AAAAAAAAAAA';
++const CLIP_WATCH = 'BBBBBBBBBBB';
++const UPLOAD_ONE = 'C'.repeat(11);
++const UPLOAD_TWO = 'D'.repeat(11);
++
++function startLive() {
++ const vod = {
++ id: 101, watch_id: VOD_WATCH, title: 'A public recording', description: 'A public stream', duration_seconds: 45,
++ created_at: '2026-10-12T10:00:00.000Z', thumbnail_url: 'https://openvibe.media/t/101.jpg',
++ playback_url: 'https://openvibe.media/v/101', hls_url: null, visibility: 'public',
++ username: 'streamer', display_name: 'Streamer', view_count: 100, is_recording: false, ai_overview: '',
++ };
++ const clip = {
++ id: 201, watch_id: CLIP_WATCH, vod_id: 101, title: 'A clip', start_time: 5, end_time: 15, duration_seconds: 10,
++ created_at: '2026-10-12T11:00:00.000Z', playback_url: 'https://openvibe.media/c/201', thumbnail_url: null,
++ visibility: 'public', auto_generated: false, username: 'clipper', display_name: 'Clipper',
++ source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer', ai_overview: '',
++ };
++ const feedVod = { kind: 'vod', id: 101, watch_id: VOD_WATCH, title: 'A public recording', created_at: '2026-10-12T10:00:00.000Z', duration_seconds: 45, views: 100, thumbnail_url: 'https://openvibe.media/t/101.jpg', excerpt: 'A public stream', channel: { username: 'streamer' } };
++ const feedClip = { kind: 'clip', id: 201, watch_id: CLIP_WATCH, title: 'A clip', created_at: '2026-10-12T11:00:00.000Z', duration_seconds: 10, views: 20, thumbnail_url: 'https://openvibe.media/t/201.jpg', excerpt: 'A clip overview', channel: { username: 'clipper' } };
++ const server = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://x');
++ const json = (status, body) => { const text = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) }); res.end(text); };
++ const p = url.pathname;
++ if (p === '/api/content/feed') return json(200, { items: url.searchParams.get('type') === 'clips' ? [feedClip] : [feedVod], next: null });
++ if (p === '/api/vods/101') return json(200, { vod, clips: [] });
++ if (p === '/api/clips/201') return json(200, { clip });
++ if (p === '/api/vods') return json(200, { vods: [], total: 0, hasMore: false });
++ if (p === '/api/clips') return json(200, { clips: [], total: 0, hasMore: false });
++ if (p === '/api/streams') return json(200, { streams: [] });
++ if (p === '/api/v1/search') return json(200, { results: [], next_cursor: null, facets: { category: [], channel: [] } });
++ 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}`,
++ close: () => new Promise((r) => server.close(r)),
++ })));
++}
++
++function request(base, path, host) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(base + path);
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: 'GET', headers: { Host: host } }, (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);
++ 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,
++ MEDIAHUB_SEARCH_URL: live.url,
++ MEDIAHUB_VIDEO_SEARCH: 'off',
++ },
++ });
++ const getVideo = (p) => request(t.base, p, 'openvibe.video');
++ const db = t.ctx.s.db;
++
++ // The two Media VODs the upload rows point at, and the Live item a watch id resolves to.
++ const mockVod = (id, watchId) => ({ id, watch_id: watchId, title: `VOD ${id}`, visibility: 'public', status: 'ready', bytes: Buffer.alloc(0), types: [] });
++ t.media.vods.set(1, mockVod(1, UPLOAD_ONE));
++ t.media.vods.set(2, mockVod(2, UPLOAD_TWO));
++ t.media.vods.set(101, mockVod(101, VOD_WATCH));
++
++ const row = (id, vodId, title) => db.query(`INSERT INTO video_uploads
++ (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, status, created_at, updated_at)
++ VALUES ($1, $2, NULL, $3, '', 'public', $4, 'video/mp4', 1000, 8388608, 1, 'ready', '2026-11-07 10:00:00', '2026-11-07 10:00:00')`,
++ [id, 'user:usr_01JZZZZZZZZZZZZZZZZZZZZZZZ', title, vodId]);
++ const ONE = 'vup_ZZZZZZZZZZZZZZZZZZZZZZZZZZ';
++ const TWO = 'vup_YYYYYYYYYYYYYYYYYYYYYYYYYY';
++ await row(ONE, 1, 'First upload');
++ await row(TWO, 2, 'Second upload');
++
++ await check('an upload without a watch id is filled in a batch, once, with a distinct 11-character id', async () => {
++ const out = await t.ctx.videoUploads.backfillWatchIds({ limit: 10 });
++ assert.deepStrictEqual({ filled: out.filled, remaining: out.remaining }, { filled: 2, remaining: 0 });
++ const rows = await db.many('SELECT id, watch_id FROM video_uploads ORDER BY id');
++ assert.deepStrictEqual(rows.map((r) => r.watch_id).sort(), [UPLOAD_ONE, UPLOAD_TWO].sort());
++ for (const r of rows) assert.ok(/^[A-Za-z0-9_-]{11}$/.test(r.watch_id), `watch id shape: ${r.watch_id}`);
++ assert.notStrictEqual(rows[0].watch_id, rows[1].watch_id, 'the ids are distinct');
++ const again = await t.ctx.videoUploads.backfillWatchIds({ limit: 10 });
++ assert.strictEqual(again.filled, 0, 'a second pass changes nothing');
++ });
++
++ await check('the old upload address answers 301 to /watch?v=<id>', async () => {
++ const r = await getVideo(`/u/${ONE}`);
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.location, `/watch?v=${UPLOAD_ONE}`);
++ });
++
++ await check('an upload watch page is canonical and shares /watch?v=<id>', async () => {
++ const r = await getVideo(`/watch?v=${UPLOAD_ONE}`);
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes(`<link rel="canonical" href="https://openvibe.video/watch?v=${UPLOAD_ONE}">`), 'canonical');
++ assert.ok(r.text.includes(`data-url="https://openvibe.video/watch?v=${UPLOAD_ONE}"`), 'share sheet');
++ });
++
++ await check('the old recording address answers 301 and keeps the moment', async () => {
++ const r = await getVideo('/v/101?t=30');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.location, `/watch?v=${VOD_WATCH}&t=30`);
++ });
++
++ await check('the old clip address answers 301 to its watch id', async () => {
++ const r = await getVideo('/c/201');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.location, `/watch?v=${CLIP_WATCH}`);
++ });
++
++ await check('a recording watch page is canonical at /watch?v=<id>', async () => {
++ const r = await getVideo(`/watch?v=${VOD_WATCH}`);
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes(`<link rel="canonical" href="https://openvibe.video/watch?v=${VOD_WATCH}">`), 'canonical');
++ assert.ok(r.text.includes(`data-url="https://openvibe.video/watch?v=${VOD_WATCH}"`), 'share sheet');
++ });
++
++ await check('the sitemap lists the watch addresses, not the numbered ones', async () => {
++ const r = await getVideo('/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes(`/watch?v=${VOD_WATCH}`), 'recording');
++ assert.ok(r.text.includes(`/watch?v=${CLIP_WATCH}`), 'clip');
++ assert.ok(r.text.includes(`/watch?v=${UPLOAD_ONE}`), 'upload');
++ assert.ok(!r.text.includes('/v/101<'), 'no numbered recording page');
++ });
++
++ await check('the Markdown twin of a watch page is that page, canonically at /watch?v=<id>', async () => {
++ const r = await getVideo(`/watch?v=${UPLOAD_ONE}&format=markdown`);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 120));
++ assert.ok(r.text.startsWith('---\ntitle:'), `a Markdown twin (${r.text.slice(0, 60)})`);
++ assert.ok(r.text.includes(`canonical: "https://openvibe.video/watch?v=${UPLOAD_ONE}"`), 'the twin names the watch address');
++ });
++
++ await t.close();
++ await live.close();
++ done();
++})();