From 2042c9b6fc133dc2abd2f6b4cbeacdb3677d043d Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sun, 4 Oct 2026 02:42:02 +0800 Subject: [PATCH] Recover related videos with bounded discovery and retryable fallbacks --- docs/related-video-recovery.md | 39 ++++++++++++++++++++ frontend/app.js | 35 ++++++++++++------ frontend/index.html | 2 + frontend/related-videos.css | 11 ++++++ frontend/related-videos.js | 28 ++++++++++++++ frontend/related-videos.test.js | 18 +++++++++ frontend/sw.js | 2 + playwright.related.config.js | 2 + server/innertube.js | 39 ++++++++++++++++++++ server/innertube.test.js | 13 +++++++ server/package.json | 2 +- server/related.js | 65 +++++++++++++++++++++++++++++++++ server/related.test.js | 43 ++++++++++++++++++++++ server/server.js | 20 ++++++++++ tests/related-videos.spec.js | 19 ++++++++++ 15 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 docs/related-video-recovery.md create mode 100644 frontend/related-videos.css create mode 100644 frontend/related-videos.js create mode 100644 frontend/related-videos.test.js create mode 100644 playwright.related.config.js create mode 100644 server/related.js create mode 100644 server/related.test.js create mode 100644 tests/related-videos.spec.js 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); +});