openvibers/openvibe.mediahub Commits
Polish clip and recording watch pages (#342)
Changes
nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/clip-1280-dark-owner-menu.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/clip-1280-dark.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/clip-1280-light-owner-menu.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/clip-1280-light.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/clip-390-dark-owner-menu.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/clip-390-dark.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/clip-390-light-owner-menu.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/clip-390-light.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/vod-1280-dark-owner-menu.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/vod-1280-dark.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/vod-1280-light-owner-menu.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/vod-1280-light.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/vod-390-dark-owner-menu.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/vod-390-dark.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/vod-390-light-owner-menu.webp differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/watch/vod-390-light.webp differnnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- A person's likes on Live recordings and clips, shown on openvibe.video.++CREATE TABLE video_likes (++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('vod', 'clip')),++ item_id bigint NOT NULL,++ owner text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (kind, item_id, owner)++);++CREATE INDEX video_likes_owner ON video_likes (owner);@@@ -536,7 +536,7 @@ a.ovv-channel-name:hover { text-decoration: underline; }@media (hover: none) { .ovv-pip-bar { opacity: 1; } }/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──-- Edit is first in the action row; the panel opens right under the player and stays out of the way until then (a++ Edit is in More; the panel opens right under the player and stays out of the way until then (alink to #manage without JavaScript still shows it). Who can see it is three choices, and the meta line says which. */.ovv-btn--edit { border-color: color-mix(in srgb, var(--ovv-accent) 55%, var(--ovv-line-strong)); color: var(--text-primary, #e6edf7); }.ovv-btn--edit i { color: var(--ovv-accent); }@@@ -687,7 +687,90 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i.ovv-app-feed .ovv-card-title { font-size: 16px; }}--/* The owner's Delete in the action row (beside Edit): a quiet button, a red one inside its question. */++/* The owner's Delete in More: a quiet button, a red one inside its question. */.ovv-deleter > summary { color: var(--danger-text, var(--danger, #f87171)); }.ovv-deleter .ovv-pop-panel form { margin-top: 10px; }.ovv-deleter .ovv-pop-panel .ovv-btn--danger { width: 100%; justify-content: center; }++++/* Watch page: compact title, channel and one family of actions. */++.ovv-watch .ovv-head { min-width: 0; }++.ovv-watch .ovv-stage, .ovv-watch .ovv-player-wrap { min-width: 0; max-width: 100%; }++.ovv-watch .ovv-title-line { display: flex; align-items: baseline; gap: 9px; min-width: 0; margin-bottom: 14px; }++.ovv-watch .ovv-kind { flex: none; color: var(--ovv-faint); font-size: .76rem; font-weight: 700; letter-spacing: .02em; }++.ovv-watch .ovv-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; margin: 0; font-size: clamp(1.25rem, 2vw, 1.65rem); line-height: 1.3; letter-spacing: -.02em; }++.ovv-watch .ovv-head-row { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px; min-width: 0; }++.ovv-watch .ovv-head .ovv-byline { flex: 0 0 auto; gap: 9px; min-width: 0; }++.ovv-watch .ovv-byline-text { flex: 0 1 auto; min-width: 0; }++.ovv-watch .ovv-avatar { width: 40px; height: 40px; }++.ovv-watch .ovv-follow { min-height: 40px; margin-left: 6px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff !important; }++.ovv-watch .ovv-follow:hover { filter: brightness(1.08); background: var(--live-red, #ef4444); }++.ovv-watch .ovv-actions-shell { position: relative; flex: 1 1 100%; min-width: 0; }++.ovv-watch .ovv-actions { display: flex; flex-wrap: nowrap; align-items: center; width: max-content; gap: 8px; }++.ovv-watch .ovv-actions > * { flex: none; }++.ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn { box-sizing: border-box; display: inline-flex; flex-direction: row; align-items: center; justify-content: center; gap: 8px; width: auto; min-height: 44px; padding: 0 16px; border-radius: 999px; font-size: .84rem; line-height: 1; white-space: nowrap; cursor: pointer; transition: background .18s ease, border-color .18s ease, transform .18s ease, box-shadow .18s ease; }++.ovv-watch .ovv-actions .ovv-btn:hover, .ovv-watch .ovv-actions details > summary.ovv-btn:hover { transform: translateY(-2px); box-shadow: 0 5px 14px rgba(0, 0, 0, .12); }++.ovv-watch .ovv-actions .ovv-btn:active, .ovv-watch .ovv-actions details > summary.ovv-btn:active { transform: scale(.97); }++.ovv-watch .ovv-actions .ovv-btn :is(i, svg) { width: 20px; height: 20px; margin: 0; font-size: 20px; line-height: 20px; }++.ovv-watch .ovv-actions .ovv-btn--icon { width: 44px; min-width: 44px; padding: 0; }++.ovv-watch .ovv-actions > .ovk-ask { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: auto; height: 44px; min-height: 44px; padding: 0 16px; border: 1px solid var(--ovv-line-strong); border-radius: 999px; background: var(--bg-tertiary, #16223a); color: var(--text-primary, #e6edf7); font: inherit; font-size: .84rem; font-weight: 700; line-height: 1; white-space: nowrap; cursor: pointer; transition: background .18s ease, transform .18s ease; }++.ovv-watch .ovv-actions > .ovk-ask:hover { background: var(--bg-hover, #1c2a44); transform: translateY(-2px); }++.ovv-watch .ovv-actions > .ovk-ask :is(.ovk-i, svg, i) { flex: none; width: 20px; height: 20px; font-size: 20px; }++.ovv-watch .ovv-like[aria-pressed="true"] { color: var(--live-red, #ef4444) !important; border-color: color-mix(in srgb, var(--live-red, #ef4444) 35%, transparent); }++.ovv-watch .ovv-like.ovv-like--pop i { animation: ovv-like-pop .32s ease-out; }++@keyframes ovv-like-pop { 50% { transform: scale(1.32) rotate(-9deg); } }++.ovv-watch .ovv-more-actions { display: grid; gap: 2px; }++.ovv-watch .ovv-more-actions > :is(a, button), .ovv-watch .ovv-more-actions .ovv-deleter > summary { box-sizing: border-box; display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 9px; background: transparent; color: inherit !important; font: inherit; font-size: .9rem; font-weight: 700; text-align: left; text-decoration: none; cursor: pointer; }++.ovv-watch .ovv-more-actions > [hidden] { display: none; }++.ovv-watch .ovv-more-actions > :is(a, button):hover, .ovv-watch .ovv-more-actions .ovv-deleter > summary:hover { background: var(--bg-hover, #1c2a44); }++.ovv-watch .ovv-actions .ovv-more-actions > a.ovv-btn, .ovv-watch .ovv-actions .ovv-more-actions .ovv-deleter > summary.ovv-btn { display: flex; justify-content: flex-start; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 9px; background: transparent; box-shadow: none; transform: none; }++.ovv-watch .ovv-more-actions .ovv-pop-list { border-top: 1px solid var(--ovv-line); padding-top: 4px; }++++/* The description is server rendered in both states. Its summary is the short preview. */++.ovv-watch .ovv-about { display: block; min-width: 0; border: 0; border-radius: 14px; padding: 12px 16px; background: color-mix(in srgb, var(--ovv-card) 88%, var(--text-primary, #e6edf7) 12%); }++.ovv-watch .ovv-about > summary { display: block; list-style: none; cursor: pointer; }++.ovv-watch .ovv-about > summary::-webkit-details-marker { display: none; }++.ovv-watch .ovv-about-facts { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; font-size: .84rem; font-weight: 700; }++.ovv-watch .ovv-about-facts .ovv-what { display: contents; margin: 0; color: inherit; font-size: inherit; }++.ovv-watch .ovv-about-facts .ovv-what-kind { display: none; }++.ovv-watch .ovv-about-preview { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; margin-top: 5px; font-size: .9rem; line-height: 1.5; white-space: pre-line; }++.ovv-watch .ovv-about-more, .ovv-watch .ovv-about-toggle-less { display: block; margin-top: 3px; font-size: .83rem; font-weight: 700; }++.ovv-watch .ovv-about-toggle-less { display: none; }++.ovv-watch .ovv-about[open] .ovv-about-preview, .ovv-watch .ovv-about[open] .ovv-about-more { display: none; }++.ovv-watch .ovv-about[open] .ovv-about-toggle-less { display: block; }++.ovv-watch .ovv-about-rest { display: grid; gap: 10px; padding-top: 10px; }++.ovv-watch .ovv-about-rest h3 { margin: 0; font-size: .88rem; }++.ovv-watch .ovv-about-chapters { display: grid; gap: 4px; }++.ovv-watch .ovv-about-rest .ovv-about-text { line-height: 1.55; }++.ovv-watch .ovv-about-source .ovv-source { margin: 5px 0 0; font-size: .82rem; }++.ovv-watch .ovv-about::details-content { height: 0; overflow: hidden; interpolate-size: allow-keywords; transition: content-visibility .25s allow-discrete, height .25s ease; }++.ovv-watch .ovv-about[open]::details-content { height: auto; }++.ovv-watch .ovv-about > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 4px; border-radius: 6px; }++@media (min-width: 1100px) { .ovv-watch .ovv-actions-shell { flex: 1 1 100%; overflow-x: auto; scrollbar-width: thin; } .ovv-watch--clip:not(:has(.ovv-col--side > *)) { grid-template-columns: minmax(0, 1040px); } }++@media (min-width: 1600px) {++ .ovv-page:has(.ovv-watch) { max-width: 1600px; }++ .ovv-watch--clip:not(:has(.ovv-col--side > *)) { grid-template-columns: minmax(0, 1400px); }++ .ovv-watch .ovv-head-row { flex-wrap: nowrap; }++ .ovv-watch .ovv-actions-shell { flex: 1 1 auto; }++}++@media (max-width: 1099px) {++ .ovv-watch .ovv-actions-shell { overflow-x: auto; scrollbar-width: none; }++ .ovv-watch .ovv-actions-shell::-webkit-scrollbar { display: none; }++ .ovv-watch .ovv-head-row { position: relative; }++ .ovv-watch .ovv-head-row::after { content: ''; position: absolute; z-index: 1; right: 0; bottom: 0; width: 28px; height: 44px; pointer-events: none; background: linear-gradient(to right, transparent, var(--bg-primary, #0b1120)); }++}++@media (max-width: 1000px) {++ .ovv-watch .ovv-about { order: 2; }++ .ovv-watch .ovv-companion { order: 3; }++ .ovv-watch .ovv-upnext { order: 4; }++}++@media (max-width: 600px) {++ .ovv-watch .ovv-head-row { display: block; }++ .ovv-watch .ovv-head .ovv-byline { display: flex; width: 100%; margin-bottom: 12px; }++ .ovv-watch .ovv-follow { margin-left: auto; }++ .ovv-watch .ovv-title-line { align-items: flex-start; }++ .ovv-watch .ovv-kind { margin-top: 4px; }++ .ovv-watch .ovv-actions-shell { width: 100%; }++ .ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn { min-height: 44px; padding: 0 16px; font-size: .84rem; }++ .ovv-watch .ovv-actions .ovv-btn--icon { width: 44px; padding: 0; }++}++@media (prefers-reduced-motion: reduce) { .ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn, .ovv-watch .ovv-about::details-content { transition: none; animation: none; } .ovv-watch .ovv-like.ovv-like--pop i { animation: none; } }@@@ -132,6 +132,19 @@if (!d.open) return;all.forEach(function (o) { if (o !== d && !o.contains(d)) close(o, false); });if (sheet(d)) return;++ if (d.closest('.ovv-actions-shell')) {++ const button = d.querySelector('summary').getBoundingClientRect();++ const width = Math.min(panel.offsetWidth, window.innerWidth - 24);++ const left = Math.max(12, Math.min(button.left, window.innerWidth - width - 12));++ const below = window.innerHeight - button.bottom;++ panel.style.position = 'fixed';++ panel.style.left = left + 'px';++ panel.style.right = 'auto';++ panel.style.width = width + 'px';++ panel.style.top = below >= panel.offsetHeight + 12 ? button.bottom + 8 + 'px' : 'auto';++ panel.style.bottom = below >= panel.offsetHeight + 12 ? 'auto' : window.innerHeight - button.top + 8 + 'px';++ return;++ }const vw = document.documentElement.clientWidth;const r = panel.getBoundingClientRect();if (r.right > vw - 12) panel.style.transform = 'translateX(' + Math.round(vw - 12 - r.right) + 'px)';@@@ -160,6 +173,59 @@});}++ function watchActions() {++ document.querySelectorAll('[data-ovv-like]').forEach(function (button) {++ const count = button.querySelector('.ovv-like-count');++ button.addEventListener('click', async function () {++ if (button.disabled) return;++ const ref = button.getAttribute('data-ovv-like').split(':');++ button.disabled = true;++ try {++ const response = await fetch('/watch/like', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ kind: ref[0], id: ref[1] }) });++ if (response.status === 401) { location.href = '/auth/login?next=' + encodeURIComponent(location.pathname + location.search); return; }++ if (!response.ok) throw new Error('Like failed');++ const state = await response.json();++ button.setAttribute('aria-pressed', String(state.liked));++ if (count) count.textContent = String(state.count);++ if (state.liked) {++ button.classList.remove('ovv-like--pop');++ void button.offsetWidth;++ button.classList.add('ovv-like--pop');++ }++ } catch (_) { button.title = 'Could not save the like. Try again.'; }++ finally { button.disabled = false; }++ });++ });++ document.querySelectorAll('[data-ovv-copy-time]').forEach(function (button) {++ button.addEventListener('click', async function () {++ const video = document.querySelector('.ovv-player');++ const url = new URL(button.getAttribute('data-ovv-copy-time'), location.origin);++ const seconds = video && Number.isFinite(video.currentTime) ? Math.floor(video.currentTime) : 0;++ if (seconds > 0) url.searchParams.set('t', String(seconds));++ try {++ await navigator.clipboard.writeText(url.href);++ const label = button.innerHTML;++ button.textContent = 'Copied';++ setTimeout(function () { button.innerHTML = label; }, 1800);++ } catch (_) { location.href = url.href; }++ });++ });++ const video = document.querySelector('video[data-ovv-video]');++ const pip = document.querySelector('[data-ovv-pip]');++ if (video && pip && document.pictureInPictureEnabled && typeof video.requestPictureInPicture === 'function') {++ const ready = function () { if (video.readyState >= 1) pip.hidden = false; };++ ready();++ video.addEventListener('loadedmetadata', ready, { once: true });++ pip.addEventListener('click', async function () {++ try {++ if (document.pictureInPictureElement === video) await document.exitPictureInPicture();++ else await video.requestPictureInPicture();++ pip.closest('details').open = false;++ } catch (_) { pip.title = 'Picture in picture is unavailable for this video.'; }++ });++ }++ }++// "Copy for YouTube": the chapters' text on the clipboard, where the browser lets a page write it.function copyButtons() {if (!navigator.clipboard || !window.fetch) return;@@@ -485,6 +551,7 @@}menus();++ watchActions();copyButtons();tabs();openers();@@@ -35,6 +35,7 @@ const TABLES = [// A playlist's items have no owner column of their own; they are exported with extraExport below and erased// by the playlists' ON DELETE CASCADE, so only the playlists table is listed here.{ table: 'video_playlists', subject: 'owner', value: AS_REQUESTER, file: 'playlists.json' },++ { table: 'video_likes', subject: 'owner', value: AS_REQUESTER, file: 'video_likes.json' },// An upload that never finished is this service's own state, not something the person made: it is erased with// the account but is not part of the export.{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },@@@ -81,7 +82,7 @@ function createAccount({ db, config, media, log = console, onMediaVodReady = nuldb,service: 'media-hub',tables: TABLES,-- note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, your OpenVibe.Pics pictures and albums, and the videos you uploaded to OpenVibe.Video. The bytes of your files, pictures and videos live in OpenVibe.Media.',++ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts, playlists and video likes, your OpenVibe.Pics pictures and albums, and the videos you uploaded to OpenVibe.Video. The bytes of your files, pictures and videos live in OpenVibe.Media.',extraExport: async (db, subject) => {const rows = await db.many(`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at@@@ -684,6 +684,11 @@ function createVideoRoutes(ctx) {const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);const ownerOf = (req) => `user:${req.viewer.subject}`;++ async function likesOf(req, kind, id) {++ const count = await ctx.s.db.one('SELECT COUNT(*)::int AS n FROM video_likes WHERE kind = $1 AND item_id = $2', [kind, Number(id)]);++ const liked = signedIn(req) ? await ctx.s.db.maybe('SELECT 1 AS yes FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), ownerOf(req)]) : null;++ return { count: Number(count.n) || 0, liked: Boolean(liked) };++ }const signInPrompt = (what, next) => html`<div class="notice signin-prompt"><p>${what}</p><p><a class="ovv-btn ovv-btn--primary" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p></div>`;const mustSignIn = (back, why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {title: 'Sign in',@@@ -748,8 +753,40 @@ function createVideoRoutes(ctx) {const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;/** The owner's badge in the meta line: who can see it now. */const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;-- /** The owner's Edit button, first in the action row: opens the edit panel under the player. */++ /** The owner's Edit link in More opens the panel under the player. */const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;++ const titleLine = (kind, title) => html`<div class="ovv-title-line"><span class="ovv-kind">${kind}</span><h1 class="ovv-title">${title}</h1></div>`;++ const likeButton = (state, kind, id) => html`<button class="ovv-btn ovv-like" type="button" data-ovv-like="${kind}:${id}" aria-pressed="${state.liked ? 'true' : 'false'}" title="Like this ${kind === 'clip' ? 'clip' : 'recording'}"><i class="fa-regular fa-heart" aria-hidden="true"></i> Like <span class="ovv-like-count">${state.count}</span></button>`;++ const channelRow = (username, name, followers) => {++ if (!username) return '';++ const channel = `${livePublic}/@${username}`;++ const count = Number(followers);++ return html`<div class="ovv-byline"><a class="ovv-avatar" href="${channel}" aria-hidden="true" tabindex="-1">${String(name || username).slice(0, 1).toUpperCase()}${live.isUsername(String(username)) ? html`<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy">` : ''}</a><div class="ovv-byline-text"><a class="ovv-channel-name" href="${channel}">${name || username}</a>${Number.isSafeInteger(count) && count >= 0 ? html`<span class="ovv-meta">${count.toLocaleString('en-US')} ${count === 1 ? 'follower' : 'followers'}</span>` : ''}</div><a class="ovv-btn ovv-follow" href="${channel}" title="Follow ${name || username} on OpenVibe.Live"><i class="fa-solid fa-plus" aria-hidden="true"></i> Follow</a></div>`;++ };++ const watchMore = ({ path, manageable, kind, id, items }) => actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: html`<div class="ovv-more-actions"><button type="button" data-ovv-copy-time="${base}${path}"><i class="fa-regular fa-copy" aria-hidden="true"></i> Copy link at time</button><button type="button" data-ovv-pip hidden><i class="fa-regular fa-window-restore" aria-hidden="true"></i> Picture in picture</button><a href="mailto:[email protected]?subject=${encodeURIComponent(`Report ${kind} ${base}${path}`)}"><i class="fa-regular fa-flag" aria-hidden="true"></i> Report</a>${manageable ? html`${editButton(kind === 'clip' ? 'clip' : 'recording')}${deleteAction({ action: `/${kind === 'clip' ? 'c' : 'v'}/${id}`, what: kind === 'clip' ? 'clip' : 'recording' })}` : ''}${menuLinks(items)}</div>` });++ const watchFacts = (views, date, duration, visibility) => {++ const count = Math.max(0, Number(views) || 0);++ const stamp = date instanceof Date ? date.getTime() : Date.parse(String(date || ''));++ const days = Number.isFinite(stamp) ? Math.max(0, Math.floor((Date.now() - stamp) / 86400000)) : null;++ const weeks = days == null ? 0 : Math.round(days / 7);++ const months = days == null ? 0 : Math.round(days / 30);++ const age = days == null ? '' : days === 0 ? 'today' : days < 7 ? `${days} ${days === 1 ? 'day' : 'days'} ago` : days < 35 ? `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago` : days < 365 ? `${months} ${months === 1 ? 'month' : 'months'} ago` : dayLabel(date);++ const seconds = Math.max(0, Math.floor(Number(duration) || 0));++ const length = seconds ? seconds < 60 ? `${seconds} s` : seconds < 3600 ? `${Math.round(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.round((seconds % 3600) / 60)} min` : '';++ return html`<span>${count.toLocaleString('en-US')} ${count === 1 ? 'view' : 'views'}</span>${age ? html`<span>· ${age}</span>` : ''}${visibilityBadge(visibility)}${length ? html`<span>· ${length}</span>` : ''}`;++ };++ const watchAbout = ({ facts, description, source = '', madeBy = '', ai = '', parts = '', from = '', chapters: chapterRows = [], path = '', tags = [] }) => html`<details class="ovv-about" id="about">++<summary><div class="ovv-about-facts">${facts}</div>++<span class="ovv-about-preview">${description || 'More about this video'}</span>++${source ? html`<div class="ovv-about-source">${source}</div>` : ''}++<span class="ovv-about-more">…more</span><span class="ovv-about-toggle-less">Show less</span></summary>++<div class="ovv-about-rest">${description && description !== ai ? html`<p class="ovv-about-text">${description}</p>` : ''}++${madeBy ? html`<p class="ovv-about-from">Made by ${madeBy}</p>` : ''}${parts}${from}++${chapterRows.length ? html`<div class="ovv-about-chapters"><h3>Chapters</h3>${chapterRows.map((m) => html`<a href="${path}${path.includes('?') ? '&' : '?'}t=${m.offset_seconds}">${clock(m.offset_seconds)} ${m.description}</a>`)}</div>` : ''}++${tags.length ? html`<p class="ovv-about-tags">${tags.map((tag) => html`<span>#${tag}</span>`)}</p>` : ''}++${ai ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${ai}</p>` : ''}++ </div></details>`;++/*** "Clip" in the action row: a finished recording, any signed-in viewer who can see it (Live's rule, as on Live). A* small form under the button; signed out, the button signs in first and comes back.@@@ -908,6 +945,20 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON// parser, so the two never read each other's bodies.r.use(express.urlencoded({ extended: false, limit: '64kb' }));++ r.post('/watch/like', async (req, res) => {++ if (!signedIn(req)) return res.status(401).json({ error: 'Sign in to like this video.' });++ if (!sameSite(req, res, '/')) return undefined;++ const kind = String(req.body.kind || '');++ const id = String(req.body.id || '');++ if (!['vod', 'clip'].includes(kind) || !live.isId(id)) return res.status(400).json({ error: 'Invalid video.' });++ const data = await (kind === 'vod' ? live.vod(id) : live.clip(id)) || await ownView(req, res, kind, id);++ if (!data || !data[kind]) return res.status(404).json({ error: 'Video not found.' });++ const owner = ownerOf(req);++ const existing = await ctx.s.db.maybe('SELECT 1 AS yes FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), owner]);++ if (existing) await ctx.s.db.exec('DELETE FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), owner]);++ else await ctx.s.db.exec('INSERT INTO video_likes (kind, item_id, owner, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [kind, Number(id), owner, ctx.s.iso()]);++ res.set('Cache-Control', 'no-store').json(await likesOf(req, kind, id));++ });// ── 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, renderVod, renderClip });@@@ -1378,19 +1429,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvconst unlisted = vod.visibility === 'unlisted';const channelName = vod.display_name || vod.username || null;-- const localChannel = vod.username ? `/@${vod.username}` : null;const liveChannel = `${livePublic}/@${vod.username}`;-- const metaParts = [];-- // What this video is: a stream recording, the day it was streamed, how long it is, how many people watched it.-- // The channel is in the byline under the title and the category in the About section, so nothing is said twice.-- const what = whatItIs({-- kind: 'recording',-- streamedAt: streamDate(vod.stream_started_at),-- publishedAt: vod.created_at,-- durationSeconds: vod.duration_seconds,-- views: Number(vod.view_count) || 0,-- });-- if (manageable) metaParts.push(visibilityBadge(vod.visibility));++ const facts = watchFacts(vod.view_count, streamDate(vod.stream_started_at) || vod.created_at, vod.duration_seconds, vod.visibility);const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;@@@ -1414,6 +1454,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvif (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, watchId: vod.watch_id || null, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });++ const likes = await likesOf(req, 'vod', id);return page(req, res, {index: !unlisted && !ownOnly && !thin,noindex: unlisted || ownOnly || thin,@@@ -1435,28 +1476,17 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(vod), { owner: vod.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${avatarAttr(vod.username, config.networkUrl)}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div><div class="ovv-head">--<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>++${titleLine('Stream recording', vod.title || 'Stream recording')}${labelsUi.chips(labelsUi.inForce(vod))}--${what}<div class="ovv-head-row">--<div class="ovv-byline">--${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}${live.isUsername(String(vod.username || '')) ? html`<img src="${config.networkUrl}/avatar/${vod.username}" alt="" loading="lazy">` : ''}</a>` : ''}--<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 ? html`${editButton('recording')}${deleteAction({ action: `/v/${id}`, what: 'recording' })}` : ''}${saveForm}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${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({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>++${channelRow(vod.username, channelName, vod.follower_count)}++<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'vod', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${ownOnly ? '' : clipAction(req, id, vod)}${watchMore({ path: watchPath, manageable, kind: 'vod', id, items: 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>` : ''}</div></div>${manageable ? ownerPanel('vod', id, vod) : ''}--<section class="ovv-about" aria-labelledby="h-about">--<h2 id="h-about" class="ovv-about-title">About</h2>--${vod.description ? html`<p class="ovv-about-text">${vod.description}</p>` : ''}--${vod.ai_overview ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${vod.ai_overview}</p>` : ''}--${partNav}--${channelName ? html`<p class="ovv-about-from"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : ''}--</section>++${watchAbout({ facts, description: vod.description || about, ai: vod.ai_overview, parts: partNav, chapters: mems, path: watchPath, tags: Array.isArray(vod.tags) ? vod.tags : [], from: channelName ? html`<p class="ovv-about-from">Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : '' })}${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}</div><aside class="ovv-col ovv-col--side">@@@ -1510,6 +1540,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl]);let threadUrl = null;try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }++ const commentRows = cm && Array.isArray(cm.comments) ? cm.comments.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20) : null;const unlisted = clip.visibility === 'unlisted';// AI clips stay out of search, as on Live: noindex and no structured data.@@@ -1533,15 +1564,8 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`: !sourceVod? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`-- : html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${sourceWho ? html`, ${sourceWho}'s stream recording` : ''}${clipAt ? html`. The clip starts at ${clipAt} in it` : ''}.</p>`;-- const what = whatItIs({-- kind: 'clip',-- label: aiClip ? 'AI clip' : 'Clip',-- streamedAt: sourceVod && sourceVod !== 'down' ? streamDate(sourceVod.stream_started_at) : null,-- publishedAt: clip.created_at,-- durationSeconds: clip.duration_seconds != null ? clip.duration_seconds : clip.duration,-- views: Number(clip.view_count) || 0,-- });++ : html`<p class="ovv-source">Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${clipAt ? html` at ${clipAt}` : ''}${sourceWho ? html` · ${sourceWho}'s stream` : ''}</p>`;++ const facts = watchFacts(clip.view_count, clip.created_at, clip.duration_seconds != null ? clip.duration_seconds : clip.duration, clip.visibility);const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;@@@ -1552,6 +1576,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-clif (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, watchId: clip.watch_id || null, chat: null, chapters: [], transcript: segs, long: false });++ const likes = await likesOf(req, 'clip', id);return page(req, res, {index: !unlisted && !ownOnly && !aiClip,@@@ -1575,19 +1600,17 @@ ${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`: html`<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(clip), { owner: clip.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}<div class="ovv-head">--<h1 class="ovv-title">${clip.title || 'Clip'}</h1>++${titleLine(aiClip ? 'AI clip' : 'Clip', clip.title || 'Clip')}${labelsUi.chips(labelsUi.inForce(clip))}--${what}--${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 ? html`${editButton('clip')}${deleteAction({ action: `/c/${id}`, what: '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 ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>++${channelRow(clip.source_streamer_username || clip.username, clip.source_streamer_display_name || clip.display_name || clip.username, clip.follower_count)}++<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'clip', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${fullRecording ? html`<a class="ovv-btn" 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>` : ''}${watchMore({ path: watchPath, manageable, kind: 'clip', id, items: moreItems })}</div></div></div></div></div>${manageable ? ownerPanel('clip', id, clip) : ''}--${clip.ai_overview ? html`<section class="ovv-about" aria-labelledby="h-about"><h2 id="h-about" class="ovv-about-title">About</h2><p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${clip.ai_overview}</p></section>` : ''}++${watchAbout({ facts, description: clip.description || clip.ai_overview || attribution, source: clipCredit, madeBy, ai: clip.ai_overview, tags: Array.isArray(clip.tags) ? clip.tags : [] })}++${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}</div><aside class="ovv-col ovv-col--side">${side || ''}@@@ -39,11 +39,16 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';const t = await boot({ env: { MEDIAHUB_EVENTS_SECRET: SECRET } });const kim = t.network.addUser('kim');const sam = t.network.addUser('sam');++ await t.ctx.s.db.exec('INSERT INTO video_likes (kind, item_id, owner, created_at) VALUES ($1, $2, $3, $4), ($1, $2, $5, $4)', ['vod', 101, `user:${kim.subject}`, '2026-11-09T00:00:00Z', `user:${sam.subject}`]);const file = (await upload(t, kim, bytes(300, 'kim'), { name: 'kim.bin' })).json().file;const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;await t.get(`/s/${slug}/report`, { as: sam, form: { reason: 'malware', note: 'nope' }, headers: SAME });const sams = (await upload(t, sam, bytes(300, 'sam'), { name: 'sam.bin' })).json().file;++ await t.ctx.s.db.exec('INSERT INTO mh_radio_tracks (id, file_id, owner, title, artist, source_kind, licence, licence_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $6, $7, $8)', [++ 'radio_kim', file.id, `user:${kim.subject}`, 'Kim track', 'Kim', 'creator', 'creator_grant', 'https://openvibe.radio/grant',++ 'radio_sam', sams.id, `user:${sam.subject}`, 'Sam track', 'Sam',++ ]);// And kim files one against sam's file, so each has a report of their own in the export.const samSlug = (await share(t, sam, { file_id: sams.id, hours: 24 })).json().share.slug;await t.get(`/s/${samSlug}/report`, { as: kim, form: { reason: 'other', note: 'kim says' }, headers: SAME });@@@ -80,7 +85,9 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';assert.ok(files, 'a files.json part');assert.strictEqual(files.content.length, 1);assert.strictEqual(files.content[0].id, file.id);-- assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'reports.json', 'shares.json', 'usage.json'], 'and the rest of the person\'s own data');++ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'radio_tracks.json', 'reports.json', 'shares.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');++ assert.deepStrictEqual(part.files.find((f) => f.name === 'radio_tracks.json').content.map((track) => track.id), ['radio_kim'], 'a person can export their radio publications');++ assert.deepStrictEqual(part.files.find((f) => f.name === 'video_likes.json').content.map((like) => like.item_id), [101], 'a person can export their likes');assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));assert.strictEqual(part.files.find((f) => f.name === 'reports.json').content.length, 1, 'the report kim filed');assert.ok(!JSON.stringify(part).includes(sams.id), 'nothing of sam\'s');@@@ -105,6 +112,10 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';assert.strictEqual(confirmation.subject, kim.subject);assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));++ assert.strictEqual(confirmation.erased.mh_radio_tracks, 1, 'radio publications are erased with the account');++ assert.strictEqual(confirmation.erased.video_likes, 1, 'likes are erased with the account');++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM mh_radio_tracks WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s radio publication remains');++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM video_likes WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s like remains');// Sam's drive is untouched.assert.strictEqual((await t.get(`/api/v1/files/${sams.id}`, { as: sam })).status, 200);@@@ -136,7 +136,7 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, sameconst r = await video('/v/301');assert.strictEqual(r.status, 200, r.text.slice(0, 300));assert.ok(r.text.includes('<div class="ovv-player-wrap" data-ov-labels="nudity,18+" data-ov-labels-owner="@kim" data-ov-labels-opened>'), 'the player is the labelled thing');-- assert.ok(/<h1 class="ovv-title">Kim labelled<\/h1>\s*<ul class="ovk-labels" aria-label="Content labels"><li class="ovk-label" data-label="nudity">Nudity<\/li><li class="ovk-label ovk-label--adult" data-label="18\+">18\+<\/li><\/ul>/.test(r.text), 'the chips under the title');++ assert.ok(/<h1 class="ovv-title">Kim labelled<\/h1>(?:<\/div>)?\s*<ul class="ovk-labels" aria-label="Content labels"><li class="ovk-label" data-label="nudity">Nudity<\/li><li class="ovk-label ovk-label--adult" data-label="18\+">18\+<\/li><\/ul>/.test(r.text), 'the chips under the title');assert.ok(!r.text.includes('Flashing lights'), 'a suggestion is never shown to viewers');assert.ok(!r.text.includes('name="labels"'), 'no picker for a viewer');});@@@ -113,34 +113,29 @@ function startLive() {await check('outcome 1: a recording says what it is: a stream recording, the day, the length, the views', async () => {const r = await get('/v/501');assert.strictEqual(r.status, 200);-- const what = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(r.text);-- assert.ok(what, 'the page says what the video is');-- const line = what[1];-- assert.ok(line.includes('Stream recording'), line);-- assert.ok(line.includes('Streamed on') && line.includes('Oct 12, 2026'), 'the day it was streamed: ' + line);-- assert.ok(line.includes('13 minutes'), 'its length, in words: ' + line);++ assert.ok(r.text.includes('<span class="ovv-kind">Stream recording</span>'), 'the title says what the video is');++ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(r.text)[1];++ assert.ok(line.includes('13 min'), 'its length: ' + line);assert.ok(line.includes('1,204 views'), 'its views: ' + line);-- assert.ok(/<time datetime="2026-10-12T09:30:00.000Z">/.test(line), 'the day in a machine-readable time: ' + line);++ assert.ok(r.text.includes('Oct 12, 2026'), 'the stream date remains on the page');// The channel is in the byline under it, and the category in About: nothing is said twice.-- assert.ok(r.text.includes('class="ovv-channel-name"') && r.text.includes('@Streamer'), 'the channel');++ assert.ok(r.text.includes('class="ovv-channel-name"') && r.text.includes('>Streamer</a>'), 'the channel');assert.ok(r.text.includes("Streamed live on") && r.text.includes('in Gaming'), 'the category is in About');});await check('outcome 1: a recording with no stream time says when it was published, and guesses nothing', async () => {const r = await get('/v/502');assert.strictEqual(r.status, 200);-- const line = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(r.text)[1];-- assert.ok(line.includes('Published on') && line.includes('Oct 12, 2026'), line);-- assert.ok(!line.includes('Streamed on'), 'no day it was streamed: Live never said one');-- assert.ok(!line.includes('views'), 'no views: Live counted none');-- assert.ok(line.includes('45 seconds'), 'the length it does have: ' + line);++ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(r.text)[1];++ assert.ok(!r.text.includes('Streamed on'), 'no day it was streamed: Live never said one');++ assert.ok(line.includes('45 s'), 'the length it does have: ' + line);});await check('outcome 1: a clip says what it is, and an upload says it is an upload', async () => {const clip = await get('/c/601');-- const line = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(clip.text)[1];-- assert.ok(line.includes('Clip</span>') && !line.includes('AI clip'), 'a clip: ' + line);-- assert.ok(line.includes('30 seconds') && line.includes('20 views'), line);++ assert.ok(clip.text.includes('<span class="ovv-kind">Clip</span>') && !clip.text.includes('<span class="ovv-kind">AI clip</span>'), 'a clip');++ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(clip.text)[1];++ assert.ok(line.includes('30 s') && line.includes('20 views'), line);// The upload's own row: the day it was uploaded, its length and its views.const s = t.ctx.s;const id = 'vup_01J0000000000000000000000B';@@@ -163,8 +158,8 @@ function startLive() {assert.ok(line.includes('Cut from'), line);assert.ok(line.includes('A long recording'), 'the title of the recording it came from: ' + line);assert.ok(line.includes('href="/v/501?t=65"'), 'a link back to the recording, at the clip\'s moment: ' + line);-- assert.ok(line.includes("Streamer's stream recording"), 'whose recording it was: ' + line);-- assert.ok(line.includes('starts at 1:05 in it'), 'where the clip starts in it: ' + line);++ assert.ok(line.includes("Streamer's stream"), 'whose recording it was: ' + line);++ assert.ok(line.includes('at 1:05'), 'where the clip starts in it: ' + line);});await check('outcome 2: a clip whose recording is not public says so, and names nothing it cannot know', async () => {@@@ -1,9 +1,7 @@'use strict';/**-- * The owner deletes a recording or clip from the action row (a streamer, 11-08: deleting a test stream's recording-- * "wasn't immediately obvious": it was Edit, then a fold inside the edit panel, then a checkbox). Delete sits beside-- * Edit as a small question with one button; the form carries confirm=1 for the route's check; the edit panel no-- * longer hides a second delete.++ * The owner deletes a recording or clip from the More menu. Delete sits beside Edit there as a small question with++ * one button; the form carries confirm=1 for the route's check; the edit panel no longer hides a second delete.** node test/video-owner-delete.test.js*/@@@ -13,14 +11,13 @@ const path = require('path');const { deleteAction } = require('../server/video/watch-ui');const out = String(deleteAction({ action: '/v/42', what: 'recording' }));--assert.match(out, /<details class="ovv-pop ovv-deleter" data-ovv-menu>/, 'a small menu in the action row');++assert.match(out, /<details class="ovv-pop ovv-deleter" data-ovv-menu>/, 'a small confirmation menu');assert.match(out, /Delete this recording\?/, 'it asks first');assert.match(out, /<form method="post" action="\/v\/42\/delete"><input type="hidden" name="confirm" value="1">/, 'the form confirms itself');assert.match(out, /Delete for good<\/button>/, 'one button says what happens');const pages = fs.readFileSync(path.join(__dirname, '..', 'server', 'video', 'pages.js'), 'utf8');--assert.ok(pages.includes("deleteAction({ action: `/v/${id}`, what: 'recording' })"), 'the recording page offers it beside Edit');--assert.ok(pages.includes("deleteAction({ action: `/c/${id}`, what: 'clip' })"), 'the clip page too');++assert.ok(pages.includes('deleteAction({ action: `/${kind === \'clip\' ? \'c\' : \'v\'}/${id}`'), 'both watch pages offer Delete through More');const panel = pages.slice(pages.indexOf('function ownerPanel('), pages.indexOf('// The shell\'s navbar/footer scripts'));assert.ok(!/ovv-danger|I want to delete it for good/.test(panel), 'the edit panel no longer hides a second delete');--console.log('video: Delete is beside Edit, one question, one button');++console.log('video: Delete is in More, one question, one button');@@@ -87,20 +87,36 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });assert.strictEqual(mine.status, 200);assert.ok(mine.text.includes('Edit this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');-- // 2026-10-27: Edit is first in the action row and opens the panel under the player; who can see it is three-- // choices; the meta line tells the owner which it is now.-- assert.ok(/<div class="ovv-actions"><a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage"/.test(mine.text), 'Edit leads the actions');-- assert.ok(mine.text.indexOf('id="manage"') < mine.text.indexOf('id="h-about"'), 'the panel sits under the player, before About');++ // Owner tools live in More, with the panel under the player.++ assert.ok(/class="ovv-more-actions"[\s\S]*class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage"/.test(mine.text), 'Edit is in More');++ assert.ok(mine.text.indexOf('id="manage"') < mine.text.indexOf('id="about"'), 'the panel sits under the player, before About');assert.strictEqual((mine.text.match(/<input type="radio" name="visibility" value="(public|unlisted|private)"/g) || []).length, 3, 'three visibility choices');assert.ok(/class="ovv-vis-badge ovv-vis-badge--/.test(mine.text), 'the owner sees who can see it');assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<input type="radio" name="visibility" value="public" checked>'), 'prefilled');const other = await vreq(t.base, '/v/101', { cookie: samCookie });assert.ok(!other.text.includes('Edit this recording'), 'not for someone else');-- assert.ok(!/class="ovv-vis-badge/.test(other.text) && !other.text.includes('ovv-btn--edit'), 'no badge or Edit for someone else');++ assert.ok(!other.text.includes('ovv-btn--edit'), 'no Edit for someone else');const anon = await vreq(t.base, '/v/101');assert.ok(!anon.text.includes('Edit this recording'), 'not signed out');});++ await check('recording likes are counted per signed-in person', async () => {++ const path = '/watch/like';++ const body = { kind: 'vod', id: '101' };++ assert.strictEqual((await vreq(t.base, path, { method: 'POST', form: body })).status, 401);++ let r = await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(JSON.parse(r.text), { count: 1, liked: true });++ r = await vreq(t.base, '/v/101', { cookie: samCookie });++ assert.match(r.text, /class="ovv-like-count">1<\/span>/);++ r = await vreq(t.base, path, { method: 'POST', cookie: samCookie, form: body });++ assert.deepStrictEqual(JSON.parse(r.text), { count: 2, liked: true });++ r = await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body });++ assert.deepStrictEqual(JSON.parse(r.text), { count: 1, liked: false });++ assert.strictEqual((await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: { kind: 'clip', id: '999' } })).status, 404);++ assert.strictEqual((await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body, same: false })).status, 403);++ });++await check('an edit is made on Live as the person, then the page shows it', async () => {await vreq(t.base, '/v/101'); // warm the read cacheconst r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim renamed', description: 'Second', visibility: 'unlisted' } });@@@ -60,6 +60,7 @@ function startLive() {};const publicClip = {id: 201, vod_id: 101, title: 'A clip', start_time: 5, end_time: 15, duration_seconds: 10,++ created_at: '2026-10-26T11:00:00.000Z', view_count: 49,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',@@@ -374,6 +375,9 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('Chapters'));assert.ok(r.text.includes('Transcript'));assert.ok(r.text.includes('A public overview'));++ assert.ok(r.text.includes('<details class="ovv-about" id="about">') && r.text.includes('<span class="ovv-about-preview">A public stream</span>'), 'description has a collapsed preview');++ assert.ok(/class="ovv-about-facts"><span>100 views<\/span>.*ovv-vis-badge.*<span>· 45 s<\/span>/.test(r.text), 'views, visibility and short duration lead the description');++ assert.ok(r.text.includes('<p class="ovv-about-text">A public stream</p>') && r.text.includes('ovv-about-toggle-less'), 'full description is in the HTML for expansion');assert.ok(r.text.includes('/@streamer'));assert.ok(!r.text.includes('Edit this recording'), 'owner tools only for the channel (video-owner.test.js)');assert.ok(r.text.includes('Discuss on OpenVibe.Community'));@@@ -410,6 +414,8 @@ function countOccurrences(haystack, needle) {assert.strictEqual(cld.duration, 'PT10S');assert.ok(r.text.includes('src="https://openvibe.media/c/201"'), 'the clip playback url');assert.ok(r.text.includes('Made by Clipper'), 'attributed to the person who made it');++ assert.ok(r.text.includes('<span>49 views</span>') && r.text.includes('<span>· 10 s</span>'), 'clip stats use the short description line');++ assert.ok(/<summary>[\s\S]*Cut from <a href="\/v\/101\?t=5">/.test(r.text), 'the source link is in the short description');assert.ok(r.text.includes('Full recording</a>') && r.text.includes('title="Watch the recording it was cut from, from this moment"'));assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/clip-1280-dark-owner-menu.webp differ
Binary files /dev/null and b/docs/screenshots/watch/clip-1280-dark-owner-menu.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/clip-1280-dark.webp differ
Binary files /dev/null and b/docs/screenshots/watch/clip-1280-dark.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/clip-1280-light-owner-menu.webp differ
Binary files /dev/null and b/docs/screenshots/watch/clip-1280-light-owner-menu.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/clip-1280-light.webp differ
Binary files /dev/null and b/docs/screenshots/watch/clip-1280-light.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/clip-390-dark-owner-menu.webp differ
Binary files /dev/null and b/docs/screenshots/watch/clip-390-dark-owner-menu.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/clip-390-dark.webp differ
Binary files /dev/null and b/docs/screenshots/watch/clip-390-dark.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/clip-390-light-owner-menu.webp differ
Binary files /dev/null and b/docs/screenshots/watch/clip-390-light-owner-menu.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/clip-390-light.webp differ
Binary files /dev/null and b/docs/screenshots/watch/clip-390-light.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/vod-1280-dark-owner-menu.webp differ
Binary files /dev/null and b/docs/screenshots/watch/vod-1280-dark-owner-menu.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/vod-1280-dark.webp differ
Binary files /dev/null and b/docs/screenshots/watch/vod-1280-dark.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/vod-1280-light-owner-menu.webp differ
Binary files /dev/null and b/docs/screenshots/watch/vod-1280-light-owner-menu.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/vod-1280-light.webp differ
Binary files /dev/null and b/docs/screenshots/watch/vod-1280-light.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/vod-390-dark-owner-menu.webp differ
Binary files /dev/null and b/docs/screenshots/watch/vod-390-dark-owner-menu.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/vod-390-dark.webp differ
Binary files /dev/null and b/docs/screenshots/watch/vod-390-dark.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/vod-390-light-owner-menu.webp differ
Binary files /dev/null and b/docs/screenshots/watch/vod-390-light-owner-menu.webp differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/watch/vod-390-light.webp differ
Binary files /dev/null and b/docs/screenshots/watch/vod-390-light.webp differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- A person's likes on Live recordings and clips, shown on openvibe.video.
+CREATE TABLE video_likes (
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('vod', 'clip')),
+ item_id bigint NOT NULL,
+ owner text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (kind, item_id, owner)
+);
+CREATE INDEX video_likes_owner ON video_likes (owner);
@@ -536,7 +536,7 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -536,7 +536,7 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@media (hover: none) { .ovv-pip-bar { opacity: 1; } }
@media (hover: none) { .ovv-pip-bar { opacity: 1; } }
/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──
/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──
- Edit is first in the action row; the panel opens right under the player and stays out of the way until then (a
+ Edit is in More; the panel opens right under the player and stays out of the way until then (a
link to #manage without JavaScript still shows it). Who can see it is three choices, and the meta line says which. */
link to #manage without JavaScript still shows it). Who can see it is three choices, and the meta line says which. */
.ovv-btn--edit { border-color: color-mix(in srgb, var(--ovv-accent) 55%, var(--ovv-line-strong)); color: var(--text-primary, #e6edf7); }
.ovv-btn--edit { border-color: color-mix(in srgb, var(--ovv-accent) 55%, var(--ovv-line-strong)); color: var(--text-primary, #e6edf7); }
.ovv-btn--edit i { color: var(--ovv-accent); }
.ovv-btn--edit i { color: var(--ovv-accent); }
@@ -687,7 +687,90 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
@@ -687,7 +687,90 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
.ovv-app-feed .ovv-card-title { font-size: 16px; }
.ovv-app-feed .ovv-card-title { font-size: 16px; }
}
}
-/* The owner's Delete in the action row (beside Edit): a quiet button, a red one inside its question. */
+/* The owner's Delete in More: a quiet button, a red one inside its question. */
.ovv-deleter > summary { color: var(--danger-text, var(--danger, #f87171)); }
.ovv-deleter > summary { color: var(--danger-text, var(--danger, #f87171)); }
.ovv-deleter .ovv-pop-panel form { margin-top: 10px; }
.ovv-deleter .ovv-pop-panel form { margin-top: 10px; }
.ovv-deleter .ovv-pop-panel .ovv-btn--danger { width: 100%; justify-content: center; }
.ovv-deleter .ovv-pop-panel .ovv-btn--danger { width: 100%; justify-content: center; }
+
+/* Watch page: compact title, channel and one family of actions. */
+.ovv-watch .ovv-head { min-width: 0; }
+.ovv-watch .ovv-stage, .ovv-watch .ovv-player-wrap { min-width: 0; max-width: 100%; }
+.ovv-watch .ovv-title-line { display: flex; align-items: baseline; gap: 9px; min-width: 0; margin-bottom: 14px; }
+.ovv-watch .ovv-kind { flex: none; color: var(--ovv-faint); font-size: .76rem; font-weight: 700; letter-spacing: .02em; }
+.ovv-watch .ovv-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; margin: 0; font-size: clamp(1.25rem, 2vw, 1.65rem); line-height: 1.3; letter-spacing: -.02em; }
+.ovv-watch .ovv-head-row { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px; min-width: 0; }
+.ovv-watch .ovv-head .ovv-byline { flex: 0 0 auto; gap: 9px; min-width: 0; }
+.ovv-watch .ovv-byline-text { flex: 0 1 auto; min-width: 0; }
+.ovv-watch .ovv-avatar { width: 40px; height: 40px; }
+.ovv-watch .ovv-follow { min-height: 40px; margin-left: 6px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff !important; }
+.ovv-watch .ovv-follow:hover { filter: brightness(1.08); background: var(--live-red, #ef4444); }
+.ovv-watch .ovv-actions-shell { position: relative; flex: 1 1 100%; min-width: 0; }
+.ovv-watch .ovv-actions { display: flex; flex-wrap: nowrap; align-items: center; width: max-content; gap: 8px; }
+.ovv-watch .ovv-actions > * { flex: none; }
+.ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn { box-sizing: border-box; display: inline-flex; flex-direction: row; align-items: center; justify-content: center; gap: 8px; width: auto; min-height: 44px; padding: 0 16px; border-radius: 999px; font-size: .84rem; line-height: 1; white-space: nowrap; cursor: pointer; transition: background .18s ease, border-color .18s ease, transform .18s ease, box-shadow .18s ease; }
+.ovv-watch .ovv-actions .ovv-btn:hover, .ovv-watch .ovv-actions details > summary.ovv-btn:hover { transform: translateY(-2px); box-shadow: 0 5px 14px rgba(0, 0, 0, .12); }
+.ovv-watch .ovv-actions .ovv-btn:active, .ovv-watch .ovv-actions details > summary.ovv-btn:active { transform: scale(.97); }
+.ovv-watch .ovv-actions .ovv-btn :is(i, svg) { width: 20px; height: 20px; margin: 0; font-size: 20px; line-height: 20px; }
+.ovv-watch .ovv-actions .ovv-btn--icon { width: 44px; min-width: 44px; padding: 0; }
+.ovv-watch .ovv-actions > .ovk-ask { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: auto; height: 44px; min-height: 44px; padding: 0 16px; border: 1px solid var(--ovv-line-strong); border-radius: 999px; background: var(--bg-tertiary, #16223a); color: var(--text-primary, #e6edf7); font: inherit; font-size: .84rem; font-weight: 700; line-height: 1; white-space: nowrap; cursor: pointer; transition: background .18s ease, transform .18s ease; }
+.ovv-watch .ovv-actions > .ovk-ask:hover { background: var(--bg-hover, #1c2a44); transform: translateY(-2px); }
+.ovv-watch .ovv-actions > .ovk-ask :is(.ovk-i, svg, i) { flex: none; width: 20px; height: 20px; font-size: 20px; }
+.ovv-watch .ovv-like[aria-pressed="true"] { color: var(--live-red, #ef4444) !important; border-color: color-mix(in srgb, var(--live-red, #ef4444) 35%, transparent); }
+.ovv-watch .ovv-like.ovv-like--pop i { animation: ovv-like-pop .32s ease-out; }
+@keyframes ovv-like-pop { 50% { transform: scale(1.32) rotate(-9deg); } }
+.ovv-watch .ovv-more-actions { display: grid; gap: 2px; }
+.ovv-watch .ovv-more-actions > :is(a, button), .ovv-watch .ovv-more-actions .ovv-deleter > summary { box-sizing: border-box; display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 9px; background: transparent; color: inherit !important; font: inherit; font-size: .9rem; font-weight: 700; text-align: left; text-decoration: none; cursor: pointer; }
+.ovv-watch .ovv-more-actions > [hidden] { display: none; }
+.ovv-watch .ovv-more-actions > :is(a, button):hover, .ovv-watch .ovv-more-actions .ovv-deleter > summary:hover { background: var(--bg-hover, #1c2a44); }
+.ovv-watch .ovv-actions .ovv-more-actions > a.ovv-btn, .ovv-watch .ovv-actions .ovv-more-actions .ovv-deleter > summary.ovv-btn { display: flex; justify-content: flex-start; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 9px; background: transparent; box-shadow: none; transform: none; }
+.ovv-watch .ovv-more-actions .ovv-pop-list { border-top: 1px solid var(--ovv-line); padding-top: 4px; }
+
+/* The description is server rendered in both states. Its summary is the short preview. */
+.ovv-watch .ovv-about { display: block; min-width: 0; border: 0; border-radius: 14px; padding: 12px 16px; background: color-mix(in srgb, var(--ovv-card) 88%, var(--text-primary, #e6edf7) 12%); }
+.ovv-watch .ovv-about > summary { display: block; list-style: none; cursor: pointer; }
+.ovv-watch .ovv-about > summary::-webkit-details-marker { display: none; }
+.ovv-watch .ovv-about-facts { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; font-size: .84rem; font-weight: 700; }
+.ovv-watch .ovv-about-facts .ovv-what { display: contents; margin: 0; color: inherit; font-size: inherit; }
+.ovv-watch .ovv-about-facts .ovv-what-kind { display: none; }
+.ovv-watch .ovv-about-preview { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; margin-top: 5px; font-size: .9rem; line-height: 1.5; white-space: pre-line; }
+.ovv-watch .ovv-about-more, .ovv-watch .ovv-about-toggle-less { display: block; margin-top: 3px; font-size: .83rem; font-weight: 700; }
+.ovv-watch .ovv-about-toggle-less { display: none; }
+.ovv-watch .ovv-about[open] .ovv-about-preview, .ovv-watch .ovv-about[open] .ovv-about-more { display: none; }
+.ovv-watch .ovv-about[open] .ovv-about-toggle-less { display: block; }
+.ovv-watch .ovv-about-rest { display: grid; gap: 10px; padding-top: 10px; }
+.ovv-watch .ovv-about-rest h3 { margin: 0; font-size: .88rem; }
+.ovv-watch .ovv-about-chapters { display: grid; gap: 4px; }
+.ovv-watch .ovv-about-rest .ovv-about-text { line-height: 1.55; }
+.ovv-watch .ovv-about-source .ovv-source { margin: 5px 0 0; font-size: .82rem; }
+.ovv-watch .ovv-about::details-content { height: 0; overflow: hidden; interpolate-size: allow-keywords; transition: content-visibility .25s allow-discrete, height .25s ease; }
+.ovv-watch .ovv-about[open]::details-content { height: auto; }
+.ovv-watch .ovv-about > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 4px; border-radius: 6px; }
+@media (min-width: 1100px) { .ovv-watch .ovv-actions-shell { flex: 1 1 100%; overflow-x: auto; scrollbar-width: thin; } .ovv-watch--clip:not(:has(.ovv-col--side > *)) { grid-template-columns: minmax(0, 1040px); } }
+@media (min-width: 1600px) {
+ .ovv-page:has(.ovv-watch) { max-width: 1600px; }
+ .ovv-watch--clip:not(:has(.ovv-col--side > *)) { grid-template-columns: minmax(0, 1400px); }
+ .ovv-watch .ovv-head-row { flex-wrap: nowrap; }
+ .ovv-watch .ovv-actions-shell { flex: 1 1 auto; }
+}
+@media (max-width: 1099px) {
+ .ovv-watch .ovv-actions-shell { overflow-x: auto; scrollbar-width: none; }
+ .ovv-watch .ovv-actions-shell::-webkit-scrollbar { display: none; }
+ .ovv-watch .ovv-head-row { position: relative; }
+ .ovv-watch .ovv-head-row::after { content: ''; position: absolute; z-index: 1; right: 0; bottom: 0; width: 28px; height: 44px; pointer-events: none; background: linear-gradient(to right, transparent, var(--bg-primary, #0b1120)); }
+}
+@media (max-width: 1000px) {
+ .ovv-watch .ovv-about { order: 2; }
+ .ovv-watch .ovv-companion { order: 3; }
+ .ovv-watch .ovv-upnext { order: 4; }
+}
+@media (max-width: 600px) {
+ .ovv-watch .ovv-head-row { display: block; }
+ .ovv-watch .ovv-head .ovv-byline { display: flex; width: 100%; margin-bottom: 12px; }
+ .ovv-watch .ovv-follow { margin-left: auto; }
+ .ovv-watch .ovv-title-line { align-items: flex-start; }
+ .ovv-watch .ovv-kind { margin-top: 4px; }
+ .ovv-watch .ovv-actions-shell { width: 100%; }
+ .ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn { min-height: 44px; padding: 0 16px; font-size: .84rem; }
+ .ovv-watch .ovv-actions .ovv-btn--icon { width: 44px; padding: 0; }
+}
+@media (prefers-reduced-motion: reduce) { .ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn, .ovv-watch .ovv-about::details-content { transition: none; animation: none; } .ovv-watch .ovv-like.ovv-like--pop i { animation: none; } }
@@ -132,6 +132,19 @@
@@ -132,6 +132,19 @@
if (!d.open) return;
if (!d.open) return;
all.forEach(function (o) { if (o !== d && !o.contains(d)) close(o, false); });
all.forEach(function (o) { if (o !== d && !o.contains(d)) close(o, false); });
if (sheet(d)) return;
if (sheet(d)) return;
+ if (d.closest('.ovv-actions-shell')) {
+ const button = d.querySelector('summary').getBoundingClientRect();
+ const width = Math.min(panel.offsetWidth, window.innerWidth - 24);
+ const left = Math.max(12, Math.min(button.left, window.innerWidth - width - 12));
+ const below = window.innerHeight - button.bottom;
+ panel.style.position = 'fixed';
+ panel.style.left = left + 'px';
+ panel.style.right = 'auto';
+ panel.style.width = width + 'px';
+ panel.style.top = below >= panel.offsetHeight + 12 ? button.bottom + 8 + 'px' : 'auto';
+ panel.style.bottom = below >= panel.offsetHeight + 12 ? 'auto' : window.innerHeight - button.top + 8 + 'px';
+ return;
+ }
const vw = document.documentElement.clientWidth;
const vw = document.documentElement.clientWidth;
const r = panel.getBoundingClientRect();
const r = panel.getBoundingClientRect();
if (r.right > vw - 12) panel.style.transform = 'translateX(' + Math.round(vw - 12 - r.right) + 'px)';
if (r.right > vw - 12) panel.style.transform = 'translateX(' + Math.round(vw - 12 - r.right) + 'px)';
@@ -160,6 +173,59 @@
@@ -160,6 +173,59 @@
});
});
}
}
+ function watchActions() {
+ document.querySelectorAll('[data-ovv-like]').forEach(function (button) {
+ const count = button.querySelector('.ovv-like-count');
+ button.addEventListener('click', async function () {
+ if (button.disabled) return;
+ const ref = button.getAttribute('data-ovv-like').split(':');
+ button.disabled = true;
+ try {
+ const response = await fetch('/watch/like', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ kind: ref[0], id: ref[1] }) });
+ if (response.status === 401) { location.href = '/auth/login?next=' + encodeURIComponent(location.pathname + location.search); return; }
+ if (!response.ok) throw new Error('Like failed');
+ const state = await response.json();
+ button.setAttribute('aria-pressed', String(state.liked));
+ if (count) count.textContent = String(state.count);
+ if (state.liked) {
+ button.classList.remove('ovv-like--pop');
+ void button.offsetWidth;
+ button.classList.add('ovv-like--pop');
+ }
+ } catch (_) { button.title = 'Could not save the like. Try again.'; }
+ finally { button.disabled = false; }
+ });
+ });
+ document.querySelectorAll('[data-ovv-copy-time]').forEach(function (button) {
+ button.addEventListener('click', async function () {
+ const video = document.querySelector('.ovv-player');
+ const url = new URL(button.getAttribute('data-ovv-copy-time'), location.origin);
+ const seconds = video && Number.isFinite(video.currentTime) ? Math.floor(video.currentTime) : 0;
+ if (seconds > 0) url.searchParams.set('t', String(seconds));
+ try {
+ await navigator.clipboard.writeText(url.href);
+ const label = button.innerHTML;
+ button.textContent = 'Copied';
+ setTimeout(function () { button.innerHTML = label; }, 1800);
+ } catch (_) { location.href = url.href; }
+ });
+ });
+ const video = document.querySelector('video[data-ovv-video]');
+ const pip = document.querySelector('[data-ovv-pip]');
+ if (video && pip && document.pictureInPictureEnabled && typeof video.requestPictureInPicture === 'function') {
+ const ready = function () { if (video.readyState >= 1) pip.hidden = false; };
+ ready();
+ video.addEventListener('loadedmetadata', ready, { once: true });
+ pip.addEventListener('click', async function () {
+ try {
+ if (document.pictureInPictureElement === video) await document.exitPictureInPicture();
+ else await video.requestPictureInPicture();
+ pip.closest('details').open = false;
+ } catch (_) { pip.title = 'Picture in picture is unavailable for this video.'; }
+ });
+ }
+ }
+
// "Copy for YouTube": the chapters' text on the clipboard, where the browser lets a page write it.
// "Copy for YouTube": the chapters' text on the clipboard, where the browser lets a page write it.
function copyButtons() {
function copyButtons() {
if (!navigator.clipboard || !window.fetch) return;
if (!navigator.clipboard || !window.fetch) return;
@@ -485,6 +551,7 @@
@@ -485,6 +551,7 @@
}
}
menus();
menus();
+ watchActions();
copyButtons();
copyButtons();
tabs();
tabs();
openers();
openers();
@@ -35,6 +35,7 @@ const TABLES = [
@@ -35,6 +35,7 @@ const TABLES = [
// A playlist's items have no owner column of their own; they are exported with extraExport below and erased
// A playlist's items have no owner column of their own; they are exported with extraExport below and erased
// by the playlists' ON DELETE CASCADE, so only the playlists table is listed here.
// by the playlists' ON DELETE CASCADE, so only the playlists table is listed here.
{ table: 'video_playlists', subject: 'owner', value: AS_REQUESTER, file: 'playlists.json' },
{ table: 'video_playlists', subject: 'owner', value: AS_REQUESTER, file: 'playlists.json' },
+ { table: 'video_likes', subject: 'owner', value: AS_REQUESTER, file: 'video_likes.json' },
// An upload that never finished is this service's own state, not something the person made: it is erased with
// An upload that never finished is this service's own state, not something the person made: it is erased with
// the account but is not part of the export.
// the account but is not part of the export.
{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
@@ -81,7 +82,7 @@ function createAccount({ db, config, media, log = console, onMediaVodReady = nul
@@ -81,7 +82,7 @@ function createAccount({ db, config, media, log = console, onMediaVodReady = nul
db,
db,
service: 'media-hub',
service: 'media-hub',
tables: TABLES,
tables: TABLES,
- note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, your OpenVibe.Pics pictures and albums, and the videos you uploaded to OpenVibe.Video. The bytes of your files, pictures and videos live in OpenVibe.Media.',
+ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts, playlists and video likes, your OpenVibe.Pics pictures and albums, and the videos you uploaded to OpenVibe.Video. The bytes of your files, pictures and videos live in OpenVibe.Media.',
extraExport: async (db, subject) => {
extraExport: async (db, subject) => {
const rows = await db.many(
const rows = await db.many(
`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at
`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at
@@ -684,6 +684,11 @@ function createVideoRoutes(ctx) {
@@ -684,6 +684,11 @@ function createVideoRoutes(ctx) {
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const ownerOf = (req) => `user:${req.viewer.subject}`;
const ownerOf = (req) => `user:${req.viewer.subject}`;
+ async function likesOf(req, kind, id) {
+ const count = await ctx.s.db.one('SELECT COUNT(*)::int AS n FROM video_likes WHERE kind = $1 AND item_id = $2', [kind, Number(id)]);
+ const liked = signedIn(req) ? await ctx.s.db.maybe('SELECT 1 AS yes FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), ownerOf(req)]) : null;
+ return { count: Number(count.n) || 0, liked: Boolean(liked) };
+ }
const signInPrompt = (what, next) => html`<div class="notice signin-prompt"><p>${what}</p><p><a class="ovv-btn ovv-btn--primary" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p></div>`;
const signInPrompt = (what, next) => html`<div class="notice signin-prompt"><p>${what}</p><p><a class="ovv-btn ovv-btn--primary" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p></div>`;
const mustSignIn = (back, why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {
const mustSignIn = (back, why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {
title: 'Sign in',
title: 'Sign in',
@@ -748,8 +753,40 @@ function createVideoRoutes(ctx) {
@@ -748,8 +753,40 @@ function createVideoRoutes(ctx) {
const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;
const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;
/** The owner's badge in the meta line: who can see it now. */
/** The owner's badge in the meta line: who can see it now. */
const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;
const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;
- /** The owner's Edit button, first in the action row: opens the edit panel under the player. */
+ /** The owner's Edit link in More opens the panel under the player. */
const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;
const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;
+ const titleLine = (kind, title) => html`<div class="ovv-title-line"><span class="ovv-kind">${kind}</span><h1 class="ovv-title">${title}</h1></div>`;
+ const likeButton = (state, kind, id) => html`<button class="ovv-btn ovv-like" type="button" data-ovv-like="${kind}:${id}" aria-pressed="${state.liked ? 'true' : 'false'}" title="Like this ${kind === 'clip' ? 'clip' : 'recording'}"><i class="fa-regular fa-heart" aria-hidden="true"></i> Like <span class="ovv-like-count">${state.count}</span></button>`;
+ const channelRow = (username, name, followers) => {
+ if (!username) return '';
+ const channel = `${livePublic}/@${username}`;
+ const count = Number(followers);
+ return html`<div class="ovv-byline"><a class="ovv-avatar" href="${channel}" aria-hidden="true" tabindex="-1">${String(name || username).slice(0, 1).toUpperCase()}${live.isUsername(String(username)) ? html`<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy">` : ''}</a><div class="ovv-byline-text"><a class="ovv-channel-name" href="${channel}">${name || username}</a>${Number.isSafeInteger(count) && count >= 0 ? html`<span class="ovv-meta">${count.toLocaleString('en-US')} ${count === 1 ? 'follower' : 'followers'}</span>` : ''}</div><a class="ovv-btn ovv-follow" href="${channel}" title="Follow ${name || username} on OpenVibe.Live"><i class="fa-solid fa-plus" aria-hidden="true"></i> Follow</a></div>`;
+ };
+ const watchMore = ({ path, manageable, kind, id, items }) => actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: html`<div class="ovv-more-actions"><button type="button" data-ovv-copy-time="${base}${path}"><i class="fa-regular fa-copy" aria-hidden="true"></i> Copy link at time</button><button type="button" data-ovv-pip hidden><i class="fa-regular fa-window-restore" aria-hidden="true"></i> Picture in picture</button><a href="mailto:[email protected]?subject=${encodeURIComponent(`Report ${kind} ${base}${path}`)}"><i class="fa-regular fa-flag" aria-hidden="true"></i> Report</a>${manageable ? html`${editButton(kind === 'clip' ? 'clip' : 'recording')}${deleteAction({ action: `/${kind === 'clip' ? 'c' : 'v'}/${id}`, what: kind === 'clip' ? 'clip' : 'recording' })}` : ''}${menuLinks(items)}</div>` });
+ const watchFacts = (views, date, duration, visibility) => {
+ const count = Math.max(0, Number(views) || 0);
+ const stamp = date instanceof Date ? date.getTime() : Date.parse(String(date || ''));
+ const days = Number.isFinite(stamp) ? Math.max(0, Math.floor((Date.now() - stamp) / 86400000)) : null;
+ const weeks = days == null ? 0 : Math.round(days / 7);
+ const months = days == null ? 0 : Math.round(days / 30);
+ const age = days == null ? '' : days === 0 ? 'today' : days < 7 ? `${days} ${days === 1 ? 'day' : 'days'} ago` : days < 35 ? `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago` : days < 365 ? `${months} ${months === 1 ? 'month' : 'months'} ago` : dayLabel(date);
+ const seconds = Math.max(0, Math.floor(Number(duration) || 0));
+ const length = seconds ? seconds < 60 ? `${seconds} s` : seconds < 3600 ? `${Math.round(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.round((seconds % 3600) / 60)} min` : '';
+ return html`<span>${count.toLocaleString('en-US')} ${count === 1 ? 'view' : 'views'}</span>${age ? html`<span>· ${age}</span>` : ''}${visibilityBadge(visibility)}${length ? html`<span>· ${length}</span>` : ''}`;
+ };
+ const watchAbout = ({ facts, description, source = '', madeBy = '', ai = '', parts = '', from = '', chapters: chapterRows = [], path = '', tags = [] }) => html`<details class="ovv-about" id="about">
+<summary><div class="ovv-about-facts">${facts}</div>
+<span class="ovv-about-preview">${description || 'More about this video'}</span>
+${source ? html`<div class="ovv-about-source">${source}</div>` : ''}
+<span class="ovv-about-more">…more</span><span class="ovv-about-toggle-less">Show less</span></summary>
+<div class="ovv-about-rest">${description && description !== ai ? html`<p class="ovv-about-text">${description}</p>` : ''}
+${madeBy ? html`<p class="ovv-about-from">Made by ${madeBy}</p>` : ''}${parts}${from}
+${chapterRows.length ? html`<div class="ovv-about-chapters"><h3>Chapters</h3>${chapterRows.map((m) => html`<a href="${path}${path.includes('?') ? '&' : '?'}t=${m.offset_seconds}">${clock(m.offset_seconds)} ${m.description}</a>`)}</div>` : ''}
+${tags.length ? html`<p class="ovv-about-tags">${tags.map((tag) => html`<span>#${tag}</span>`)}</p>` : ''}
+${ai ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${ai}</p>` : ''}
+ </div></details>`;
+
/**
/**
* "Clip" in the action row: a finished recording, any signed-in viewer who can see it (Live's rule, as on Live). A
* "Clip" in the action row: a finished recording, any signed-in viewer who can see it (Live's rule, as on Live). A
* small form under the button; signed out, the button signs in first and comes back.
* small form under the button; signed out, the button signs in first and comes back.
@@ -908,6 +945,20 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -908,6 +945,20 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// parser, so the two never read each other's bodies.
// parser, so the two never read each other's bodies.
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
+ r.post('/watch/like', async (req, res) => {
+ if (!signedIn(req)) return res.status(401).json({ error: 'Sign in to like this video.' });
+ if (!sameSite(req, res, '/')) return undefined;
+ const kind = String(req.body.kind || '');
+ const id = String(req.body.id || '');
+ if (!['vod', 'clip'].includes(kind) || !live.isId(id)) return res.status(400).json({ error: 'Invalid video.' });
+ const data = await (kind === 'vod' ? live.vod(id) : live.clip(id)) || await ownView(req, res, kind, id);
+ if (!data || !data[kind]) return res.status(404).json({ error: 'Video not found.' });
+ const owner = ownerOf(req);
+ const existing = await ctx.s.db.maybe('SELECT 1 AS yes FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), owner]);
+ if (existing) await ctx.s.db.exec('DELETE FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), owner]);
+ else await ctx.s.db.exec('INSERT INTO video_likes (kind, item_id, owner, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [kind, Number(id), owner, ctx.s.iso()]);
+ res.set('Cache-Control', 'no-store').json(await likesOf(req, kind, id));
+ });
// ── 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, renderVod, renderClip });
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 });
@@ -1378,19 +1429,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1378,19 +1429,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const unlisted = vod.visibility === 'unlisted';
const unlisted = vod.visibility === 'unlisted';
const channelName = vod.display_name || vod.username || null;
const channelName = vod.display_name || vod.username || null;
- const localChannel = vod.username ? `/@${vod.username}` : null;
const liveChannel = `${livePublic}/@${vod.username}`;
const liveChannel = `${livePublic}/@${vod.username}`;
- const metaParts = [];
- // What this video is: a stream recording, the day it was streamed, how long it is, how many people watched it.
- // The channel is in the byline under the title and the category in the About section, so nothing is said twice.
- const what = whatItIs({
- kind: 'recording',
- streamedAt: streamDate(vod.stream_started_at),
- publishedAt: vod.created_at,
- durationSeconds: vod.duration_seconds,
- views: Number(vod.view_count) || 0,
- });
- if (manageable) metaParts.push(visibilityBadge(vod.visibility));
+ const facts = watchFacts(vod.view_count, streamDate(vod.stream_started_at) || vod.created_at, vod.duration_seconds, vod.visibility);
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
@@ -1414,6 +1454,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1414,6 +1454,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
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, watchId: vod.watch_id || null, 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 });
+ const likes = await likesOf(req, 'vod', id);
return page(req, res, {
return page(req, res, {
index: !unlisted && !ownOnly && !thin,
index: !unlisted && !ownOnly && !thin,
noindex: unlisted || ownOnly || thin,
noindex: unlisted || ownOnly || thin,
@@ -1435,28 +1476,17 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1435,28 +1476,17 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(vod), { owner: vod.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${avatarAttr(vod.username, config.networkUrl)}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(vod), { owner: vod.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${avatarAttr(vod.username, config.networkUrl)}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
<div class="ovv-head">
<div class="ovv-head">
-<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
+${titleLine('Stream recording', vod.title || 'Stream recording')}
${labelsUi.chips(labelsUi.inForce(vod))}
${labelsUi.chips(labelsUi.inForce(vod))}
-${what}
<div class="ovv-head-row">
<div class="ovv-head-row">
-<div class="ovv-byline">
-${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}${live.isUsername(String(vod.username || '')) ? html`<img src="${config.networkUrl}/avatar/${vod.username}" alt="" loading="lazy">` : ''}</a>` : ''}
-<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 ? html`${editButton('recording')}${deleteAction({ action: `/v/${id}`, what: 'recording' })}` : ''}${saveForm}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${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({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
+${channelRow(vod.username, channelName, vod.follower_count)}
+<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'vod', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${ownOnly ? '' : clipAction(req, id, vod)}${watchMore({ path: watchPath, manageable, kind: 'vod', id, items: moreItems })}</div></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>
</div>
</div>
${manageable ? ownerPanel('vod', id, vod) : ''}
${manageable ? ownerPanel('vod', id, vod) : ''}
-<section class="ovv-about" aria-labelledby="h-about">
-<h2 id="h-about" class="ovv-about-title">About</h2>
-${vod.description ? html`<p class="ovv-about-text">${vod.description}</p>` : ''}
-${vod.ai_overview ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${vod.ai_overview}</p>` : ''}
-${partNav}
-${channelName ? html`<p class="ovv-about-from"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : ''}
-</section>
+${watchAbout({ facts, description: vod.description || about, ai: vod.ai_overview, parts: partNav, chapters: mems, path: watchPath, tags: Array.isArray(vod.tags) ? vod.tags : [], from: channelName ? html`<p class="ovv-about-from">Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : '' })}
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
</div>
</div>
<aside class="ovv-col ovv-col--side">
<aside class="ovv-col ovv-col--side">
@@ -1510,6 +1540,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -1510,6 +1540,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
]);
]);
let threadUrl = null;
let threadUrl = null;
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
+ const commentRows = cm && Array.isArray(cm.comments) ? cm.comments.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20) : null;
const unlisted = clip.visibility === 'unlisted';
const unlisted = clip.visibility === 'unlisted';
// AI clips stay out of search, as on Live: noindex and no structured data.
// AI clips stay out of search, as on Live: noindex and no structured data.
@@ -1533,15 +1564,8 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -1533,15 +1564,8 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`
? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`
: !sourceVod
: !sourceVod
? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`
? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`
- : html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${sourceWho ? html`, ${sourceWho}'s stream recording` : ''}${clipAt ? html`. The clip starts at ${clipAt} in it` : ''}.</p>`;
- const what = whatItIs({
- kind: 'clip',
- label: aiClip ? 'AI clip' : 'Clip',
- streamedAt: sourceVod && sourceVod !== 'down' ? streamDate(sourceVod.stream_started_at) : null,
- publishedAt: clip.created_at,
- durationSeconds: clip.duration_seconds != null ? clip.duration_seconds : clip.duration,
- views: Number(clip.view_count) || 0,
- });
+ : html`<p class="ovv-source">Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${clipAt ? html` at ${clipAt}` : ''}${sourceWho ? html` · ${sourceWho}'s stream` : ''}</p>`;
+ const facts = watchFacts(clip.view_count, clip.created_at, clip.duration_seconds != null ? clip.duration_seconds : clip.duration, clip.visibility);
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
@@ -1552,6 +1576,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -1552,6 +1576,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
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, watchId: clip.watch_id || null, 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 });
+ const likes = await likesOf(req, 'clip', id);
return page(req, res, {
return page(req, res, {
index: !unlisted && !ownOnly && !aiClip,
index: !unlisted && !ownOnly && !aiClip,
@@ -1575,19 +1600,17 @@ ${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is
@@ -1575,19 +1600,17 @@ ${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is
${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`
${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`
: html`<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(clip), { owner: clip.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
: html`<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(clip), { owner: clip.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
<div class="ovv-head">
<div class="ovv-head">
-<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
+${titleLine(aiClip ? 'AI clip' : 'Clip', clip.title || 'Clip')}
${labelsUi.chips(labelsUi.inForce(clip))}
${labelsUi.chips(labelsUi.inForce(clip))}
-${what}
-${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>
-${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 ? html`${editButton('clip')}${deleteAction({ action: `/c/${id}`, what: '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 ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
+${channelRow(clip.source_streamer_username || clip.username, clip.source_streamer_display_name || clip.display_name || clip.username, clip.follower_count)}
+<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'clip', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${fullRecording ? html`<a class="ovv-btn" 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>` : ''}${watchMore({ path: watchPath, manageable, kind: 'clip', id, items: moreItems })}</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
${manageable ? ownerPanel('clip', id, clip) : ''}
${manageable ? ownerPanel('clip', id, clip) : ''}
-${clip.ai_overview ? html`<section class="ovv-about" aria-labelledby="h-about"><h2 id="h-about" class="ovv-about-title">About</h2><p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${clip.ai_overview}</p></section>` : ''}
+${watchAbout({ facts, description: clip.description || clip.ai_overview || attribution, source: clipCredit, madeBy, ai: clip.ai_overview, tags: Array.isArray(clip.tags) ? clip.tags : [] })}
+${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
</div>
</div>
<aside class="ovv-col ovv-col--side">
<aside class="ovv-col ovv-col--side">
${side || ''}
${side || ''}
@@ -39,11 +39,16 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -39,11 +39,16 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
const t = await boot({ env: { MEDIAHUB_EVENTS_SECRET: SECRET } });
const t = await boot({ env: { MEDIAHUB_EVENTS_SECRET: SECRET } });
const kim = t.network.addUser('kim');
const kim = t.network.addUser('kim');
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
+ await t.ctx.s.db.exec('INSERT INTO video_likes (kind, item_id, owner, created_at) VALUES ($1, $2, $3, $4), ($1, $2, $5, $4)', ['vod', 101, `user:${kim.subject}`, '2026-11-09T00:00:00Z', `user:${sam.subject}`]);
const file = (await upload(t, kim, bytes(300, 'kim'), { name: 'kim.bin' })).json().file;
const file = (await upload(t, kim, bytes(300, 'kim'), { name: 'kim.bin' })).json().file;
const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
await t.get(`/s/${slug}/report`, { as: sam, form: { reason: 'malware', note: 'nope' }, headers: SAME });
await t.get(`/s/${slug}/report`, { as: sam, form: { reason: 'malware', note: 'nope' }, headers: SAME });
const sams = (await upload(t, sam, bytes(300, 'sam'), { name: 'sam.bin' })).json().file;
const sams = (await upload(t, sam, bytes(300, 'sam'), { name: 'sam.bin' })).json().file;
+ await t.ctx.s.db.exec('INSERT INTO mh_radio_tracks (id, file_id, owner, title, artist, source_kind, licence, licence_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $6, $7, $8)', [
+ 'radio_kim', file.id, `user:${kim.subject}`, 'Kim track', 'Kim', 'creator', 'creator_grant', 'https://openvibe.radio/grant',
+ 'radio_sam', sams.id, `user:${sam.subject}`, 'Sam track', 'Sam',
+ ]);
// And kim files one against sam's file, so each has a report of their own in the export.
// And kim files one against sam's file, so each has a report of their own in the export.
const samSlug = (await share(t, sam, { file_id: sams.id, hours: 24 })).json().share.slug;
const samSlug = (await share(t, sam, { file_id: sams.id, hours: 24 })).json().share.slug;
await t.get(`/s/${samSlug}/report`, { as: kim, form: { reason: 'other', note: 'kim says' }, headers: SAME });
await t.get(`/s/${samSlug}/report`, { as: kim, form: { reason: 'other', note: 'kim says' }, headers: SAME });
@@ -80,7 +85,9 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -80,7 +85,9 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
assert.ok(files, 'a files.json part');
assert.ok(files, 'a files.json part');
assert.strictEqual(files.content.length, 1);
assert.strictEqual(files.content.length, 1);
assert.strictEqual(files.content[0].id, file.id);
assert.strictEqual(files.content[0].id, file.id);
- assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'reports.json', 'shares.json', 'usage.json'], 'and the rest of the person\'s own data');
+ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'radio_tracks.json', 'reports.json', 'shares.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');
+ assert.deepStrictEqual(part.files.find((f) => f.name === 'radio_tracks.json').content.map((track) => track.id), ['radio_kim'], 'a person can export their radio publications');
+ assert.deepStrictEqual(part.files.find((f) => f.name === 'video_likes.json').content.map((like) => like.item_id), [101], 'a person can export their likes');
assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));
assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));
assert.strictEqual(part.files.find((f) => f.name === 'reports.json').content.length, 1, 'the report kim filed');
assert.strictEqual(part.files.find((f) => f.name === 'reports.json').content.length, 1, 'the report kim filed');
assert.ok(!JSON.stringify(part).includes(sams.id), 'nothing of sam\'s');
assert.ok(!JSON.stringify(part).includes(sams.id), 'nothing of sam\'s');
@@ -105,6 +112,10 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -105,6 +112,10 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
assert.strictEqual(confirmation.subject, kim.subject);
assert.strictEqual(confirmation.subject, kim.subject);
assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));
assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));
assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));
assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));
+ assert.strictEqual(confirmation.erased.mh_radio_tracks, 1, 'radio publications are erased with the account');
+ assert.strictEqual(confirmation.erased.video_likes, 1, 'likes are erased with the account');
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM mh_radio_tracks WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s radio publication remains');
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM video_likes WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s like remains');
// Sam's drive is untouched.
// Sam's drive is untouched.
assert.strictEqual((await t.get(`/api/v1/files/${sams.id}`, { as: sam })).status, 200);
assert.strictEqual((await t.get(`/api/v1/files/${sams.id}`, { as: sam })).status, 200);
@@ -136,7 +136,7 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
@@ -136,7 +136,7 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
const r = await video('/v/301');
const r = await video('/v/301');
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.ok(r.text.includes('<div class="ovv-player-wrap" data-ov-labels="nudity,18+" data-ov-labels-owner="@kim" data-ov-labels-opened>'), 'the player is the labelled thing');
assert.ok(r.text.includes('<div class="ovv-player-wrap" data-ov-labels="nudity,18+" data-ov-labels-owner="@kim" data-ov-labels-opened>'), 'the player is the labelled thing');
- assert.ok(/<h1 class="ovv-title">Kim labelled<\/h1>\s*<ul class="ovk-labels" aria-label="Content labels"><li class="ovk-label" data-label="nudity">Nudity<\/li><li class="ovk-label ovk-label--adult" data-label="18\+">18\+<\/li><\/ul>/.test(r.text), 'the chips under the title');
+ assert.ok(/<h1 class="ovv-title">Kim labelled<\/h1>(?:<\/div>)?\s*<ul class="ovk-labels" aria-label="Content labels"><li class="ovk-label" data-label="nudity">Nudity<\/li><li class="ovk-label ovk-label--adult" data-label="18\+">18\+<\/li><\/ul>/.test(r.text), 'the chips under the title');
assert.ok(!r.text.includes('Flashing lights'), 'a suggestion is never shown to viewers');
assert.ok(!r.text.includes('Flashing lights'), 'a suggestion is never shown to viewers');
assert.ok(!r.text.includes('name="labels"'), 'no picker for a viewer');
assert.ok(!r.text.includes('name="labels"'), 'no picker for a viewer');
});
});
@@ -113,34 +113,29 @@ function startLive() {
@@ -113,34 +113,29 @@ function startLive() {
await check('outcome 1: a recording says what it is: a stream recording, the day, the length, the views', async () => {
await check('outcome 1: a recording says what it is: a stream recording, the day, the length, the views', async () => {
const r = await get('/v/501');
const r = await get('/v/501');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- const what = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(r.text);
- assert.ok(what, 'the page says what the video is');
- const line = what[1];
- assert.ok(line.includes('Stream recording'), line);
- assert.ok(line.includes('Streamed on') && line.includes('Oct 12, 2026'), 'the day it was streamed: ' + line);
- assert.ok(line.includes('13 minutes'), 'its length, in words: ' + line);
+ assert.ok(r.text.includes('<span class="ovv-kind">Stream recording</span>'), 'the title says what the video is');
+ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(r.text)[1];
+ assert.ok(line.includes('13 min'), 'its length: ' + line);
assert.ok(line.includes('1,204 views'), 'its views: ' + line);
assert.ok(line.includes('1,204 views'), 'its views: ' + line);
- assert.ok(/<time datetime="2026-10-12T09:30:00.000Z">/.test(line), 'the day in a machine-readable time: ' + line);
+ assert.ok(r.text.includes('Oct 12, 2026'), 'the stream date remains on the page');
// The channel is in the byline under it, and the category in About: nothing is said twice.
// The channel is in the byline under it, and the category in About: nothing is said twice.
- assert.ok(r.text.includes('class="ovv-channel-name"') && r.text.includes('@Streamer'), 'the channel');
+ assert.ok(r.text.includes('class="ovv-channel-name"') && r.text.includes('>Streamer</a>'), 'the channel');
assert.ok(r.text.includes("Streamed live on") && r.text.includes('in Gaming'), 'the category is in About');
assert.ok(r.text.includes("Streamed live on") && r.text.includes('in Gaming'), 'the category is in About');
});
});
await check('outcome 1: a recording with no stream time says when it was published, and guesses nothing', async () => {
await check('outcome 1: a recording with no stream time says when it was published, and guesses nothing', async () => {
const r = await get('/v/502');
const r = await get('/v/502');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- const line = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(r.text)[1];
- assert.ok(line.includes('Published on') && line.includes('Oct 12, 2026'), line);
- assert.ok(!line.includes('Streamed on'), 'no day it was streamed: Live never said one');
- assert.ok(!line.includes('views'), 'no views: Live counted none');
- assert.ok(line.includes('45 seconds'), 'the length it does have: ' + line);
+ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(r.text)[1];
+ assert.ok(!r.text.includes('Streamed on'), 'no day it was streamed: Live never said one');
+ assert.ok(line.includes('45 s'), 'the length it does have: ' + line);
});
});
await check('outcome 1: a clip says what it is, and an upload says it is an upload', async () => {
await check('outcome 1: a clip says what it is, and an upload says it is an upload', async () => {
const clip = await get('/c/601');
const clip = await get('/c/601');
- const line = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(clip.text)[1];
- assert.ok(line.includes('Clip</span>') && !line.includes('AI clip'), 'a clip: ' + line);
- assert.ok(line.includes('30 seconds') && line.includes('20 views'), line);
+ assert.ok(clip.text.includes('<span class="ovv-kind">Clip</span>') && !clip.text.includes('<span class="ovv-kind">AI clip</span>'), 'a clip');
+ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(clip.text)[1];
+ assert.ok(line.includes('30 s') && line.includes('20 views'), line);
// The upload's own row: the day it was uploaded, its length and its views.
// The upload's own row: the day it was uploaded, its length and its views.
const s = t.ctx.s;
const s = t.ctx.s;
const id = 'vup_01J0000000000000000000000B';
const id = 'vup_01J0000000000000000000000B';
@@ -163,8 +158,8 @@ function startLive() {
@@ -163,8 +158,8 @@ function startLive() {
assert.ok(line.includes('Cut from'), line);
assert.ok(line.includes('Cut from'), line);
assert.ok(line.includes('A long recording'), 'the title of the recording it came from: ' + line);
assert.ok(line.includes('A long recording'), 'the title of the recording it came from: ' + line);
assert.ok(line.includes('href="/v/501?t=65"'), 'a link back to the recording, at the clip\'s moment: ' + line);
assert.ok(line.includes('href="/v/501?t=65"'), 'a link back to the recording, at the clip\'s moment: ' + line);
- assert.ok(line.includes("Streamer's stream recording"), 'whose recording it was: ' + line);
- assert.ok(line.includes('starts at 1:05 in it'), 'where the clip starts in it: ' + line);
+ assert.ok(line.includes("Streamer's stream"), 'whose recording it was: ' + line);
+ assert.ok(line.includes('at 1:05'), 'where the clip starts in it: ' + line);
});
});
await check('outcome 2: a clip whose recording is not public says so, and names nothing it cannot know', async () => {
await check('outcome 2: a clip whose recording is not public says so, and names nothing it cannot know', async () => {
@@ -1,9 +1,7 @@
@@ -1,9 +1,7 @@
'use strict';
'use strict';
/**
/**
- * The owner deletes a recording or clip from the action row (a streamer, 11-08: deleting a test stream's recording
- * "wasn't immediately obvious": it was Edit, then a fold inside the edit panel, then a checkbox). Delete sits beside
- * Edit as a small question with one button; the form carries confirm=1 for the route's check; the edit panel no
- * longer hides a second delete.
+ * The owner deletes a recording or clip from the More menu. Delete sits beside Edit there as a small question with
+ * one button; the form carries confirm=1 for the route's check; the edit panel no longer hides a second delete.
*
*
* node test/video-owner-delete.test.js
* node test/video-owner-delete.test.js
*/
*/
@@ -13,14 +11,13 @@ const path = require('path');
@@ -13,14 +11,13 @@ const path = require('path');
const { deleteAction } = require('../server/video/watch-ui');
const { deleteAction } = require('../server/video/watch-ui');
const out = String(deleteAction({ action: '/v/42', what: 'recording' }));
const out = String(deleteAction({ action: '/v/42', what: 'recording' }));
-assert.match(out, /<details class="ovv-pop ovv-deleter" data-ovv-menu>/, 'a small menu in the action row');
+assert.match(out, /<details class="ovv-pop ovv-deleter" data-ovv-menu>/, 'a small confirmation menu');
assert.match(out, /Delete this recording\?/, 'it asks first');
assert.match(out, /Delete this recording\?/, 'it asks first');
assert.match(out, /<form method="post" action="\/v\/42\/delete"><input type="hidden" name="confirm" value="1">/, 'the form confirms itself');
assert.match(out, /<form method="post" action="\/v\/42\/delete"><input type="hidden" name="confirm" value="1">/, 'the form confirms itself');
assert.match(out, /Delete for good<\/button>/, 'one button says what happens');
assert.match(out, /Delete for good<\/button>/, 'one button says what happens');
const pages = fs.readFileSync(path.join(__dirname, '..', 'server', 'video', 'pages.js'), 'utf8');
const pages = fs.readFileSync(path.join(__dirname, '..', 'server', 'video', 'pages.js'), 'utf8');
-assert.ok(pages.includes("deleteAction({ action: `/v/${id}`, what: 'recording' })"), 'the recording page offers it beside Edit');
-assert.ok(pages.includes("deleteAction({ action: `/c/${id}`, what: 'clip' })"), 'the clip page too');
+assert.ok(pages.includes('deleteAction({ action: `/${kind === \'clip\' ? \'c\' : \'v\'}/${id}`'), 'both watch pages offer Delete through More');
const panel = pages.slice(pages.indexOf('function ownerPanel('), pages.indexOf('// The shell\'s navbar/footer scripts'));
const panel = pages.slice(pages.indexOf('function ownerPanel('), pages.indexOf('// The shell\'s navbar/footer scripts'));
assert.ok(!/ovv-danger|I want to delete it for good/.test(panel), 'the edit panel no longer hides a second delete');
assert.ok(!/ovv-danger|I want to delete it for good/.test(panel), 'the edit panel no longer hides a second delete');
-console.log('video: Delete is beside Edit, one question, one button');
+console.log('video: Delete is in More, one question, one button');
@@ -87,20 +87,36 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
@@ -87,20 +87,36 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });
const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });
assert.strictEqual(mine.status, 200);
assert.strictEqual(mine.status, 200);
assert.ok(mine.text.includes('Edit this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');
assert.ok(mine.text.includes('Edit this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');
- // 2026-10-27: Edit is first in the action row and opens the panel under the player; who can see it is three
- // choices; the meta line tells the owner which it is now.
- assert.ok(/<div class="ovv-actions"><a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage"/.test(mine.text), 'Edit leads the actions');
- assert.ok(mine.text.indexOf('id="manage"') < mine.text.indexOf('id="h-about"'), 'the panel sits under the player, before About');
+ // Owner tools live in More, with the panel under the player.
+ assert.ok(/class="ovv-more-actions"[\s\S]*class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage"/.test(mine.text), 'Edit is in More');
+ assert.ok(mine.text.indexOf('id="manage"') < mine.text.indexOf('id="about"'), 'the panel sits under the player, before About');
assert.strictEqual((mine.text.match(/<input type="radio" name="visibility" value="(public|unlisted|private)"/g) || []).length, 3, 'three visibility choices');
assert.strictEqual((mine.text.match(/<input type="radio" name="visibility" value="(public|unlisted|private)"/g) || []).length, 3, 'three visibility choices');
assert.ok(/class="ovv-vis-badge ovv-vis-badge--/.test(mine.text), 'the owner sees who can see it');
assert.ok(/class="ovv-vis-badge ovv-vis-badge--/.test(mine.text), 'the owner sees who can see it');
assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<input type="radio" name="visibility" value="public" checked>'), 'prefilled');
assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<input type="radio" name="visibility" value="public" checked>'), 'prefilled');
const other = await vreq(t.base, '/v/101', { cookie: samCookie });
const other = await vreq(t.base, '/v/101', { cookie: samCookie });
assert.ok(!other.text.includes('Edit this recording'), 'not for someone else');
assert.ok(!other.text.includes('Edit this recording'), 'not for someone else');
- assert.ok(!/class="ovv-vis-badge/.test(other.text) && !other.text.includes('ovv-btn--edit'), 'no badge or Edit for someone else');
+ assert.ok(!other.text.includes('ovv-btn--edit'), 'no Edit for someone else');
const anon = await vreq(t.base, '/v/101');
const anon = await vreq(t.base, '/v/101');
assert.ok(!anon.text.includes('Edit this recording'), 'not signed out');
assert.ok(!anon.text.includes('Edit this recording'), 'not signed out');
});
});
+ await check('recording likes are counted per signed-in person', async () => {
+ const path = '/watch/like';
+ const body = { kind: 'vod', id: '101' };
+ assert.strictEqual((await vreq(t.base, path, { method: 'POST', form: body })).status, 401);
+ let r = await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(JSON.parse(r.text), { count: 1, liked: true });
+ r = await vreq(t.base, '/v/101', { cookie: samCookie });
+ assert.match(r.text, /class="ovv-like-count">1<\/span>/);
+ r = await vreq(t.base, path, { method: 'POST', cookie: samCookie, form: body });
+ assert.deepStrictEqual(JSON.parse(r.text), { count: 2, liked: true });
+ r = await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body });
+ assert.deepStrictEqual(JSON.parse(r.text), { count: 1, liked: false });
+ assert.strictEqual((await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: { kind: 'clip', id: '999' } })).status, 404);
+ assert.strictEqual((await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body, same: false })).status, 403);
+ });
+
await check('an edit is made on Live as the person, then the page shows it', async () => {
await check('an edit is made on Live as the person, then the page shows it', async () => {
await vreq(t.base, '/v/101'); // warm the read cache
await vreq(t.base, '/v/101'); // warm the read cache
const r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim renamed', description: 'Second', visibility: 'unlisted' } });
const r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim renamed', description: 'Second', visibility: 'unlisted' } });
@@ -60,6 +60,7 @@ function startLive() {
@@ -60,6 +60,7 @@ function startLive() {
};
};
const publicClip = {
const publicClip = {
id: 201, vod_id: 101, title: 'A clip', start_time: 5, end_time: 15, duration_seconds: 10,
id: 201, vod_id: 101, title: 'A clip', start_time: 5, end_time: 15, duration_seconds: 10,
+ created_at: '2026-10-26T11:00:00.000Z', view_count: 49,
playback_url: 'https://openvibe.media/c/201', thumbnail_url: null, visibility: 'public',
playback_url: 'https://openvibe.media/c/201', thumbnail_url: null, visibility: 'public',
auto_generated: false, username: 'clipper', display_name: 'Clipper',
auto_generated: false, username: 'clipper', display_name: 'Clipper',
source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer',
source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer',
@@ -374,6 +375,9 @@ function countOccurrences(haystack, needle) {
@@ -374,6 +375,9 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Chapters'));
assert.ok(r.text.includes('Chapters'));
assert.ok(r.text.includes('Transcript'));
assert.ok(r.text.includes('Transcript'));
assert.ok(r.text.includes('A public overview'));
assert.ok(r.text.includes('A public overview'));
+ assert.ok(r.text.includes('<details class="ovv-about" id="about">') && r.text.includes('<span class="ovv-about-preview">A public stream</span>'), 'description has a collapsed preview');
+ assert.ok(/class="ovv-about-facts"><span>100 views<\/span>.*ovv-vis-badge.*<span>· 45 s<\/span>/.test(r.text), 'views, visibility and short duration lead the description');
+ assert.ok(r.text.includes('<p class="ovv-about-text">A public stream</p>') && r.text.includes('ovv-about-toggle-less'), 'full description is in the HTML for expansion');
assert.ok(r.text.includes('/@streamer'));
assert.ok(r.text.includes('/@streamer'));
assert.ok(!r.text.includes('Edit this recording'), 'owner tools only for the channel (video-owner.test.js)');
assert.ok(!r.text.includes('Edit this recording'), 'owner tools only for the channel (video-owner.test.js)');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
@@ -410,6 +414,8 @@ function countOccurrences(haystack, needle) {
@@ -410,6 +414,8 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(cld.duration, 'PT10S');
assert.strictEqual(cld.duration, 'PT10S');
assert.ok(r.text.includes('src="https://openvibe.media/c/201"'), 'the clip playback url');
assert.ok(r.text.includes('src="https://openvibe.media/c/201"'), 'the clip playback url');
assert.ok(r.text.includes('Made by Clipper'), 'attributed to the person who made it');
assert.ok(r.text.includes('Made by Clipper'), 'attributed to the person who made it');
+ assert.ok(r.text.includes('<span>49 views</span>') && r.text.includes('<span>· 10 s</span>'), 'clip stats use the short description line');
+ assert.ok(/<summary>[\s\S]*Cut from <a href="\/v\/101\?t=5">/.test(r.text), 'the source link is in the short description');
assert.ok(r.text.includes('Full recording</a>') && r.text.includes('title="Watch the recording it was cut from, from this moment"'));
assert.ok(r.text.includes('Full recording</a>') && r.text.includes('title="Watch the recording it was cut from, from this moment"'));
assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');
assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));