openvibers/openvibe.mediahub Commits

Video channel page: a name nobody has is a 404 (it was an empty 200 page linking to a Live channel that does not exist), and the header shows the person's picture over the letter (#89)

OP OpenVibers committed 27 days ago · 09245b65d5 · 4 files, +43 −15

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 1dc7289..e6e4531 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -58,6 +58,10 @@
.ovv-byline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 14px; padding-bottom: 16px; border-bottom: 1px solid var(--ovv-line); }
.ovv-avatar { display: grid; place-items: center; flex: none; width: 42px; height: 42px; border-radius: 50%; background: linear-gradient(135deg, var(--accent-light, #60a5fa), var(--accent-strong, #2563eb)); color: #fff !important; font-weight: 800; text-decoration: none; }
.ovv-avatar--big { width: 64px; height: 64px; font-size: 1.6rem; }
++/* The person's picture (Network's /avatar/:username, a lettered one when they have none) over the letter, which
++ still shows if the picture cannot load. */
++.ovv-avatar { position: relative; overflow: hidden; }
++.ovv-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.ovv-byline-text { display: grid; gap: 2px; min-width: 0; flex: 1 1 200px; }
.ovv-channel-name { font-weight: 700; color: var(--text-primary, #e6edf7) !important; text-decoration: none; overflow-wrap: anywhere; }
a.ovv-channel-name:hover { text-decoration: underline; }
ddiff --git a/server/network/identity.js b/server/network/identity.js
iindex d8fa0f0..6fded05 100644
---- a/server/network/identity.js
++++ b/server/network/identity.js
@@@ -24,28 +24,37 @@ function createIdentity({ config, fetchImpl = globalThis.fetch, log = console })
fetchImpl,
}) : null;
-- /** One username → its subject, or null when Network does not know it (or cannot be asked). */
-- async function resolveUsername(username) {
-- if (!tokens) return null;
++ /**
++ * One username → { status: 'found', id } | { status: 'unknown' } (Network answered 404) | { status: 'error' } (not
++ * configured, or Network could not be asked). Only 'unknown' says nobody has the name.
++ */
++ async function lookup(username) {
++ if (!tokens) return { status: 'error' };
try {
const res = await fetchImpl(
`${config.networkInternalUrl}/internal/identity/resolve?username=${encodeURIComponent(username)}`,
{ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(5000) });
if (res.status === 401 && tokens.invalidate) tokens.invalidate();
-- if (res.status === 404) return null;
++ if (res.status === 404) return { status: 'unknown' };
if (!res.ok) {
log.warn(`[MediaHub] identity.resolve answered ${res.status} for @${username.slice(0, 40)}`);
-- return null;
++ return { status: 'error' };
}
const data = await res.json().catch(() => null);
const id = data && data.subject && data.subject.id;
-- return typeof id === 'string' && id.startsWith('usr_') ? id : null;
++ return typeof id === 'string' && id.startsWith('usr_') ? { status: 'found', id } : { status: 'error' };
} catch (err) {
log.warn('[MediaHub] identity.resolve failed:', (err && err.message) || '');
-- return null;
++ return { status: 'error' };
}
}
++ /** One username → its subject, or null when Network does not know it (or cannot be asked). */
++ async function resolveUsername(username) {
++ const r = await lookup(username);
++ return r.status === 'found' ? r.id : null;
++ }
++
/**
* names → { subjects: ['usr_…'], names: ['kim'], unresolved: ['kim'] }.
* `names` keeps every typed username (shown to the owner, never matched); `unresolved` is what Network could not
@@@ -65,7 +74,7 @@ function createIdentity({ config, fetchImpl = globalThis.fetch, log = console })
return { subjects: [...new Set(subjects)], names: [...new Set(names)], unresolved };
}
-- return { configured, resolveUsername, resolveUsernames };
++ return { configured, lookup, resolveUsername, resolveUsernames };
}
module.exports = { createIdentity };
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 57c5f2f..0583f7e 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -386,17 +386,20 @@ function createVideoRoutes(ctx) {
// A channel page lists that person's public playlists, which are stored by subject: the username is resolved
// through Network (identity.subject.resolve) and remembered for ten minutes (an unknown name for one), at most
// 1,000, so a crawled channel page is not a Network call per visit.
-- const subjects = new Map(); // username (lowercase) → { at, ttl, subject }
-- async function subjectOf(username) {
++ // `unknown` is true only when Network answered that nobody has the name (not when it could not be asked).
++ const subjects = new Map(); // username (lowercase) → { at, ttl, subject, unknown }
++ async function identityOf(username) {
const key = username.toLowerCase();
const hit = subjects.get(key);
-- if (hit && Date.now() - hit.at < hit.ttl) return hit.subject;
-- const subject = ctx.identity && ctx.identity.resolveUsername ? await ctx.identity.resolveUsername(username) : null;
++ if (hit && Date.now() - hit.at < hit.ttl) return hit;
++ const r = ctx.identity && ctx.identity.lookup ? await ctx.identity.lookup(username) : { status: 'error' };
++ const entry = { at: Date.now(), ttl: r.status === 'found' ? 600_000 : 60_000, subject: r.status === 'found' ? r.id : null, unknown: r.status === 'unknown' };
subjects.delete(key);
-- subjects.set(key, { at: Date.now(), ttl: subject ? 600_000 : 60_000, subject });
++ subjects.set(key, entry);
if (subjects.size > 1000) subjects.delete(subjects.keys().next().value);
-- return subject;
++ return entry;
}
++ async function subjectOf(username) { return (await identityOf(username)).subject; }
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
@@@ -1017,12 +1020,17 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}
const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
const uploaded = pageNum === 1 ? await ctx.videoUploads.channel(username) : [];
++ // A name nobody has, with nothing to show, is a 404 (it was a 200 "No recordings yet" page linking to a Live
++ // channel that does not exist, 2026-10-17). Only when Network says so: if it cannot be asked, the page stays.
++ if (!vods.length && !clips.length && !uploaded.length && !lists.length && (await identityOf(username).catch(() => ({}))).unknown) {
++ return notFound(req, res);
++ }
return page(req, res, {
index: true,
title: `@${username}`,
description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,
-- body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
++ body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy"></span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Videos</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex b19f3db..ba41f62 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -316,6 +316,13 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('/v/101'));
assert.ok(r.text.includes('Clips'));
assert.ok(r.text.includes('/c/201'));
++ assert.match(r.text, /<img src="[^"]*\/avatar\/streamer" alt="" loading="lazy">/, 'the person\'s picture over the letter');
++ });
++
++ await check('a name nobody has, with nothing to show, is a 404 (not an empty channel page)', async () => {
++ const r = await getVideo('/@nobody_here_x9');
++ assert.strictEqual(r.status, 404);
++ assert.ok(!r.text.includes('No recordings yet'));
});
await check('captions.vtt is exact, escaping and collapsing cue text', async () => {