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)
Changes
@@@ -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; }@@@ -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 };@@@ -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>` : ''}@@@ -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 () => {
@@ -58,6 +58,10 @@
@@ -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-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 { 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; }
.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-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; }
.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; }
a.ovv-channel-name:hover { text-decoration: underline; }
@@ -24,28 +24,37 @@ function createIdentity({ config, fetchImpl = globalThis.fetch, log = console })
@@ -24,28 +24,37 @@ function createIdentity({ config, fetchImpl = globalThis.fetch, log = console })
fetchImpl,
fetchImpl,
}) : null;
}) : 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 {
try {
const res = await fetchImpl(
const res = await fetchImpl(
`${config.networkInternalUrl}/internal/identity/resolve?username=${encodeURIComponent(username)}`,
`${config.networkInternalUrl}/internal/identity/resolve?username=${encodeURIComponent(username)}`,
{ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(5000) });
{ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(5000) });
if (res.status === 401 && tokens.invalidate) tokens.invalidate();
if (res.status === 401 && tokens.invalidate) tokens.invalidate();
- if (res.status === 404) return null;
+ if (res.status === 404) return { status: 'unknown' };
if (!res.ok) {
if (!res.ok) {
log.warn(`[MediaHub] identity.resolve answered ${res.status} for @${username.slice(0, 40)}`);
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 data = await res.json().catch(() => null);
const id = data && data.subject && data.subject.id;
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) {
} catch (err) {
log.warn('[MediaHub] identity.resolve failed:', (err && err.message) || '');
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 → { subjects: ['usr_…'], names: ['kim'], unresolved: ['kim'] }.
* `names` keeps every typed username (shown to the owner, never matched); `unresolved` is what Network could not
* `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 })
@@ -65,7 +74,7 @@ function createIdentity({ config, fetchImpl = globalThis.fetch, log = console })
return { subjects: [...new Set(subjects)], names: [...new Set(names)], unresolved };
return { subjects: [...new Set(subjects)], names: [...new Set(names)], unresolved };
}
}
- return { configured, resolveUsername, resolveUsernames };
+ return { configured, lookup, resolveUsername, resolveUsernames };
}
}
module.exports = { createIdentity };
module.exports = { createIdentity };
@@ -386,17 +386,20 @@ function createVideoRoutes(ctx) {
@@ -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
// 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
// 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.
// 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 key = username.toLowerCase();
const hit = subjects.get(key);
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.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);
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 live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const r = asyncRouter();
@@ -1017,12 +1020,17 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}
@@ -1017,12 +1020,17 @@ ${manageable ? ownerPanel('clip', id, clip) : ''}
const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
const uploaded = pageNum === 1 ? await ctx.videoUploads.channel(username) : [];
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, {
return page(req, res, {
index: true,
index: true,
title: `@${username}`,
title: `@${username}`,
description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,
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>` : ''}
${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>
<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>` : ''}
${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>` : ''}
@@ -316,6 +316,13 @@ function countOccurrences(haystack, needle) {
@@ -316,6 +316,13 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('/v/101'));
assert.ok(r.text.includes('/v/101'));
assert.ok(r.text.includes('Clips'));
assert.ok(r.text.includes('Clips'));
assert.ok(r.text.includes('/c/201'));
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 () => {
await check('captions.vtt is exact, escaping and collapsing cue text', async () => {