openvibers/openvibe.mediahub Commits
Watch pages: YouTube's /watch?v=<watch id> for recordings, clips and uploads (#316)
Changes
@@@ -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, itscard, or `DELETE /api/v1/files/:id` — puts it in the bin for 30 days instead of deleting it: its share links stop atonce, its bytes stay in OpenVibe.Media, and it still counts against storage. `/files/bin` in the sidebar (with annew file mode 100644@@@ -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;@@@ -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 });@@@ -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 }),@@@ -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,@@@ -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)),};@@@ -36,7 +36,8 @@ function createVideoIndexNow({ config, live, indexnow, log = console, intervalMsfor (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;@@@ -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>`: '';@@@ -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;@@@ -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-labellecrumbs: [{ 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="ovvreturn 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="ovvif (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="ovvtitle: 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-clif (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-cltitle: 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 downconst 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@@@ -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),@@@ -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>${stviews: 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>${sttitle: 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 thiawait 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 thiawait 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 thiawait 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,@@@ -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, sharingconst 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, sharingif (!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, sharingif (!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, sharingconst 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, sharingreturn { 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]),@@@ -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) {@@@ -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]));@@@ -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);@@@ -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']);@@@ -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);@@@ -37,7 +37,12 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truconst 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 = truassert.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 = truassert.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 = truconst 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}&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}`);nnew file mode 100644@@@ -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();++})();
@@ -4,6 +4,16 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,16 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## 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
- 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
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
once, its bytes stay in OpenVibe.Media, and it still counts against storage. `/files/bin` in the sidebar (with a
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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;
@@ -82,6 +82,9 @@ async function createApp(opts = {}) {
@@ -82,6 +82,9 @@ async function createApp(opts = {}) {
const picsDescribe = opts.picsDescribe || require('./pics/describe').createPicDescriber({ s, media, config, fetchImpl, log });
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.
// 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 });
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 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 videoCuts = require('./video/cut').createCuts({ s, media, uploads: videoUploads, videoAi, config, log, spawnImpl: opts.spawnImpl });
const syncKeys = createSyncKeys({ s, config });
const syncKeys = createSyncKeys({ s, config });
@@ -89,6 +89,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -89,6 +89,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
}
}
const vods = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/vods`;
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' };
const VOD_EXT = { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/x-matroska': 'mkv' };
return {
return {
@@ -112,6 +113,12 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -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. */
/** 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 }),
vodComplete: (vodId) => call(`/${Number(vodId)}/complete`, { root: vods(), method: 'POST', json: {}, timeoutMs: 30 * 60_000 }),
vodGet: (vodId) => call(`/${Number(vodId)}`, { root: vods() }),
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. */
/** 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() }),
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 }),
vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),
@@ -1,18 +1,18 @@
@@ -1,18 +1,18 @@
'use strict';
'use strict';
// The Markdown twins of openvibe.video's public pages (openvibe-shared/agent-layer): the home and the
// 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
// 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
// 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.
// 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. */
/** A request as an anonymous browser makes it: no session, no cookies, the video host. */
function anonymousRequest(path, search, host) {
function anonymousRequest(path, search, host) {
const url = path + search;
const url = path + search;
return {
return {
method: 'GET', url, originalUrl: url, path,
method: 'GET', url, originalUrl: url, path,
- params: {}, query: {},
+ params: {}, query: Object.fromEntries(new URLSearchParams(String(search || '').replace(/^\?/, ''))),
headers: { accept: 'text/html', host },
headers: { accept: 'text/html', host },
cookies: {}, body: undefined, protocol: 'https', secure: true,
cookies: {}, body: undefined, protocol: 'https', secure: true,
ip: '127.0.0.1', ips: ['127.0.0.1'], hostname: host,
ip: '127.0.0.1', ips: ['127.0.0.1'], hostname: host,
@@ -327,7 +327,7 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
@@ -327,7 +327,7 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
return {
return {
enabled, MODES, request, sweep, recover, kick, byId,
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 })),
.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)),
highlightsPlan: async (row) => planHighlights(videoAi ? await videoAi.forUpload(row) : null, Number(row.duration_seconds)),
};
};
@@ -36,7 +36,8 @@ function createVideoIndexNow({ config, live, indexnow, log = console, intervalMs
@@ -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 : []))) {
for (const it of pages.flatMap((p) => (p && Array.isArray(p.items) ? p.items : []))) {
const id = Number(it && it.id);
const id = Number(it && it.id);
if (!Number.isSafeInteger(id) || id <= 0) continue;
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;
if (seen.has(path)) continue;
seen.add(path);
seen.add(path);
if (!primed) continue;
if (!primed) continue;
@@ -56,7 +56,8 @@ function renderPage(o) {
@@ -56,7 +56,8 @@ function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
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
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>`
? 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>`
: '';
: '';
@@ -74,7 +74,7 @@ function cardFrom(items, at = Date.now()) {
@@ -74,7 +74,7 @@ function cardFrom(items, at = Date.now()) {
const item = {
const item = {
title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),
title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),
meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'view' : 'views'} this week`,
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);
const image = imageOf(it);
if (image) item.image = image;
if (image) item.image = image;
@@ -8,8 +8,9 @@
@@ -8,8 +8,9 @@
* / what OpenVibe.Video is: who is live now, the latest uploads, the month's most watched,
* / 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
* 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)
* /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
* /@: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/captions.vtt the AI transcript as WebVTT captions, with what was heard ("[laughter]")
* /v/:id/chapters.vtt the AI moments as WebVTT chapters
* /v/:id/chapters.vtt the AI moments as WebVTT chapters
@@ -205,7 +206,7 @@ async function pipsOf(vod, live) {
@@ -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),
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,
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),
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;
return out;
@@ -267,6 +268,8 @@ const isoTime = (v) => { const t = Date.parse(String(v || '')); return Number.is
@@ -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 }) {
function videoLd({ base, path, item, title, description, chapterList, partOf }) {
const pageUrl = `${base}${path}`;
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 thumb = httpsUrl(item.thumbnail_url);
const channel = item.display_name || item.username;
const channel = item.display_name || item.username;
const parts = (chapterList || []).map((m, i, all) => ({
const parts = (chapterList || []).map((m, i, all) => ({
@@ -274,7 +277,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })
@@ -274,7 +277,7 @@ function videoLd({ base, path, item, title, description, chapterList, partOf })
name: String(m.description || `Chapter ${i + 1}`).slice(0, 110),
name: String(m.description || `Chapter ${i + 1}`).slice(0, 110),
startOffset: m.offset_seconds,
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)) } : {})),
...(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 {
return {
...seo.jsonLd.video({
...seo.jsonLd.video({
@@ -586,7 +589,7 @@ function feedCard(item, { views = false } = {}) {
@@ -586,7 +589,7 @@ function feedCard(item, { views = false } = {}) {
function vodCard(v) {
function vodCard(v) {
return card({
return card({
- href: `/v/${v.id}`,
+ href: v.watch_id ? `/watch?v=${v.watch_id}` : `/v/${v.id}`,
labels: labelsUi.inForce(v),
labels: labelsUi.inForce(v),
owner: v.username || null,
owner: v.username || null,
thumb: httpsUrl(v.thumbnail_url),
thumb: httpsUrl(v.thumbnail_url),
@@ -597,10 +600,17 @@ function vodCard(v) {
@@ -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) {
function clipCard(c) {
const dur = c.duration_seconds != null ? c.duration_seconds : c.duration;
const dur = c.duration_seconds != null ? c.duration_seconds : c.duration;
return card({
return card({
- href: `/c/${c.id}`,
+ href: c.watch_id ? `/watch?v=${c.watch_id}` : `/c/${c.id}`,
kind: 'clip',
kind: 'clip',
badge: c.auto_generated ? 'AI clip' : null,
badge: c.auto_generated ? 'AI clip' : null,
labels: labelsUi.inForce(c),
labels: labelsUi.inForce(c),
@@ -796,7 +806,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -796,7 +806,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
// ── 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) ──
// ── 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,
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}` })}
@@ -827,7 +837,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
if (kind === 'upload') {
if (kind === 'upload') {
const row = await ctx.videoUploads.bySeq(itemId);
const row = await ctx.videoUploads.bySeq(itemId);
if (!row || row.status !== 'ready' || row.state !== 'active' || row.visibility === 'private') return null;
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;
let info = null;
try {
try {
@@ -836,7 +846,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -836,7 +846,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
} catch { info = null; }
} catch { info = null; }
if (!info) return null;
if (!info) return null;
return {
return {
- href: kind === 'vod' ? `/v/${itemId}` : `/c/${itemId}`,
+ href: watchHref(kind, itemId, info.watch_id),
title: info.title || (kind === 'clip' ? 'Clip' : 'Stream recording'),
title: info.title || (kind === 'clip' ? 'Clip' : 'Stream recording'),
thumb: httpsUrl(info.thumbnail_url),
thumb: httpsUrl(info.thumbnail_url),
channel: info.username || info.display_name || null,
channel: info.username || info.display_name || null,
@@ -849,9 +859,14 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -849,9 +859,14 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
async function itemHref(kind, itemId) {
async function itemHref(kind, itemId) {
if (kind === 'upload') {
if (kind === 'upload') {
const row = await ctx.videoUploads.bySeq(itemId);
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) {
async function upNext(req, kind, id) {
@@ -866,7 +881,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -866,7 +881,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
const nextId = Number(next.item_id);
const nextId = Number(next.item_id);
const nextHref = await itemHref(next.kind, nextId);
const nextHref = await itemHref(next.kind, nextId);
if (!nextHref) return { html: '', nextUrl: null };
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 info = await itemInfo(next.kind, nextId);
const label = (info && info.title) || `${next.kind === 'vod' ? 'VOD' : next.kind === 'clip' ? 'Clip' : 'Video'} ${nextId}`;
const label = (info && info.title) || `${next.kind === 'vod' ? 'VOD' : next.kind === 'clip' ? 'Clip' : 'Video'} ${nextId}`;
return {
return {
@@ -1013,7 +1028,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -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 ${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.';
: '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 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, {
return page(req, res, {
index: true,
index: true,
title: 'OpenVibe.Video: watch streams again and share your videos',
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
@@ -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 }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Categories', href: '/#categories' }, { label: cat.label }],
jsonLd: [
jsonLd: [
seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Video', url: `${base}/` }, { name: cat.label, url: `${base}/category/${slug}` }]),
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>
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>
<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
@@ -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));
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 ──────────────────────────────────────
// ── 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) => {
r.get('/v/:id', async (req, res) => {
const id = String(req.params.id || '');
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);
if (!live.isId(id)) return notFound(req, res);
let data = await live.vod(id);
let data = await live.vod(id);
@@ -1202,6 +1257,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1202,6 +1257,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
}
}
if (!data || !data.vod) return notFound(req, res);
if (!data || !data.vod) return notFound(req, res);
const vod = data.vod;
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
// 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").
// video's, at the second this part starts (owner, 2026-10-27: "a single vod … that seeks just fine").
const rec = vod.recording;
const rec = vod.recording;
@@ -1287,7 +1344,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -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 (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' });
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.
// 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, {
return page(req, res, {
index: !unlisted && !ownOnly && !thin,
index: !unlisted && !ownOnly && !thin,
noindex: unlisted || ownOnly || thin,
noindex: unlisted || ownOnly || thin,
@@ -1295,8 +1352,9 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -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),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
styles: ['kit.css'],
styles: ['kit.css'],
description: about,
description: about,
+ canonical: `${base}${watchPath}`,
...(ownOnly || vod.is_recording ? {} : {
...(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',
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
@@ -1317,7 +1375,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
@@ -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>`) : ''}
<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>
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</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>
</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>` : ''}
${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>
</div>
@@ -1339,11 +1397,20 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -1339,11 +1397,20 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
</aside>
</aside>
</div>`,
</div>`,
});
});
- });
+ }
// ── Clip watch page ─────────────────────────────────────
// ── Clip watch page ─────────────────────────────────────
r.get('/c/:id', async (req, res) => {
r.get('/c/:id', async (req, res) => {
const id = String(req.params.id || '');
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);
if (!live.isId(id)) return notFound(req, res);
let data = await live.clip(id);
let data = await live.clip(id);
@@ -1354,6 +1421,8 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -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);
if (!data || !data.clip) return notFound(req, res);
const clip = data.clip;
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;
if (clip && clip.ai_overview) clip.ai_overview = String(clip.ai_overview).trim() || null;
const manageable = mayManage(req, 'clip', clip);
const manageable = mayManage(req, 'clip', clip);
@@ -1379,7 +1448,9 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -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.
// A clip Media is still cutting (or failed to cut) has no file to play yet.
const cutting = Boolean(clip.status) && clip.status !== 'ready';
const cutting = Boolean(clip.status) && clip.status !== 'ready';
if (cutting) live.forgetItem('clip', id);
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 sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
const attribution = clip.auto_generated ? `AI clip from ${sourceName}'s stream` : `Made by ${madeBy}`;
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
@@ -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 (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' });
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.
// 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, {
return page(req, res, {
index: !unlisted && !ownOnly && !aiClip,
index: !unlisted && !ownOnly && !aiClip,
@@ -1420,8 +1491,9 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -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),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
styles: ['kit.css'],
styles: ['kit.css'],
description: clip.ai_overview || factualDescription('clip', clip),
description: clip.ai_overview || factualDescription('clip', clip),
+ canonical: `${base}${watchPath}`,
...(ownOnly || aiClip ? {} : {
...(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',
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
@@ -1441,7 +1513,7 @@ ${clipCredit}
@@ -1441,7 +1513,7 @@ ${clipCredit}
<div class="ovv-head-row">
<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>
<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>
${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>
</div>
</div>
</div>
@@ -1454,7 +1526,7 @@ ${up.html}
@@ -1454,7 +1526,7 @@ ${up.html}
</aside>
</aside>
</div>`,
</div>`,
});
});
- });
+ }
// ── Channel page ────────────────────────────────────────
// ── Channel page ────────────────────────────────────────
// ── Owner changes (web forms, same-origin, as the person on Live) ──
// ── Owner changes (web forms, same-origin, as the person on Live) ──
@@ -1716,7 +1788,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
@@ -1716,7 +1788,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
const first = items[0];
const first = items[0];
const firstHref = first ? await itemHref(first.kind, Number(first.item_id)) : null;
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, {
return page(req, res, {
index: pl.visibility === 'public',
index: pl.visibility === 'public',
@@ -1843,9 +1915,11 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
@@ -1843,9 +1915,11 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
const video = sitemapVideo(it);
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
// 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.
// 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 })),
...[...channels].map(([u, d]) => ({ loc: `${base}/@${u}`, lastmod: d })),
].map((u) => (u.lastmod ? u : { ...u, lastmod: undefined }));
].map((u) => (u.lastmod ? u : { ...u, lastmod: undefined }));
sitemapCache = { at: Date.now(), xml: seo.sitemapXml(urls) };
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
@@ -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 vodItems = (vods && vods.items) || [];
const clipItems = (clips && clips.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 {
return {
name: SITE_NAME,
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
// AGT-5: each of Video's own pages is linked at its Markdown twin (openvibe-shared 4.108.0 leaves files and
@@ -28,7 +28,7 @@ function documentFor(row, { videoUrl, ai = null }) {
@@ -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(' ') : '';
const transcript = ai && ai.status === 'done' && Array.isArray(ai.segments) ? ai.segments.map((x) => x.text).join(' ') : '';
return {
return {
visibility: 'public',
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),
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),
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),
body: [row.title, row.description, overview, transcript.slice(0, 20000)].filter(Boolean).join('\n').slice(0, 48000),
@@ -98,7 +98,7 @@ function createUploadApi({ ctx, config }) {
@@ -98,7 +98,7 @@ function createUploadApi({ ctx, config }) {
if (!row) return undefined;
if (!row) return undefined;
try {
try {
const out = await up.complete(row);
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); }
} catch (err) { return problem(res, err); }
});
});
@@ -107,7 +107,7 @@ function createUploadApi({ ctx, config }) {
@@ -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,
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),
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),
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,
created_at: c.created_at, finished_at: c.finished_at || null,
});
});
r.post('/:id/cuts', express.json({ limit: '4kb' }), async (req, res) => {
r.post('/:id/cuts', express.json({ limit: '4kb' }), async (req, res) => {
@@ -131,7 +131,7 @@ function createUploadApi({ ctx, config }) {
@@ -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). */
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
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 up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
@@ -148,7 +148,7 @@ function mountUploadPages(r, h) {
@@ -148,7 +148,7 @@ function mountUploadPages(r, h) {
if (c.status === 'done') {
if (c.status === 'done') {
const shorter = c.source_seconds && c.kept_seconds ? html` · ${durationLabel(c.source_seconds)} → ${durationLabel(c.kept_seconds)}` : '';
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` : '';
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>`;
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)}%` : '';
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>` : ''}
@@ -226,23 +226,77 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
body: html`<h1>Your videos</h1>
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>
<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.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>`}`,
</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) => {
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);
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
// 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.
// 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) {
if (!own && !who.staff && ctx.sharing) {
const share = await ctx.sharing.shareOf('video', row.id).catch(() => null);
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 manage = own || who.staff;
const ready = row.status === 'ready';
const ready = row.status === 'ready';
const vod = ready ? await up.playback(row) : null;
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
@@ -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,
views: Number(row.views) || 0,
});
});
const thumb = httpsUrl(row.thumbnail_url);
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
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.
// 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) : '';
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
@@ -287,6 +343,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
title: row.title,
title: row.title,
styles: ['kit.css'],
styles: ['kit.css'],
description: about,
description: about,
+ canonical: `${ctx.config.video.url}${watchPath}`,
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
scripts: ready ? ['js/video.js'] : [],
scripts: ready ? ['js/video.js'] : [],
player: !!(ready && vod),
player: !!(ready && vod),
@@ -295,7 +352,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
@@ -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') : ''}
${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.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') : ''}
${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>`
${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>`
: 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>`
: row.status === 'failed' ? html`<p>This upload could not be processed. ${row.error || ''}</p>`
@@ -306,7 +363,7 @@ ${what}
@@ -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>` : ''}
${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 class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
</div>
</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>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${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>
${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"><
@@ -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>
<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>` : ''}
</details></section>` : ''}
${cutsOn ? cutPanel(row, cuts, cutBusy, reel) : ''}
${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>
${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report this video</summary>
<form method="post" action="/u/${row.id}/report">
<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>
<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
@@ -336,7 +393,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
</aside>
</aside>
</div>`,
</div>`,
});
});
- });
+ }
// The AI's transcript and chapters as WebVTT tracks, for whoever may see the video.
// The AI's transcript and chapters as WebVTT tracks, for whoever may see the video.
async function aiOf(req) {
async function aiOf(req) {
@@ -364,7 +421,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
@@ -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 });
await up.update(row, { title: b.title === undefined ? undefined : b.title, description: b.description, visibility: b.visibility === undefined ? undefined : b.visibility });
} catch (err) {
} catch (err) {
if (!(err instanceof UploadError)) throw 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();
if (ctx.videoSearch) ctx.videoSearch.kick();
return res.redirect(303, `/u/${row.id}?saved=1`);
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
@@ -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'));
await ctx.videoCuts.request(row, who, String((req.body || {}).mode || 'pauses'));
} catch (err) {
} catch (err) {
if (!(err instanceof UploadError)) throw 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`);
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
@@ -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 });
await up.report(row, { reporter: signedIn(req) ? ownerOf(req) : up.addressId(req.ip), reason: String(b.reason || ''), note: b.note });
} catch (err) {
} catch (err) {
if (!(err instanceof UploadError)) throw 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();
if (ctx.videoSearch) ctx.videoSearch.kick();
return res.redirect(303, `/u/${row.id}?reported=1`);
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
@@ -413,7 +470,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
/** A card for an upload (home, channel). */
/** A card for an upload (home, channel). */
return function uploadCard(x) {
return function uploadCard(x) {
return card({
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,
channel: x.owner_username || null, channelHref: x.owner_username ? `/@${x.owner_username}` : null,
duration: x.duration_seconds ? durationLabel(x.duration_seconds) : 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,
date: dateLabel(x.ready_at || x.created_at) || null, iso: x.ready_at || x.created_at || null,
@@ -2,7 +2,8 @@
@@ -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,
* 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
* 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)
* 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
@@ -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 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 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);
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). */
/** 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);
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 patchRow = async (id, patch) => {
const cols = Object.keys(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])]);
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
@@ -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}).`);
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 id = s.newId('vup');
const now = s.iso();
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);
if (sharing) await sharing.register(who.requester, 'video', id, visibility);
return await byId(id);
return await byId(id);
}
}
@@ -126,9 +146,10 @@ function createUploads({ s, media, config, log = console, events = null, sharing
@@ -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}).`);
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 id = s.newId('vup');
const now = s.iso();
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);
if (sharing) await sharing.register(who.requester, 'video', id, visibility);
return await byId(id);
return await byId(id);
}
}
@@ -215,14 +236,17 @@ function createUploads({ s, media, config, log = console, events = null, sharing
@@ -215,14 +236,17 @@ function createUploads({ s, media, config, log = console, events = null, sharing
const subject = String(meta.owner_subject || '');
const subject = String(meta.owner_subject || '');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) return null;
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]);
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 now = s.iso();
const title = cleanLine(vod.title, 120) || `Recording, ${now.slice(0, 16).replace('T', ' ')} UTC`;
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 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 *`,
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]);
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})`);
log.log(`[MediaHub] a record-only recording is ${row.id} (Media VOD ${vodId})`);
return await absorb(row, vod);
return await absorb(row, vod);
@@ -274,9 +298,43 @@ function createUploads({ s, media, config, log = console, events = null, sharing
@@ -274,9 +298,43 @@ function createUploads({ s, media, config, log = console, events = null, sharing
return { recorded: true, hidden: hide || row.state === 'hidden' };
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 {
return {
PART_SIZE, REASONS, UploadError, kindOf, addressId, canSee,
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]),
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]),
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]),
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]),
@@ -159,7 +159,10 @@ function downloadsFor(kind, id, what) {
@@ -159,7 +159,10 @@ function downloadsFor(kind, id, what) {
* null; `chapters` = [{ offset_seconds, description }]; `transcript` = [{ start, text }]; `long` = the video is an
* null; `chapters` = [{ offset_seconds, description }]; `transcript` = [{ start, text }]; `long` = the video is an
* hour or longer. Null when there is none of the three.
* 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 = [];
const tabs = [];
if (chat && chat.rows.length) {
if (chat && chat.rows.length) {
const total = chat.rows.length + chat.leftOut;
const total = chat.rows.length + chat.leftOut;
@@ -173,7 +176,7 @@ function companion({ kind, id, chat, chapters, transcript, long }) {
@@ -173,7 +176,7 @@ function companion({ kind, id, chat, chapters, transcript, long }) {
tabs.push({
tabs.push({
key: 'chapters', tab: 'Chapters', title: 'Chapters', count: chapters.length,
key: 'chapters', tab: 'Chapters', title: 'Chapters', count: chapters.length,
note: 'Made by AI from the stream',
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) {
if (transcript && transcript.length) {
@@ -41,6 +41,8 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -41,6 +41,8 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const deletedVods = [];
const deletedVods = [];
const vodState = { duration: 42.5, completeFails: false };
const vodState = { duration: 42.5, completeFails: false };
let vodSeq = 0;
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 };
const state = { down: false, tokenRejects: false };
let seq = 0;
let seq = 0;
@@ -116,9 +118,15 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -116,9 +118,15 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
if (path.startsWith('/api/v1/')) {
if (path.startsWith('/api/v1/')) {
if (state.tokenRejects || req.headers.authorization !== `Bearer ${SERVICE_TOKEN}`) return problem(res, 401, 'auth.required', 'Authentication required');
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]
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');
if (parts[3] !== 'vods') return problem(res, 404, 'route.not_found', 'no such route');
const vodPublicOf = (v) => ({
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,
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,
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,
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 } = {}) {
@@ -129,7 +137,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const body = JSON.parse(raw.toString('utf8') || '{}');
const body = JSON.parse(raw.toString('utf8') || '{}');
const id = ++vodSeq;
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
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 });
return json(res, 201, { id });
}
}
const v = vods.get(Number(parts[4]));
const v = vods.get(Number(parts[4]));
@@ -37,7 +37,12 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body } = {}) {
@@ -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 req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
const chunks = [];
const chunks = [];
res.on('data', (c) => chunks.push(c));
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);
req.on('error', reject);
if (data) req.write(data);
if (data) req.write(data);
@@ -144,10 +144,11 @@ function probe(buf) {
@@ -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 () => {
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 });
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]);
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 });
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']);
assert.deepStrictEqual([r.status, r.json().code], [409, 'cut.is_copy']);
@@ -54,6 +54,7 @@ function startSearch() {
@@ -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']);
[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' };
const ids = { pub: 'vup_01J0000000000000000000000A', priv: 'vup_01J0000000000000000000000B', proc: 'vup_01J0000000000000000000000C', hidden: 'vup_01J0000000000000000000000D' };
await insert(ids.pub);
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.priv, { visibility: 'private' });
await insert(ids.proc, { status: 'processing' });
await insert(ids.proc, { status: 'processing' });
await insert(ids.hidden, { state: 'hidden' });
await insert(ids.hidden, { state: 'hidden' });
@@ -69,7 +70,7 @@ function startSearch() {
@@ -69,7 +70,7 @@ function startSearch() {
assert.strictEqual(r.upserted, 1, JSON.stringify(r));
assert.strictEqual(r.upserted, 1, JSON.stringify(r));
assert.deepStrictEqual([...search.docs.keys()], [ids.pub]);
assert.deepStrictEqual([...search.docs.keys()], [ids.pub]);
const doc = search.docs.get(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.strictEqual(doc.title, 'Trip to the coast');
assert.deepStrictEqual(doc.facets, { kind: 'upload', channel: 'kim', minutes: 2 });
assert.deepStrictEqual(doc.facets, { kind: 'upload', channel: 'kim', minutes: 2 });
assert.strictEqual(doc.revision, 1);
assert.strictEqual(doc.revision, 1);
@@ -37,7 +37,12 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -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 req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
const chunks = [];
const chunks = [];
res.on('data', (c) => chunks.push(c));
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);
req.on('error', reject);
if (data) req.write(data);
if (data) req.write(data);
@@ -87,7 +92,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -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');
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 });
const done1 = await vreq(t.base, `/api/v1/videos/${id}/complete`, { method: 'POST', cookie: kimCookie });
assert.strictEqual(done1.status, 202);
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 () => {
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
@@ -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('<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('Report this video'), 'others may report it');
assert.ok(!page.text.includes('Manage this video'));
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 });
const mine = await vreq(t.base, '/me/videos', { cookie: kimCookie });
assert.ok(mine.text.includes('Trip to the coast') && mine.text.includes('Public'));
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
@@ -122,8 +128,9 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
const listUrl = saved.headers.location;
const listUrl = saved.headers.location;
const listId = listUrl.split('/').pop();
const listId = listUrl.split('/').pop();
const list = await vreq(t.base, listUrl, { cookie: samCookie });
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}&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/${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 } });
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}`);
const inList = await vreq(t.base, `/u/${id}?list=${listId}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -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();
+})();