Recover related videos with bounded discovery and retryable fallbacks

This commit is contained in:
Jonathan Sykes
2026-10-04 02:42:02 +08:00
parent df4acfc9f1
commit 2042c9b6fc
15 changed files with 326 additions and 12 deletions

View File

@@ -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.

View File

@@ -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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/></svg><span>Retry</span>';
retry.onclick = () => loadRelated({ refresh: true }); notice.append(retry);
}
list.append(notice);
}
relatedVideos.forEach((v) => {
const item = document.createElement('div');
item.className = 'related-item';

View File

@@ -36,6 +36,7 @@
<link rel="stylesheet" href="theme-glass-controls.css" />
<link rel="stylesheet" href="theme-glass-panel.css" />
<link rel="stylesheet" href="fullscreen-orientation.css" />
<link rel="stylesheet" href="related-videos.css" />
</head>
<body>
<div class="app">
@@ -662,6 +663,7 @@
<script src="p2p-transfer.js"></script>
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="related-videos.js"></script>
<script src="audio-continuity.js"></script>
<script src="fullscreen-orientation.js"></script>
<script src="settings-sections.js"></script>

View File

@@ -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; }

View File

@@ -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);

View File

@@ -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');
});

View File

@@ -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',

View File

@@ -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 } });

View File

@@ -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());
}

View File

@@ -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');
});

View File

@@ -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",

65
server/related.js Normal file
View File

@@ -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); }
});
}

43
server/related.test.js Normal file
View File

@@ -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');
});

View File

@@ -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>
// Channel listing that works for every kind of channel, not one-off cases:
// 1. /videos, /streams, /shorts tabs — normal channels

View File

@@ -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);
});