diff --git a/docs/related-video-recovery.md b/docs/related-video-recovery.md
new file mode 100644
index 0000000..5647c46
--- /dev/null
+++ b/docs/related-video-recovery.md
@@ -0,0 +1,39 @@
+# Related-video recovery
+
+The old Now Playing related loader called `/api/search` with the complete title.
+That endpoint follows up to 14 InnerTube pages to collect 200 results, then uses
+resilient yt-dlp if necessary. Related displayed only eight cards. It had no
+alternate query or catalog fallback, silently caught failures, and hid its panel.
+It also read mutable `current` after awaiting the request, allowing a previous
+song's response to populate the next song's recommendations.
+
+A read-only production probe (`/api/search?q=Rock Medley&refresh=1`) returned 200
+results successfully during this investigation. No persistent IP/account/region
+block was reproduced, and no privileged production logs or credentials were used.
+The exact intermittent upstream YouTube rejection cannot be established from
+that successful probe. The failure handling and request coupling above are
+verified directly in the previous code and reproduced in tests.
+
+The new `/api/related` endpoint first reads one InnerTube watch-next response,
+including compact and modern lockup video cards. A local real request for
+`wZzRoXymOUU` parsed 20 recommendations. Failures/empty results fall back to bounded
+single-page title/channel searches, then the existing resilient yt-dlp search
+path, then videos already known to the server. It excludes the current song,
+deduplicates, returns eight cards, shares in-flight work and caches successful
+results. Failed responses are not cached; Retry bypasses the success cache.
+The YouTube work has an 18-second total budget with per-operation timeouts.
+The existing `SEARCH_INNERTUBE=0` switch still disables InnerTube.
+
+Every new discovery goes through the existing metadata catalog/thumbnail collector.
+A metadata persistence failure cannot hide otherwise playable cards. Related's
+client snapshots the song and ignores superseded responses. When all sources are
+unavailable, its panel stays visible with a friendly message and Retry, rather
+than disappearing. Catalog fallback is explicitly labelled.
+
+Validation: frontend loader tests cover filtering, errors, retry refresh and stale
+responses. Server tests cover renderer parsing, next/search/catalog fallbacks,
+metadata collection, cache/in-flight deduplication, deadline exhaustion, and route
+validation. Browser tests in both themes, Chromium and WebKit at 390 px, exercise
+503 → Retry → results and assert that the section rail retains its parent.
+
+No changes to the Glass section rail; no push or deployment.
diff --git a/frontend/app.js b/frontend/app.js
index 7373ab1..25ff7d4 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -572,6 +572,9 @@ const API = {
search: (query, { refresh = false } = {}) => WEB
? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`)
: call('yt.search', 'yt_search', { query }),
+ related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id)
+ ? fetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`).then(response => response.json())
+ : API.search(meta.title || meta.channel, { refresh }),
// Videos the server already knows — answers fast while the real search loads.
searchLocal: (query) => WEB
? webFetch(`/api/search/local?q=${encodeURIComponent(query)}`)
@@ -11835,23 +11838,33 @@ function updateAbUI() {
// ============================================================================
// Related videos
// ============================================================================
-async function loadRelated() {
- if (!current || !current.meta) return;
- $('relatedPanel').classList.add('hidden');
- relatedVideos = [];
- try {
- const res = await API.search(current.meta.title);
- if (!res || !res.ok || !current) return;
- relatedVideos = (res.results || []).filter((v) => v.id !== current.meta.id).slice(0, 8);
- renderRelated();
- } catch { /* ignore */ }
+let relatedState = { status: 'idle', message: '' };
+const fetchRelatedVideos = RelatedVideos.createLoader({
+ fetchRelated: (meta, options) => API.related(meta, options),
+ update(state) {
+ if (current?.meta?.id !== state.id) return;
+ relatedState = state; relatedVideos = state.cards; renderRelated();
+ },
+});
+async function loadRelated(options = {}) {
+ if (current?.meta) return fetchRelatedVideos(current.meta, options);
}
function renderRelated() {
const panel = $('relatedPanel'), list = $('relatedList');
- if (!panel || !list || !relatedVideos.length) { if (panel) panel.classList.add('hidden'); return; }
+ if (!panel || !list || (relatedState.status === 'idle' && !relatedVideos.length)) { if (panel) panel.classList.add('hidden'); return; }
panel.classList.remove('hidden');
list.classList.toggle('hidden', relatedCollapsed);
list.innerHTML = '';
+ if (relatedState.message) {
+ const notice = document.createElement('div'); notice.className = 'related-notice'; notice.setAttribute('role', 'status');
+ const message = document.createElement('p'); message.textContent = relatedState.message; notice.append(message);
+ if (relatedState.status === 'error') {
+ const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'related-retry'; retry.setAttribute('aria-label', 'Retry related videos');
+ retry.innerHTML = 'Retry';
+ retry.onclick = () => loadRelated({ refresh: true }); notice.append(retry);
+ }
+ list.append(notice);
+ }
relatedVideos.forEach((v) => {
const item = document.createElement('div');
item.className = 'related-item';
diff --git a/frontend/index.html b/frontend/index.html
index d243a19..c65f2fe 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -36,6 +36,7 @@
+
@@ -662,6 +663,7 @@
+
diff --git a/frontend/related-videos.css b/frontend/related-videos.css
new file mode 100644
index 0000000..6a29cd5
--- /dev/null
+++ b/frontend/related-videos.css
@@ -0,0 +1,11 @@
+.related-notice { display:flex; align-items:center; flex-wrap:wrap; gap:12px; padding:12px 0; color:var(--text-2); font-size:14px; line-height:1.5; }
+.related-notice p { margin:0; flex:1 1 180px; }
+.related-retry { display:inline-flex; align-items:center; justify-content:center; gap:8px; min-width:44px; min-height:44px; padding:8px 14px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); color:var(--text); background:var(--bg-2); font:inherit; cursor:pointer; }
+.related-retry svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; }
+.related-retry:hover { background:var(--bg-3); }
+.related-retry:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
+html[data-layout="glass-stage"] .related-retry { border-radius:18px; border-color:var(--glass-line); background:var(--tile); color:var(--text-2); }
+html[data-layout="glass-stage"] .related-retry:hover { background:var(--tile-hi); color:var(--text); }
+/* The scrollable player must not squeeze this card to its border height. */
+.related-panel { flex-shrink:0; }
+.related-notice { flex-shrink:0; }
diff --git a/frontend/related-videos.js b/frontend/related-videos.js
new file mode 100644
index 0000000..c055fe8
--- /dev/null
+++ b/frontend/related-videos.js
@@ -0,0 +1,28 @@
+(function(root) {
+ 'use strict';
+ function cardsFor(cards, id) {
+ const seen = new Set([id]);
+ return (Array.isArray(cards) ? cards : []).filter(card => {
+ if (!card?.id || !card.title || seen.has(card.id)) return false;
+ seen.add(card.id); return true;
+ }).slice(0, 8);
+ }
+ function createLoader({ fetchRelated, update }) {
+ let serial = 0;
+ return async function load(meta, { refresh = false } = {}) {
+ const request = ++serial, snapshot = { ...meta };
+ update({ id: snapshot.id, status: 'loading', cards: [], message: 'Finding related videos…' });
+ let state;
+ try {
+ const result = await fetchRelated(snapshot, { refresh });
+ const cards = cardsFor(result?.results, snapshot.id);
+ state = result?.ok && cards.length ? { id: snapshot.id, status: 'ready', cards, message: result.message || '' }
+ : { id: snapshot.id, status: 'error', cards: [], message: 'Related videos aren’t available right now. Please try again.', detail: result?.detail || result?.error || '' };
+ } catch (error) { state = { id: snapshot.id, status: 'error', cards: [], message: 'Related videos aren’t available right now. Please try again.', detail: error.message || '' }; }
+ if (request === serial) update(state);
+ return state;
+ };
+ }
+ const api = { cardsFor, createLoader };
+ if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.RelatedVideos = api;
+})(globalThis);
diff --git a/frontend/related-videos.test.js b/frontend/related-videos.test.js
new file mode 100644
index 0000000..efc9699
--- /dev/null
+++ b/frontend/related-videos.test.js
@@ -0,0 +1,18 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { createLoader, cardsFor } = require('./related-videos');
+test('related cards exclude the current video, duplicates and invalid cards', () => {
+ assert.deepEqual(cardsFor([{id:'a',title:'A'},{id:'b',title:'B'},{id:'b',title:'B'},null,{id:'c'}],'a'),[{id:'b',title:'B'}]);
+});
+test('failed related discovery stays actionable and Retry refreshes the source', async () => {
+ const states = [], calls = [];
+ const load = createLoader({ fetchRelated: async (_, options) => { calls.push(options); return calls.length === 1 ? {ok:false,error:'YouTube HTTP 429'} : {ok:true,results:[{id:'b',title:'B'}]}; }, update: state => states.push(state) });
+ await load({id:'a',title:'A'}); assert.equal(states.at(-1).status,'error'); assert.equal(states.at(-1).detail,'YouTube HTTP 429');
+ await load({id:'a',title:'A'},{refresh:true}); assert.equal(states.at(-1).status,'ready'); assert.equal(calls[1].refresh,true);
+});
+test('a slow previous song cannot overwrite the current song recommendations', async () => {
+ let finish; const states=[];
+ const load=createLoader({fetchRelated: meta=>meta.id==='a'?new Promise(resolve=>{finish=resolve;}):Promise.resolve({ok:true,results:[{id:'d',title:'D'}]}),update:state=>states.push(state)});
+ const old=load({id:'a'}); await load({id:'b'}); finish({ok:true,results:[{id:'c',title:'C'}]}); await old;
+ assert.equal(states.at(-1).id,'b'); assert.equal(states.at(-1).cards[0].id,'d');
+});
diff --git a/frontend/sw.js b/frontend/sw.js
index 0025ca0..663f7a0 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -91,6 +91,8 @@ const SHELL = [
'/p2p-transfer.js',
'/p2p-recv-worker.js',
'/settings-sections.js',
+ '/related-videos.js',
+ '/related-videos.css',
'/audio-continuity.js',
'/fullscreen-orientation.js',
'/fullscreen-orientation.css',
diff --git a/playwright.related.config.js b/playwright.related.config.js
new file mode 100644
index 0000000..50b81b2
--- /dev/null
+++ b/playwright.related.config.js
@@ -0,0 +1,2 @@
+const { defineConfig } = require('@playwright/test');
+module.exports = defineConfig({ testDir: './tests', testMatch: /related-videos\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8101', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8101 --no-clipboard', port: 8101 } });
diff --git a/server/innertube.js b/server/innertube.js
index 929f1c3..c6ae686 100644
--- a/server/innertube.js
+++ b/server/innertube.js
@@ -104,3 +104,42 @@ export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs
}
return all.slice(0, limit);
}
+
+// Watch-next recommendations use compact renderers or the newer lockup model.
+// Traverse only the recommendation contents, never player configuration/ads.
+export function parseRelated(json) {
+ const contents = json?.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results
+ || json?.contents?.singleColumnWatchNextResults?.results?.results?.contents;
+ if (!Array.isArray(contents)) throw new Error('innertube: unexpected related response shape');
+ const cards = [], seen = new Set();
+ function visit(value) {
+ if (!value || typeof value !== 'object') return;
+ const renderer = value.compactVideoRenderer || value.videoRenderer;
+ const model = value.lockupViewModel;
+ const id = renderer?.videoId || (model?.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO' ? model.contentId : '');
+ if (typeof id === 'string' && /^[\w-]{11}$/.test(id) && !seen.has(id)) {
+ const meta = model?.metadata?.lockupMetadataViewModel;
+ const part = meta?.metadata?.contentMetadataViewModel?.metadataRows?.[0]?.metadataParts?.[0]?.text;
+ const owner = renderer?.shortBylineText?.runs?.[0] || renderer?.longBylineText?.runs?.[0] || {};
+ const browse = owner.navigationEndpoint?.browseEndpoint || part?.commandRuns?.[0]?.onTap?.innertubeCommand?.browseEndpoint || {};
+ const badge = model?.contentImage?.thumbnailViewModel?.overlays?.flatMap(o => o.thumbnailOverlayBadgeViewModel?.thumbnailBadges || []).find(b => b.thumbnailBadgeViewModel?.text)?.thumbnailBadgeViewModel?.text;
+ const title = renderer?.title?.simpleText || renderer?.title?.runs?.map(r => r.text).join('') || meta?.title?.content;
+ if (title) { seen.add(id); cards.push({ id, title, channel: owner.text || part?.content || '', channelId: browse.browseId || '', channelUrl: browse.canonicalBaseUrl ? `https://www.youtube.com${browse.canonicalBaseUrl}` : '', duration: lengthToSeconds(renderer?.lengthText?.simpleText || badge), thumbnail: `https://i.ytimg.com/vi/${id}/mqdefault.jpg` }); }
+ return;
+ }
+ // Ignore advertised placements rather than recursing into their videos.
+ for (const [key, child] of Object.entries(value)) if (!/adSlot|promoted|adPlacement/i.test(key)) {
+ if (Array.isArray(child)) child.forEach(visit); else visit(child);
+ }
+ }
+ contents.forEach(visit);
+ return cards;
+}
+export async function related(videoId, { fetchImpl = fetch, timeoutMs = 4000 } = {}) {
+ const response = await fetchImpl('https://www.youtube.com/youtubei/v1/next?prettyPrint=false', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ context: { client: CLIENT }, videoId }), signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!response.ok) throw new Error(`innertube related: HTTP ${response.status}`);
+ return parseRelated(await response.json());
+}
diff --git a/server/innertube.test.js b/server/innertube.test.js
index 62dc2ce..da1011c 100644
--- a/server/innertube.test.js
+++ b/server/innertube.test.js
@@ -47,3 +47,16 @@ test('searchDeep follows continuation tokens, de-duplicates and stops at the lim
test('parseContinuation of an empty response is empty', () => {
expect(parseContinuation({})).toEqual({ cards: [], next: '' });
});
+
+test('related parser reads compact and modern lockup cards, excluding playlists and ads', async () => {
+ const { parseRelated } = await import('./innertube.js');
+ const compact = { compactVideoRenderer: { videoId: 'aaaaaaaaaaa', title: { simpleText: 'First' }, shortBylineText: { runs: [{ text: 'Artist' }] }, lengthText: { simpleText: '3:12' } } };
+ const modern = { lockupViewModel: { contentId: 'bbbbbbbbbbb', contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', metadata: { lockupMetadataViewModel: { title: { content: 'Second' }, metadata: { contentMetadataViewModel: { metadataRows: [{ metadataParts: [{ text: { content: 'Band' } }] }] } } } } } };
+ const result = parseRelated({ contents: { twoColumnWatchNextResults: { secondaryResults: { secondaryResults: { results: [compact, modern, compact, { lockupViewModel: { ...modern.lockupViewModel, contentType: 'LOCKUP_CONTENT_TYPE_PLAYLIST' } }, { adSlotRenderer: { contents: compact } }] } } } } });
+ expect(result.map(c => [c.id, c.title, c.channel])).toEqual([['aaaaaaaaaaa', 'First', 'Artist'], ['bbbbbbbbbbb', 'Second', 'Band']]);expect(result[0].duration).toBe(192);
+});
+test('related HTTP failures and unsupported shapes throw for search fallback', async () => {
+ const { related, parseRelated } = await import('./innertube.js');
+ expect(() => parseRelated({ error: {} })).toThrow('response shape');
+ await expect(related('aaaaaaaaaaa', { fetchImpl: async () => ({ ok: false, status: 429 }) })).rejects.toThrow('429');
+});
diff --git a/server/package.json b/server/package.json
index 1a5125e..114ca94 100644
--- a/server/package.json
+++ b/server/package.json
@@ -6,7 +6,7 @@
"scripts": {
"start": "bun server.js",
"dev": "bun --hot server.js",
- "test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./ytdlp-pool.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js"
+ "test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js"
},
"dependencies": {
"@hono/node-server": "^1.14.0",
diff --git a/server/related.js b/server/related.js
new file mode 100644
index 0000000..174c4b7
--- /dev/null
+++ b/server/related.js
@@ -0,0 +1,65 @@
+// Related discovery is bounded and independent of the deep search collector.
+export function relatedQueries({ title = '', channel = '' }) {
+ title = String(title).replace(/\([^)]*(?:official|lyrics?|music video)[^)]*\)|\[[^\]]*(?:official|lyrics?|music video)[^\]]*\]/gi, '').replace(/\s+/g, ' ').trim().slice(0, 300);
+ channel = String(channel).trim().slice(0, 120);
+ return [...new Set([title && channel ? `${title} ${channel}` : '', title, channel].filter(Boolean))];
+}
+export function selectRelated(cards, id, limit = 8) {
+ const seen = new Set([id]);
+ return (Array.isArray(cards) ? cards : []).filter(card => {
+ if (!card || typeof card.id !== 'string' || !card.title || seen.has(card.id)) return false;
+ seen.add(card.id); return true;
+ }).slice(0, limit);
+}
+export function createRelatedService({ next, search, local, collect = async () => {}, decorate = async cards => cards, now = Date.now, budgetMs = 18000 }) {
+ const cache = new Map(), pending = new Map();
+ async function load(seed, { refresh = false } = {}) {
+ const key = JSON.stringify([seed.id, seed.title, seed.channel]);
+ const cached = cache.get(key);
+ if (!refresh && cached && now() - cached.at < cached.ttl) return cached.result;
+ if (pending.has(key)) return pending.get(key);
+ const task = (async () => {
+ const deadline = now() + budgetMs, details = [];
+ let results = [], source = 'youtube';
+ try { results = selectRelated(await next(seed.id, Math.min(4000, budgetMs)), seed.id); }
+ catch (error) { details.push(String(error.message || error).slice(0, 300)); }
+ const queries = relatedQueries(seed);
+ for (const query of queries) {
+ if (results.length || now() >= deadline) break;
+ try { results = selectRelated(await search(query, Math.max(1, deadline - now())), seed.id); if (results.length) source = 'search'; }
+ catch (error) { details.push(String(error.message || error).slice(0, 300)); }
+ }
+ if (!results.length) {
+ source = 'catalog';
+ // Local results still work when YouTube blocks or times out. Broad queries
+ // avoid requiring every word of a long video title to be in another title.
+ const localQueries = [...new Set([seed.channel, seed.title?.split(/\s+/).slice(0, 3).join(' '), ...queries].filter(Boolean))];
+ for (const query of localQueries) {
+ try { results = selectRelated(await local(query), seed.id); } catch { /* try another local query */ }
+ if (results.length) break;
+ }
+ }
+ if (!results.length) return { ok: false, error: 'Related videos aren’t available right now. Please try again.', detail: details.join('\n') };
+ // Metadata/thumbnail persistence must not discard otherwise playable cards.
+ try { await collect(results); } catch { /* discovery remains usable */ }
+ try { results = await decorate(results); } catch { /* remote thumbnails remain */ }
+ const result = { ok: true, results, source, ...(source === 'catalog' ? { message: 'YouTube is unavailable. Showing related videos already known to this server.' } : {}) };
+ if (cache.size >= 500) cache.delete(cache.keys().next().value);
+ cache.set(key, { result, at: now(), ttl: source === 'catalog' ? 60000 : 600000 });
+ return result;
+ })().finally(() => pending.delete(key));
+ pending.set(key, task); return task;
+ }
+ return { load };
+}
+export function registerRelatedRoute(app, service) {
+ app.get('/api/related', async c => {
+ const id = (c.req.query('videoId') || '').trim();
+ if (!/^[\w-]{11}$/.test(id)) return c.json({ ok: false, error: 'Invalid video id.' }, 400);
+ const seed = { id, title: (c.req.query('title') || '').trim().slice(0, 300), channel: (c.req.query('channel') || '').trim().slice(0, 120) };
+ try {
+ const result = await service.load(seed, { refresh: c.req.query('refresh') === '1' });
+ return c.json(result, result.ok ? 200 : 503);
+ } catch { return c.json({ ok: false, error: 'Related videos aren’t available right now. Please try again.' }, 503); }
+ });
+}
diff --git a/server/related.test.js b/server/related.test.js
new file mode 100644
index 0000000..ee8a9e1
--- /dev/null
+++ b/server/related.test.js
@@ -0,0 +1,43 @@
+import { test, expect } from 'bun:test';
+import { Hono } from 'hono';
+import { createRelatedService, relatedQueries, selectRelated, registerRelatedRoute } from './related.js';
+const seed = { id: 'aaaaaaaaaaa', title: 'Grace (Official Lyric Video)', channel: 'Artist' };
+const card = { id: 'bbbbbbbbbbb', title: 'Another song', channel: 'Artist' };
+const fail = async () => { throw new Error('YouTube HTTP 429'); };
+test('fallback queries remove video boilerplate and deduplicate; cards exclude seed', () => {
+ expect(relatedQueries(seed)).toEqual(['Grace Artist', 'Grace', 'Artist']);
+ expect(selectRelated([{ ...card, id: seed.id }, card, card, null], seed.id)).toEqual([card]);
+});
+test('watch-next results bypass deep search and collect metadata', async () => {
+ let searches = 0, collected;
+ const service = createRelatedService({ next: async () => [card], search: async () => { searches++; return []; }, local: async () => [], collect: async cards => { collected = cards; } });
+ expect((await service.load(seed)).source).toBe('youtube'); expect(searches).toBe(0); expect(collected).toEqual([card]);
+});
+test('YouTube next failure and empty narrow query fall back to title search', async () => {
+ const queries = [];
+ const service = createRelatedService({ next: fail, search: async q => { queries.push(q); return q === 'Grace' ? [card] : []; }, local: async () => [] });
+ expect((await service.load(seed)).source).toBe('search'); expect(queries).toEqual(['Grace Artist', 'Grace']);
+});
+test('catalog fallback works without YouTube and metadata failures do not hide cards', async () => {
+ const service = createRelatedService({ next: fail, search: fail, local: async q => q === 'Artist' ? [card] : [], collect: fail });
+ const answer = await service.load(seed); expect(answer.ok).toBe(true); expect(answer.source).toBe('catalog'); expect(answer.results).toEqual([card]);
+});
+test('shared inflight request, successful cache, and explicit refresh', async () => {
+ let finish, calls=0;
+ const service=createRelatedService({next:async()=>{calls++;return new Promise(resolve=>{finish=resolve;});},search:fail,local:async()=>[]});
+ const a=service.load(seed),b=service.load(seed); finish([card]); await Promise.all([a,b]); expect(calls).toBe(1);
+ await service.load(seed); expect(calls).toBe(1);
+ const retry=service.load(seed,{refresh:true}); finish([card]); await retry; expect(calls).toBe(2);
+});
+test('failed responses are never cached, and exhausted budget goes straight to catalog', async () => {
+ let time=0, calls=0, searches=0;
+ const service=createRelatedService({now:()=>time,budgetMs:10,next:async()=>{calls++;time+=20;throw new Error('timeout');},search:async()=>{searches++;return[];},local:async()=>[]});
+ expect((await service.load(seed)).ok).toBe(false); expect((await service.load(seed)).ok).toBe(false);
+ expect(calls).toBe(2); expect(searches).toBe(0);
+});
+test('endpoint validates ids and provides a friendly retryable failure', async () => {
+ const app=new Hono();registerRelatedRoute(app,createRelatedService({next:fail,search:fail,local:async()=>[]}));
+ expect((await app.request('/api/related?videoId=bad')).status).toBe(400);
+ const response=await app.request(`/api/related?videoId=${seed.id}&title=Grace`); expect(response.status).toBe(503);
+ const body=await response.json(); expect(body.error).toContain('Please try again');expect(body.detail).toContain('429');
+});
diff --git a/server/server.js b/server/server.js
index 792d6c6..b904559 100644
--- a/server/server.js
+++ b/server/server.js
@@ -61,6 +61,7 @@ import { registerIntakeRoutes } from './p2p-intake.js';
import { createP2pHub, holdersPayload, createRehydrator } from './p2p-hub.js';
import { sha256Range } from './hash.js';
import * as innertube from './innertube.js';
+import { createRelatedService, registerRelatedRoute } from './related.js';
import * as searchCacheDb from './search-cache.js';
import { ingest as collectVideoMetadata, syncListening, linkListening, startThumbnails } from './video-catalog.js';
import { registerCatalogRoutes } from './recommendations.js';
@@ -488,6 +489,25 @@ app.get('/api/search', async (c) => {
}
});
+// Eight related cards should not depend on a 200-result deep search succeeding.
+registerRelatedRoute(app, createRelatedService({
+ next: (id, timeoutMs) => process.env.SEARCH_INNERTUBE === '0' ? Promise.reject(new Error('InnerTube disabled')) : innertube.related(id, { timeoutMs }),
+ search: async (query, budgetMs) => {
+ const start = Date.now();
+ try {
+ if (process.env.SEARCH_INNERTUBE !== '0') {
+ const cards = await innertube.search(query, { timeoutMs: Math.min(3500, budgetMs) });
+ if (cards.length) return cards;
+ }
+ } catch { /* use the existing resilient yt-dlp search path */ }
+ const remaining = budgetMs - (Date.now() - start);
+ if (remaining <= 0) throw new Error('Related search timed out');
+ return parseCards(await runYtdlpResilient([`ytsearch12:${query}`, '--dump-json', '--flat-playlist', '--no-warnings', '--ignore-errors', '--socket-timeout', '4', '--retries', '1'], { signal: AbortSignal.timeout(Math.min(6000, remaining)) }));
+ },
+ local: query => searchCacheDb.searchVideos(query, 20),
+ collect: cards => collectVideoMetadata(cards, 'related'),
+}));
+
// GET /api/channel?c=
// Channel listing that works for every kind of channel, not one-off cases:
// 1. /videos, /streams, /shorts tabs — normal channels
diff --git a/tests/related-videos.spec.js b/tests/related-videos.spec.js
new file mode 100644
index 0000000..bd0c5d5
--- /dev/null
+++ b/tests/related-videos.spec.js
@@ -0,0 +1,19 @@
+const { test, expect } = require('@playwright/test');
+const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
+for (const layout of ['classic', 'glass-stage']) test(`${layout}: related failure stays visible and Retry refreshes`, async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await openClassic(page, { populated: true }); await showPlayingFixture(page);
+ let calls = 0;
+ await page.route('**/api/related?**', route => {
+ calls++; if (calls > 1) expect(new URL(route.request().url()).searchParams.get('refresh')).toBe('1');
+ return route.fulfill({ status: calls === 1 ? 503 : 200, contentType: 'application/json', body: JSON.stringify(calls === 1 ? { ok: false, error: 'Raw YouTube failure' } : { ok: true, results: [{ id: 'bbbbbbbbbbb', title: 'A related song', channel: 'Artist' }] }) });
+ });
+ const railParent = await page.locator('#sectionRail').evaluate(el => el.parentElement.id || el.parentElement.className);
+ await page.evaluate(async layout => { data.settings.layout = layout; applyAppearance(); view = { type: 'nowPlaying' }; render(); relatedCollapsed = false; await loadRelated(); }, layout);
+ await expect(page.locator('#relatedPanel')).toBeVisible();
+ await expect(page.locator('#relatedList')).toContainText('Please try again');
+ await expect(page.locator('#relatedList')).not.toContainText('Raw YouTube failure');
+ await page.getByRole('button', { name: 'Retry related videos' }).click();
+ await expect(page.locator('.ri-title')).toHaveText('A related song');
+ expect(await page.locator('#sectionRail').evaluate(el => el.parentElement.id || el.parentElement.className)).toBe(railParent);
+});