openvibers/openvibe.mediahub Commits
openvibe.video/search: recordings, clips and uploads through OpenVibe.Search, recordings by category (#111)
Changes
@@@ -135,6 +135,14 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-channel-head h1 { margin: 0 0 .2rem; }.ovv-channel-head .ovv-meta { margin: 0; }.ovv-pagination { display: flex; gap: 10px; margin-top: 1.2rem; }++/* /search and the home hero's search box; the kind and category filters are chips. */++.ovv-search { display: flex; flex-wrap: wrap; gap: 8px; margin: 1rem 0; max-width: 640px; }++.ovv-search input[type=search] { flex: 1 1 220px; min-width: 0; font: inherit; min-height: 40px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--ovv-line-strong); background: var(--bg-input, var(--bg-secondary, #0f1729)); color: inherit; }++.ovv-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 1rem; }++.ovv-chips a { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovv-line); color: inherit; text-decoration: none; font-size: .88rem; font-weight: 600; }++.ovv-chips a span { color: var(--text-secondary, #96a7c2); font-weight: 500; }++.ovv-chips a[aria-current="true"] { border-color: var(--accent-strong, #2563eb); background: color-mix(in srgb, var(--accent-strong, #2563eb) 16%, transparent); }++.ovv-chips a:hover, .ovv-chips a:focus-visible { border-color: var(--ovv-accent); }/* Chat replay */.ovv-chat-body { max-height: 26rem; overflow-y: auto; display: grid; gap: 2px; }@@@ -93,7 +93,7 @@ ${render(o.body)}}// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).--const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Record', href: '/record' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];++const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Search', href: '/search' }, { label: 'Upload', href: '/upload' }, { label: 'Record', href: '/record' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a// browser keyed the guest copy by its URL alone and, for five minutes after a quiet sign-in, showed the signed-out page@@@ -839,6 +839,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar<h1>Watch streams again, and share your own videos</h1><p class="ovv-lede">Recordings and clips of streams on OpenVibe.Live land here when the stream ends. Upload a video or record your camera or screen in the browser, and OpenVibe.AI adds captions, chapters and a summary. Everything you post plays on your channel at openvibe.video/@you.</p><p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a> <a class="ovv-btn" href="/record"><i class="fa-solid fa-video" aria-hidden="true"></i> Record in the browser</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Go live from your browser</a></p>++${searchForm()}${stats.length ? html`<dl class="ovv-stats">${stats.map((s) => html`<div><dt>${s.label}</dt><dd>${s.value}</dd></div>`)}</dl>` : ''}</header>${onAirCards.length ? html`<section class="ovv-section" aria-labelledby="h-live"><div class="ovv-section-head"><h2 id="h-live"><span class="ovv-dot" aria-hidden="true"></span> Live now on OpenVibe.Live</h2><a href="${livePublic}/">Everyone live</a></div>${grid(onAirCards)}</section>` : ''}${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Latest uploads</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}@@@ -907,6 +908,61 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h});});++ // ── Search: words across Live's recordings and clips and Video's uploads (OpenVibe.Search), best match first ──++ // Without ?type the three kinds show their best eight each; ?type=vod|clip|video pages one kind (24 at a time, Search's++ // cursor), and recordings filter by category. Results are noindex; the empty search page is not.++ const SEARCH_KINDS = [['vod', 'Stream recordings'], ['clip', 'Clips'], ['video', 'Uploads']];++ const searchForm = (q = '') => html`<form class="ovv-search" method="get" action="/search" role="search"><label class="sr-only" for="ovv-q">Search videos</label><input id="ovv-q" name="q" type="search" value="${q}" maxlength="200" placeholder="Search recordings, clips and uploads" required><button class="ovv-btn ovv-btn--primary" type="submit"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> Search</button></form>`;++ async function searchCards(kind, items) {++ if (kind === 'video') {++ const rows = await Promise.all(items.map((x) => settle(ctx.videoUploads.byId(x.id))));++ return rows.filter((x) => x && x.visibility === 'public' && x.status === 'ready' && x.state === 'active').map((x) => uploadCard(x));++ }++ const known = await catalogIndex();++ return items.map((x) => feedCard(known.get(`${kind}:${x.id}`) || { id: x.id, kind, title: x.title, thumbnail_url: null, duration_seconds: x.duration_seconds, created_at: x.published_at, channel: { username: x.channel } }));++ }++ r.get('/search', async (req, res) => {++ const q = typeof req.query.q === 'string' ? req.query.q.replace(/\s+/g, ' ').trim().slice(0, 200) : '';++ const kind = typeof req.query.type === 'string' && shelves.KINDS[req.query.type] ? req.query.type : null;++ const category = kind === 'vod' && isCategory(req.query.category) ? String(req.query.category) : null;++ const cursor = kind && typeof req.query.after === 'string' ? req.query.after : null;++ const link = (o) => `/search?${new URLSearchParams(Object.entries({ q, ...o }).filter(([, v]) => v))}`;++ if (!q) {++ const shelf = await settle(shelves.shelves());++ const categories = (shelf && shelf.categories) || [];++ return page(req, res, {++ index: true, title: 'Search OpenVibe.Video', description: 'Search stream recordings and clips from OpenVibe.Live and videos people uploaded to OpenVibe.Video.',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],++ body: html`<h1>Search videos</h1>${searchForm()}++${categories.length ? html`<section class="ovv-section" aria-labelledby="h-cats"><h2 id="h-cats">Or browse by category</h2><ul class="ovv-shelf" role="list">${categories.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,++ });++ }++ const kinds = kind ? [kind] : SEARCH_KINDS.map(([k]) => k);++ const answers = await Promise.all(kinds.map((k) => settle(shelves.search(q, { kind: k, category: k === 'vod' ? category : null, cursor, limit: kind ? 24 : 8 }))));++ if (answers.every((a) => !a)) {++ return page(req, res, {++ title: `Search: ${q}`, noindex: true, cache: cache.htmlHeaders({ private: true }), crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],++ body: html`<h1>Search videos</h1>${searchForm(q)}<p class="muted">Search cannot be read right now. Try again in a minute, or browse the <a href="/">latest recordings</a>.</p>`,++ }, 503);++ }++ const sections = await Promise.all(kinds.map(async (k, i) => {++ const a = answers[i];++ const label = SEARCH_KINDS.find(([x]) => x === k)[1];++ const cards = a ? await searchCards(k, a.items) : [];++ return { kind: k, label, cards, next: a && a.next, categories: (a && a.categories) || [] };++ }));++ const found = sections.some((x) => x.cards.length);++ const vod = sections.find((x) => x.kind === 'vod');++ const chips = vod && (vod.categories.length || category) ? html`<nav class="ovv-chips" aria-label="Recordings by category"><a href="${link({ type: 'vod' })}"${!category ? raw(' aria-current="true"') : ''}>All categories</a>${vod.categories.map((c) => html`<a href="${link({ type: 'vod', category: c.slug })}"${c.slug === category ? raw(' aria-current="true"') : ''}>${c.label} <span>${n(c.count)}</span></a>`)}</nav>` : '';++ const tabs = html`<nav class="ovv-chips" aria-label="What to search"><a href="${link({})}"${!kind ? raw(' aria-current="true"') : ''}>Everything</a>${SEARCH_KINDS.map(([k, label]) => html`<a href="${link({ type: k })}"${k === kind ? raw(' aria-current="true"') : ''}>${label}</a>`)}</nav>`;++ return page(req, res, {++ title: `Search: ${q}`, noindex: true, crumbs: [{ label: 'Home', href: '/' }, { label: 'Search', href: '/search' }, { label: q }],++ body: html`<h1>Results for “${q}”</h1>${searchForm(q)}${tabs}++${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv-section" aria-labelledby="h-${x.kind}"><div class="ovv-section-head"><h2 id="h-${x.kind}">${x.label}</h2>${!kind && x.next ? html`<a href="${link({ type: x.kind })}">More ${x.label.toLowerCase()}</a>` : ''}</div>${x.kind === 'vod' ? chips : ''}${grid(x.cards, 'Nothing matched.')}${kind && x.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="${link({ type: kind, category, after: x.next })}" rel="next">More results</a></p>` : ''}</section>` : ''))++ : html`<p class="muted">Nothing matched “${q}”${category ? ' in that category' : ''}. Try fewer or other words, or <a href="/#categories">browse by category</a>.</p>`}`,++ });++ });++// ── What shipped: the shared update log, as on every OpenVibe site (MediaHub's log: Video and Download ship together) ──r.get('/updates', (req, res) => page(req, res, {index: true,@@@ -29,6 +29,14 @@ const CATEGORIES = {};const isCategory = (v) => Object.prototype.hasOwnProperty.call(CATEGORIES, String(v || ''));const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;++// What /search looks through: Live's recordings and clips (owner live) and Video's own uploads (owner media-hub), each a++// Search type with its own id shape.++const KINDS = {++ vod: { owner: 'live', type: 'vod', id: /^[1-9]\d{0,15}$/ },++ clip: { owner: 'live', type: 'clip', id: /^[1-9]\d{0,15}$/ },++ video: { owner: 'media-hub', type: 'video', id: /^vup_[0-9A-HJKMNP-TV-Z]{26}$/ },++};++const CURSOR_RE = /^[A-Za-z0-9_-]{1,512}$/;function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console } = {}) {const base = String((config.search && config.search.url) || '').replace(/\/+$/, '');@@@ -61,8 +69,43 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console.map((x) => ({ value: String((x && x.value) || ''), count: Number(x && x.count) || 0 })).filter((x) => x.value && x.count > 0);++ /** Search results of one kind → the items a card needs (ids checked against the kind's shape). */++ const itemsOf = (results, kind) => (Array.isArray(results) ? results : [])++ .filter((x) => x && x.type === KINDS[kind].type && KINDS[kind].id.test(String(x.id)))++ .map((x) => ({++ kind,++ id: kind === 'video' ? String(x.id) : Number(x.id),++ title: String(x.title || '').slice(0, 300),++ channel: x.facets && USERNAME_RE.test(String(x.facets.channel || '')) ? String(x.facets.channel) : null,++ duration_seconds: x.facets && Number.isFinite(Number(x.facets.duration_seconds)) ? Number(x.facets.duration_seconds) : null,++ published_at: x.published_at || null,++ }));++return {enabled: Boolean(base),++ KINDS,++ /**++ * Words across one kind: Live's recordings (optionally of one category), its clips, or Video's uploads, best++ * match first. → { items, next, categories: [{ slug, label, icon, count }] (recordings only) } or null (no++ * words, an unknown kind, or Search unreadable).++ */++ async search(q, { kind = 'vod', category = null, cursor = null, limit = PAGE } = {}) {++ const text = String(q || '').replace(/\s+/g, ' ').trim().slice(0, 200);++ const k = KINDS[kind];++ if (!text || !k) return null;++ const qs = new URLSearchParams({ q: text, owner: k.owner, type: k.type, limit: String(Math.min(Math.max(1, limit), PAGE)) });++ if (kind === 'vod') {++ qs.set('facets', 'category');++ if (isCategory(category)) qs.set('facet.category', category);++ }++ if (cursor && CURSOR_RE.test(String(cursor))) qs.set('cursor', String(cursor));++ const body = await getJson(qs);++ if (!body || !Array.isArray(body.results)) return null;++ const categories = kind === 'vod' && body.facets ? facetList(body.facets.category).filter((x) => isCategory(x.value))++ .map((x) => ({ slug: x.value, ...CATEGORIES[x.value], count: x.count })) : [];++ const next = typeof body.next_cursor === 'string' && CURSOR_RE.test(body.next_cursor) ? body.next_cursor : null;++ return { items: itemsOf(body.results, kind), next, categories };++ },/** { categories: [{ slug, label, icon, blurb, count }], channels: [{ username, count }] } or null. */async shelves() {const body = await getJson(new URLSearchParams({ q: '', owner: 'live', type: 'vod', facets: 'category,channel', limit: '1' }));@@@ -95,4 +138,4 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console};}--module.exports = { createSearchReads, CATEGORIES, isCategory };++module.exports = { createSearchReads, CATEGORIES, isCategory, KINDS };@@@ -114,6 +114,17 @@ function startLive() {}// OpenVibe.Search (the same stub answers for it): the facets, and one category's recordings.if (p === '/api/v1/search') {++ // Words (/search): "robot" finds a recording (with a next page and the category facet) and a clip; "<script>" nothing.++ if (url.searchParams.get('q')) {++ if (url.searchParams.get('q') !== 'robot') return json(200, { results: [], next_cursor: null, ...(url.searchParams.get('facets') ? { facets: { category: [] } } : {}) });++ const type = url.searchParams.get('type');++ if (type === 'vod') {++ return json(200, { results: [{ owner: 'live', type: 'vod', id: '101', title: 'A public recording', facets: { channel: 'streamer', category: 'gaming', duration_seconds: 45 } }],++ next_cursor: url.searchParams.get('cursor') ? null : 'page2', facets: { category: [{ value: 'gaming', count: 1 }, { value: 'nope', count: 2 }] } });++ }++ if (type === 'clip') return json(200, { results: [{ owner: 'live', type: 'clip', id: '201', title: 'A clip', facets: { channel: 'clipper' } }, { owner: 'live', type: 'clip', id: 'javascript:x', title: 'bad' }], next_cursor: null });++ return json(200, { results: [{ owner: 'media-hub', type: 'video', id: 'vup_01HZZZZZZZZZZZZZZZZZZZZZZZ', title: 'Gone upload' }], next_cursor: null });++ }if (url.searchParams.get('facet.category') === 'gaming') {return json(200, { results: [{ owner: 'live', type: 'vod', id: '101', title: 'A public recording', facets: { channel: 'streamer', category: 'gaming', duration_seconds: 45 }, published_at: '2026-10-12T10:00:00.000Z' }], next_cursor: null });}@@@ -434,9 +445,29 @@ function countOccurrences(haystack, needle) {assert.ok(!map.includes('/category/music'), 'a category with one is not');});-- await check('the navbar names the site\'s places: Videos, Upload, Record, Your videos and Playlists, the current one active', async () => {++ await check('/search finds recordings, clips and uploads through Search, filters recordings by category, and pages one kind', async () => {++ const empty = await getVideo('/search');++ assert.strictEqual(empty.status, 200);++ assert.ok(empty.text.includes('<h1>Search videos</h1>') && empty.text.includes('href="/category/gaming"'), 'a search box and the categories');++ assert.doesNotMatch(empty.text, /<meta name="robots" content="noindex/);++ const r = await getVideo('/search?q=robot');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<meta name="robots" content="noindex/, 'results are not indexed');++ assert.ok(r.text.includes('href="/v/101"') && r.text.includes('https://openvibe.media/t/101.jpg'), 'the recording, with the thumbnail Live has');++ assert.ok(r.text.includes('href="/c/201"') && !r.text.includes('javascript:x'), 'the clip; a malformed id is dropped');++ assert.ok(!r.text.includes('Gone upload'), 'an upload Search still lists but Video does not have publicly is left out');++ assert.ok(r.text.includes('href="/search?q=robot&type=vod"') && r.text.includes('More stream recordings'), 'more of one kind');++ assert.ok(r.text.includes('href="/search?q=robot&type=vod&category=gaming"') && !r.text.includes('category=nope'), 'known categories only');++ const one = await getVideo('/search?q=robot&type=vod&category=gaming');++ assert.match(one.text, /href="\/search\?q=robot&type=vod&category=gaming" aria-current="true"/);++ assert.ok(one.text.includes('after=page2') && one.text.includes('More results'), 'the next page');++ const none = await getVideo('/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E');++ assert.ok(none.text.includes('Nothing matched') && !none.text.includes('<script>alert(1)'), 'nothing found, and the words are escaped');++ });++++ await check('the navbar names the site\'s places: Videos, Search, Upload, Record, Your videos and Playlists, the current one active', async () => {const home = (await getVideo('/')).text;-- assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Record","href":"\/record","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);++ assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Search","href":"\/search","active":false\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Record","href":"\/record","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');});
@@ -135,6 +135,14 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -135,6 +135,14 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-channel-head h1 { margin: 0 0 .2rem; }
.ovv-channel-head h1 { margin: 0 0 .2rem; }
.ovv-channel-head .ovv-meta { margin: 0; }
.ovv-channel-head .ovv-meta { margin: 0; }
.ovv-pagination { display: flex; gap: 10px; margin-top: 1.2rem; }
.ovv-pagination { display: flex; gap: 10px; margin-top: 1.2rem; }
+/* /search and the home hero's search box; the kind and category filters are chips. */
+.ovv-search { display: flex; flex-wrap: wrap; gap: 8px; margin: 1rem 0; max-width: 640px; }
+.ovv-search input[type=search] { flex: 1 1 220px; min-width: 0; font: inherit; min-height: 40px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--ovv-line-strong); background: var(--bg-input, var(--bg-secondary, #0f1729)); color: inherit; }
+.ovv-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 1rem; }
+.ovv-chips a { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovv-line); color: inherit; text-decoration: none; font-size: .88rem; font-weight: 600; }
+.ovv-chips a span { color: var(--text-secondary, #96a7c2); font-weight: 500; }
+.ovv-chips a[aria-current="true"] { border-color: var(--accent-strong, #2563eb); background: color-mix(in srgb, var(--accent-strong, #2563eb) 16%, transparent); }
+.ovv-chips a:hover, .ovv-chips a:focus-visible { border-color: var(--ovv-accent); }
/* Chat replay */
/* Chat replay */
.ovv-chat-body { max-height: 26rem; overflow-y: auto; display: grid; gap: 2px; }
.ovv-chat-body { max-height: 26rem; overflow-y: auto; display: grid; gap: 2px; }
@@ -93,7 +93,7 @@ ${render(o.body)}
@@ -93,7 +93,7 @@ ${render(o.body)}
}
}
// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).
// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).
-const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Record', href: '/record' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];
+const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Search', href: '/search' }, { label: 'Upload', href: '/upload' }, { label: 'Record', href: '/record' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];
// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a
// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a
// browser keyed the guest copy by its URL alone and, for five minutes after a quiet sign-in, showed the signed-out page
// browser keyed the guest copy by its URL alone and, for five minutes after a quiet sign-in, showed the signed-out page
@@ -839,6 +839,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
@@ -839,6 +839,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
<h1>Watch streams again, and share your own videos</h1>
<h1>Watch streams again, and share your own videos</h1>
<p class="ovv-lede">Recordings and clips of streams on OpenVibe.Live land here when the stream ends. Upload a video or record your camera or screen in the browser, and OpenVibe.AI adds captions, chapters and a summary. Everything you post plays on your channel at openvibe.video/@you.</p>
<p class="ovv-lede">Recordings and clips of streams on OpenVibe.Live land here when the stream ends. Upload a video or record your camera or screen in the browser, and OpenVibe.AI adds captions, chapters and a summary. Everything you post plays on your channel at openvibe.video/@you.</p>
<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a> <a class="ovv-btn" href="/record"><i class="fa-solid fa-video" aria-hidden="true"></i> Record in the browser</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Go live from your browser</a></p>
<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a> <a class="ovv-btn" href="/record"><i class="fa-solid fa-video" aria-hidden="true"></i> Record in the browser</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Go live from your browser</a></p>
+${searchForm()}
${stats.length ? html`<dl class="ovv-stats">${stats.map((s) => html`<div><dt>${s.label}</dt><dd>${s.value}</dd></div>`)}</dl>` : ''}</header>
${stats.length ? html`<dl class="ovv-stats">${stats.map((s) => html`<div><dt>${s.label}</dt><dd>${s.value}</dd></div>`)}</dl>` : ''}</header>
${onAirCards.length ? html`<section class="ovv-section" aria-labelledby="h-live"><div class="ovv-section-head"><h2 id="h-live"><span class="ovv-dot" aria-hidden="true"></span> Live now on OpenVibe.Live</h2><a href="${livePublic}/">Everyone live</a></div>${grid(onAirCards)}</section>` : ''}
${onAirCards.length ? html`<section class="ovv-section" aria-labelledby="h-live"><div class="ovv-section-head"><h2 id="h-live"><span class="ovv-dot" aria-hidden="true"></span> Live now on OpenVibe.Live</h2><a href="${livePublic}/">Everyone live</a></div>${grid(onAirCards)}</section>` : ''}
${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Latest uploads</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Latest uploads</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
@@ -907,6 +908,61 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h
@@ -907,6 +908,61 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h
});
});
});
});
+ // ── Search: words across Live's recordings and clips and Video's uploads (OpenVibe.Search), best match first ──
+ // Without ?type the three kinds show their best eight each; ?type=vod|clip|video pages one kind (24 at a time, Search's
+ // cursor), and recordings filter by category. Results are noindex; the empty search page is not.
+ const SEARCH_KINDS = [['vod', 'Stream recordings'], ['clip', 'Clips'], ['video', 'Uploads']];
+ const searchForm = (q = '') => html`<form class="ovv-search" method="get" action="/search" role="search"><label class="sr-only" for="ovv-q">Search videos</label><input id="ovv-q" name="q" type="search" value="${q}" maxlength="200" placeholder="Search recordings, clips and uploads" required><button class="ovv-btn ovv-btn--primary" type="submit"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> Search</button></form>`;
+ async function searchCards(kind, items) {
+ if (kind === 'video') {
+ const rows = await Promise.all(items.map((x) => settle(ctx.videoUploads.byId(x.id))));
+ return rows.filter((x) => x && x.visibility === 'public' && x.status === 'ready' && x.state === 'active').map((x) => uploadCard(x));
+ }
+ const known = await catalogIndex();
+ return items.map((x) => feedCard(known.get(`${kind}:${x.id}`) || { id: x.id, kind, title: x.title, thumbnail_url: null, duration_seconds: x.duration_seconds, created_at: x.published_at, channel: { username: x.channel } }));
+ }
+ r.get('/search', async (req, res) => {
+ const q = typeof req.query.q === 'string' ? req.query.q.replace(/\s+/g, ' ').trim().slice(0, 200) : '';
+ const kind = typeof req.query.type === 'string' && shelves.KINDS[req.query.type] ? req.query.type : null;
+ const category = kind === 'vod' && isCategory(req.query.category) ? String(req.query.category) : null;
+ const cursor = kind && typeof req.query.after === 'string' ? req.query.after : null;
+ const link = (o) => `/search?${new URLSearchParams(Object.entries({ q, ...o }).filter(([, v]) => v))}`;
+ if (!q) {
+ const shelf = await settle(shelves.shelves());
+ const categories = (shelf && shelf.categories) || [];
+ return page(req, res, {
+ index: true, title: 'Search OpenVibe.Video', description: 'Search stream recordings and clips from OpenVibe.Live and videos people uploaded to OpenVibe.Video.',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],
+ body: html`<h1>Search videos</h1>${searchForm()}
+${categories.length ? html`<section class="ovv-section" aria-labelledby="h-cats"><h2 id="h-cats">Or browse by category</h2><ul class="ovv-shelf" role="list">${categories.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,
+ });
+ }
+ const kinds = kind ? [kind] : SEARCH_KINDS.map(([k]) => k);
+ const answers = await Promise.all(kinds.map((k) => settle(shelves.search(q, { kind: k, category: k === 'vod' ? category : null, cursor, limit: kind ? 24 : 8 }))));
+ if (answers.every((a) => !a)) {
+ return page(req, res, {
+ title: `Search: ${q}`, noindex: true, cache: cache.htmlHeaders({ private: true }), crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],
+ body: html`<h1>Search videos</h1>${searchForm(q)}<p class="muted">Search cannot be read right now. Try again in a minute, or browse the <a href="/">latest recordings</a>.</p>`,
+ }, 503);
+ }
+ const sections = await Promise.all(kinds.map(async (k, i) => {
+ const a = answers[i];
+ const label = SEARCH_KINDS.find(([x]) => x === k)[1];
+ const cards = a ? await searchCards(k, a.items) : [];
+ return { kind: k, label, cards, next: a && a.next, categories: (a && a.categories) || [] };
+ }));
+ const found = sections.some((x) => x.cards.length);
+ const vod = sections.find((x) => x.kind === 'vod');
+ const chips = vod && (vod.categories.length || category) ? html`<nav class="ovv-chips" aria-label="Recordings by category"><a href="${link({ type: 'vod' })}"${!category ? raw(' aria-current="true"') : ''}>All categories</a>${vod.categories.map((c) => html`<a href="${link({ type: 'vod', category: c.slug })}"${c.slug === category ? raw(' aria-current="true"') : ''}>${c.label} <span>${n(c.count)}</span></a>`)}</nav>` : '';
+ const tabs = html`<nav class="ovv-chips" aria-label="What to search"><a href="${link({})}"${!kind ? raw(' aria-current="true"') : ''}>Everything</a>${SEARCH_KINDS.map(([k, label]) => html`<a href="${link({ type: k })}"${k === kind ? raw(' aria-current="true"') : ''}>${label}</a>`)}</nav>`;
+ return page(req, res, {
+ title: `Search: ${q}`, noindex: true, crumbs: [{ label: 'Home', href: '/' }, { label: 'Search', href: '/search' }, { label: q }],
+ body: html`<h1>Results for “${q}”</h1>${searchForm(q)}${tabs}
+${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv-section" aria-labelledby="h-${x.kind}"><div class="ovv-section-head"><h2 id="h-${x.kind}">${x.label}</h2>${!kind && x.next ? html`<a href="${link({ type: x.kind })}">More ${x.label.toLowerCase()}</a>` : ''}</div>${x.kind === 'vod' ? chips : ''}${grid(x.cards, 'Nothing matched.')}${kind && x.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="${link({ type: kind, category, after: x.next })}" rel="next">More results</a></p>` : ''}</section>` : ''))
+ : html`<p class="muted">Nothing matched “${q}”${category ? ' in that category' : ''}. Try fewer or other words, or <a href="/#categories">browse by category</a>.</p>`}`,
+ });
+ });
+
// ── What shipped: the shared update log, as on every OpenVibe site (MediaHub's log: Video and Download ship together) ──
// ── What shipped: the shared update log, as on every OpenVibe site (MediaHub's log: Video and Download ship together) ──
r.get('/updates', (req, res) => page(req, res, {
r.get('/updates', (req, res) => page(req, res, {
index: true,
index: true,
@@ -29,6 +29,14 @@ const CATEGORIES = {
@@ -29,6 +29,14 @@ const CATEGORIES = {
};
};
const isCategory = (v) => Object.prototype.hasOwnProperty.call(CATEGORIES, String(v || ''));
const isCategory = (v) => Object.prototype.hasOwnProperty.call(CATEGORIES, String(v || ''));
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
+// What /search looks through: Live's recordings and clips (owner live) and Video's own uploads (owner media-hub), each a
+// Search type with its own id shape.
+const KINDS = {
+ vod: { owner: 'live', type: 'vod', id: /^[1-9]\d{0,15}$/ },
+ clip: { owner: 'live', type: 'clip', id: /^[1-9]\d{0,15}$/ },
+ video: { owner: 'media-hub', type: 'video', id: /^vup_[0-9A-HJKMNP-TV-Z]{26}$/ },
+};
+const CURSOR_RE = /^[A-Za-z0-9_-]{1,512}$/;
function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
const base = String((config.search && config.search.url) || '').replace(/\/+$/, '');
const base = String((config.search && config.search.url) || '').replace(/\/+$/, '');
@@ -61,8 +69,43 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
@@ -61,8 +69,43 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
.map((x) => ({ value: String((x && x.value) || ''), count: Number(x && x.count) || 0 }))
.map((x) => ({ value: String((x && x.value) || ''), count: Number(x && x.count) || 0 }))
.filter((x) => x.value && x.count > 0);
.filter((x) => x.value && x.count > 0);
+ /** Search results of one kind → the items a card needs (ids checked against the kind's shape). */
+ const itemsOf = (results, kind) => (Array.isArray(results) ? results : [])
+ .filter((x) => x && x.type === KINDS[kind].type && KINDS[kind].id.test(String(x.id)))
+ .map((x) => ({
+ kind,
+ id: kind === 'video' ? String(x.id) : Number(x.id),
+ title: String(x.title || '').slice(0, 300),
+ channel: x.facets && USERNAME_RE.test(String(x.facets.channel || '')) ? String(x.facets.channel) : null,
+ duration_seconds: x.facets && Number.isFinite(Number(x.facets.duration_seconds)) ? Number(x.facets.duration_seconds) : null,
+ published_at: x.published_at || null,
+ }));
+
return {
return {
enabled: Boolean(base),
enabled: Boolean(base),
+ KINDS,
+ /**
+ * Words across one kind: Live's recordings (optionally of one category), its clips, or Video's uploads, best
+ * match first. → { items, next, categories: [{ slug, label, icon, count }] (recordings only) } or null (no
+ * words, an unknown kind, or Search unreadable).
+ */
+ async search(q, { kind = 'vod', category = null, cursor = null, limit = PAGE } = {}) {
+ const text = String(q || '').replace(/\s+/g, ' ').trim().slice(0, 200);
+ const k = KINDS[kind];
+ if (!text || !k) return null;
+ const qs = new URLSearchParams({ q: text, owner: k.owner, type: k.type, limit: String(Math.min(Math.max(1, limit), PAGE)) });
+ if (kind === 'vod') {
+ qs.set('facets', 'category');
+ if (isCategory(category)) qs.set('facet.category', category);
+ }
+ if (cursor && CURSOR_RE.test(String(cursor))) qs.set('cursor', String(cursor));
+ const body = await getJson(qs);
+ if (!body || !Array.isArray(body.results)) return null;
+ const categories = kind === 'vod' && body.facets ? facetList(body.facets.category).filter((x) => isCategory(x.value))
+ .map((x) => ({ slug: x.value, ...CATEGORIES[x.value], count: x.count })) : [];
+ const next = typeof body.next_cursor === 'string' && CURSOR_RE.test(body.next_cursor) ? body.next_cursor : null;
+ return { items: itemsOf(body.results, kind), next, categories };
+ },
/** { categories: [{ slug, label, icon, blurb, count }], channels: [{ username, count }] } or null. */
/** { categories: [{ slug, label, icon, blurb, count }], channels: [{ username, count }] } or null. */
async shelves() {
async shelves() {
const body = await getJson(new URLSearchParams({ q: '', owner: 'live', type: 'vod', facets: 'category,channel', limit: '1' }));
const body = await getJson(new URLSearchParams({ q: '', owner: 'live', type: 'vod', facets: 'category,channel', limit: '1' }));
@@ -95,4 +138,4 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
@@ -95,4 +138,4 @@ function createSearchReads({ config, fetchImpl = globalThis.fetch, log = console
};
};
}
}
-module.exports = { createSearchReads, CATEGORIES, isCategory };
+module.exports = { createSearchReads, CATEGORIES, isCategory, KINDS };
@@ -114,6 +114,17 @@ function startLive() {
@@ -114,6 +114,17 @@ function startLive() {
}
}
// OpenVibe.Search (the same stub answers for it): the facets, and one category's recordings.
// OpenVibe.Search (the same stub answers for it): the facets, and one category's recordings.
if (p === '/api/v1/search') {
if (p === '/api/v1/search') {
+ // Words (/search): "robot" finds a recording (with a next page and the category facet) and a clip; "<script>" nothing.
+ if (url.searchParams.get('q')) {
+ if (url.searchParams.get('q') !== 'robot') return json(200, { results: [], next_cursor: null, ...(url.searchParams.get('facets') ? { facets: { category: [] } } : {}) });
+ const type = url.searchParams.get('type');
+ if (type === 'vod') {
+ return json(200, { results: [{ owner: 'live', type: 'vod', id: '101', title: 'A public recording', facets: { channel: 'streamer', category: 'gaming', duration_seconds: 45 } }],
+ next_cursor: url.searchParams.get('cursor') ? null : 'page2', facets: { category: [{ value: 'gaming', count: 1 }, { value: 'nope', count: 2 }] } });
+ }
+ if (type === 'clip') return json(200, { results: [{ owner: 'live', type: 'clip', id: '201', title: 'A clip', facets: { channel: 'clipper' } }, { owner: 'live', type: 'clip', id: 'javascript:x', title: 'bad' }], next_cursor: null });
+ return json(200, { results: [{ owner: 'media-hub', type: 'video', id: 'vup_01HZZZZZZZZZZZZZZZZZZZZZZZ', title: 'Gone upload' }], next_cursor: null });
+ }
if (url.searchParams.get('facet.category') === 'gaming') {
if (url.searchParams.get('facet.category') === 'gaming') {
return json(200, { results: [{ owner: 'live', type: 'vod', id: '101', title: 'A public recording', facets: { channel: 'streamer', category: 'gaming', duration_seconds: 45 }, published_at: '2026-10-12T10:00:00.000Z' }], next_cursor: null });
return json(200, { results: [{ owner: 'live', type: 'vod', id: '101', title: 'A public recording', facets: { channel: 'streamer', category: 'gaming', duration_seconds: 45 }, published_at: '2026-10-12T10:00:00.000Z' }], next_cursor: null });
}
}
@@ -434,9 +445,29 @@ function countOccurrences(haystack, needle) {
@@ -434,9 +445,29 @@ function countOccurrences(haystack, needle) {
assert.ok(!map.includes('/category/music'), 'a category with one is not');
assert.ok(!map.includes('/category/music'), 'a category with one is not');
});
});
- await check('the navbar names the site\'s places: Videos, Upload, Record, Your videos and Playlists, the current one active', async () => {
+ await check('/search finds recordings, clips and uploads through Search, filters recordings by category, and pages one kind', async () => {
+ const empty = await getVideo('/search');
+ assert.strictEqual(empty.status, 200);
+ assert.ok(empty.text.includes('<h1>Search videos</h1>') && empty.text.includes('href="/category/gaming"'), 'a search box and the categories');
+ assert.doesNotMatch(empty.text, /<meta name="robots" content="noindex/);
+ const r = await getVideo('/search?q=robot');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<meta name="robots" content="noindex/, 'results are not indexed');
+ assert.ok(r.text.includes('href="/v/101"') && r.text.includes('https://openvibe.media/t/101.jpg'), 'the recording, with the thumbnail Live has');
+ assert.ok(r.text.includes('href="/c/201"') && !r.text.includes('javascript:x'), 'the clip; a malformed id is dropped');
+ assert.ok(!r.text.includes('Gone upload'), 'an upload Search still lists but Video does not have publicly is left out');
+ assert.ok(r.text.includes('href="/search?q=robot&type=vod"') && r.text.includes('More stream recordings'), 'more of one kind');
+ assert.ok(r.text.includes('href="/search?q=robot&type=vod&category=gaming"') && !r.text.includes('category=nope'), 'known categories only');
+ const one = await getVideo('/search?q=robot&type=vod&category=gaming');
+ assert.match(one.text, /href="\/search\?q=robot&type=vod&category=gaming" aria-current="true"/);
+ assert.ok(one.text.includes('after=page2') && one.text.includes('More results'), 'the next page');
+ const none = await getVideo('/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E');
+ assert.ok(none.text.includes('Nothing matched') && !none.text.includes('<script>alert(1)'), 'nothing found, and the words are escaped');
+ });
+
+ await check('the navbar names the site\'s places: Videos, Search, Upload, Record, Your videos and Playlists, the current one active', async () => {
const home = (await getVideo('/')).text;
const home = (await getVideo('/')).text;
- assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Record","href":"\/record","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);
+ assert.match(home, /"links":\[\{"label":"Videos","href":"\/","active":true\},\{"label":"Search","href":"\/search","active":false\},\{"label":"Upload","href":"\/upload","active":false\},\{"label":"Record","href":"\/record","active":false\},\{"label":"Your videos","href":"\/me\/videos","active":false\},\{"label":"Playlists","href":"\/playlists","active":false\}\]/);
assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');
assert.ok(home.includes('href="/playlists"'), 'the no-JS nav links Playlists too');
});
});