openvibers/openvibe.mediahub Commits

openvibe.video/search: recordings, clips and uploads through OpenVibe.Search, recordings by category (#111)

OP OpenVibers committed 22 days ago · 46790482c1 · 5 files, +142 −4

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 222bcc8..e1a74e7 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -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; }
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex d783e35..ffbf2d8 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -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
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 725b3b2..37a828e 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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,
ddiff --git a/server/video/search-reads.js b/server/video/search-reads.js
iindex 5db225f..a479a91 100644
---- a/server/video/search-reads.js
++++ b/server/video/search-reads.js
@@@ -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 };
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex 7738d58..e522f4f 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -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&amp;type=vod"') && r.text.includes('More stream recordings'), 'more of one kind');
++ assert.ok(r.text.includes('href="/search?q=robot&amp;type=vod&amp;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&amp;type=vod&amp;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');
});