Compare commits
2 Commits
df4acfc9f1
...
04b620e3c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b620e3c7 | ||
|
|
2042c9b6fc |
39
docs/related-video-recovery.md
Normal file
39
docs/related-video-recovery.md
Normal 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.
|
||||
@@ -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';
|
||||
|
||||
11
frontend/download-actions.css
Normal file
11
frontend/download-actions.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* Shared quiet controls; use each layout's existing surface and focus tokens. */
|
||||
:is(.dl-row,.download-job) .download-action { display:inline-flex; align-items:center; justify-content:center; flex:none; gap:8px; height:auto; min-height:44px; min-width:44px; padding:8px 14px; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--bg-2); color:var(--text); font:600 13px var(--ui); line-height:1.4; cursor:pointer; white-space:nowrap; }
|
||||
:is(.dl-row,.download-job) .download-action:hover { background:var(--bg-3); border-color:var(--accent); }
|
||||
:is(.dl-row,.download-job) .download-action.danger { color:var(--accent); }
|
||||
:is(.dl-row,.download-job) .download-action:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
||||
:is(.dl-row,.download-job) .download-action:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.download-action-icon { width:20px; height:20px; flex:none; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action { border-radius:18px; border-color:var(--glass-line); background:var(--tile); color:var(--text-2); box-shadow:none; }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); }
|
||||
24
frontend/download-actions.js
Normal file
24
frontend/download-actions.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/* One action control for the Downloads page and Settings download manager. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const paths = {
|
||||
retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>',
|
||||
cancel: '<path d="m6 6 12 12M6 18 18 6"/>',
|
||||
pause: '<path d="M8 5v14M16 5v14"/>',
|
||||
resume: '<path d="m9 5 10 7-10 7z"/>',
|
||||
};
|
||||
function create(doc, action, title) {
|
||||
if (!paths[action]) throw new Error('Unknown download action');
|
||||
const label = action[0].toUpperCase() + action.slice(1);
|
||||
const button = doc.createElement('button'); button.type = 'button';
|
||||
button.className = `dl-act download-action${action === 'cancel' ? ' danger' : ''}`;
|
||||
button.setAttribute('aria-label', `${label} ${title}`);
|
||||
const icon = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
icon.setAttribute('viewBox', '0 0 24 24'); icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('focusable', 'false');
|
||||
icon.classList.add('download-action-icon'); icon.innerHTML = paths[action];
|
||||
const text = doc.createElement('span'); text.textContent = label;
|
||||
button.append(icon, text); return button;
|
||||
}
|
||||
const api = { create };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api;
|
||||
})(globalThis);
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions;
|
||||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||
|
||||
const bytes = n => {
|
||||
@@ -222,10 +223,7 @@
|
||||
acts.className = 'dl-actions';
|
||||
|
||||
if (isActive) {
|
||||
const pauseBtn = doc.createElement('button');
|
||||
pauseBtn.type = 'button';
|
||||
pauseBtn.className = 'dl-act';
|
||||
pauseBtn.textContent = 'Pause';
|
||||
const pauseBtn = actionButtons.create(doc, 'pause', job.title || job.id);
|
||||
pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`);
|
||||
pauseBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -234,10 +232,7 @@
|
||||
};
|
||||
acts.appendChild(pauseBtn);
|
||||
} else if (isPaused) {
|
||||
const resumeBtn = doc.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.className = 'dl-act';
|
||||
resumeBtn.textContent = 'Resume';
|
||||
const resumeBtn = actionButtons.create(doc, 'resume', job.title || job.id);
|
||||
resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`);
|
||||
resumeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -246,10 +241,7 @@
|
||||
};
|
||||
acts.appendChild(resumeBtn);
|
||||
} else if (isFailed) {
|
||||
const retryBtn = doc.createElement('button');
|
||||
retryBtn.type = 'button';
|
||||
retryBtn.className = 'dl-act';
|
||||
retryBtn.textContent = 'Retry';
|
||||
const retryBtn = actionButtons.create(doc, 'retry', job.title || job.id);
|
||||
retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`);
|
||||
retryBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -259,10 +251,7 @@
|
||||
acts.appendChild(retryBtn);
|
||||
}
|
||||
|
||||
const cancelBtn = doc.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'dl-act danger';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
const cancelBtn = actionButtons.create(doc, 'cancel', job.title || job.id);
|
||||
cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`);
|
||||
cancelBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Download controls and device storage accounting. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions;
|
||||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||||
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
|
||||
function breakdown(files, playlists) {
|
||||
@@ -29,7 +30,7 @@
|
||||
const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`);
|
||||
const actions = doc.createElement('div');
|
||||
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
|
||||
const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
|
||||
const button = actionButtons.create(doc, action, job.title || job.id); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
|
||||
}
|
||||
row.append(title, status, progress, actions);
|
||||
if (job.error) errors.appendDetails(row, job.error);
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
<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" />
|
||||
<link rel="stylesheet" href="download-actions.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -662,12 +664,14 @@
|
||||
<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>
|
||||
<script src="direct-protocol.js"></script>
|
||||
<script src="direct-stream.js"></script>
|
||||
<script src="direct-media.js"></script>
|
||||
<script src="download-actions.js"></script>
|
||||
<script src="download-errors.js"></script>
|
||||
<script src="downloads.js"></script>
|
||||
<script src="downloads-page.js"></script>
|
||||
|
||||
11
frontend/related-videos.css
Normal file
11
frontend/related-videos.css
Normal 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; }
|
||||
28
frontend/related-videos.js
Normal file
28
frontend/related-videos.js
Normal 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);
|
||||
18
frontend/related-videos.test.js
Normal file
18
frontend/related-videos.test.js
Normal 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');
|
||||
});
|
||||
@@ -91,9 +91,13 @@ 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',
|
||||
'/download-actions.js',
|
||||
'/download-actions.css',
|
||||
'/download-errors.js',
|
||||
'/downloads.js',
|
||||
'/downloads-page.js',
|
||||
|
||||
2
playwright.download-actions.config.js
Normal file
2
playwright.download-actions.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({ testDir: './tests', testMatch: /download-actions\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8104', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8104 --no-clipboard', port: 8104 } });
|
||||
2
playwright.related.config.js
Normal file
2
playwright.related.config.js
Normal 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 } });
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
65
server/related.js
Normal 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
43
server/related.test.js
Normal 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');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
41
tests/download-actions.spec.js
Normal file
41
tests/download-actions.spec.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openClassic } = require('./helpers/classic-fixture');
|
||||
const { mkdirSync } = require('node:fs');
|
||||
const before = process.env.CAPTURE_BEFORE === '1';
|
||||
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) for (const screen of ['downloads', 'settings']) test(`${layout} ${width} ${screen}: failed download actions`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await openClassic(page);
|
||||
await page.evaluate(({ layout, screen }) => {
|
||||
data.settings.layout = layout; applyAppearance();
|
||||
window.actionCalls = [];
|
||||
Downloads.configure({ slots: () => 4, files: async () => [], playlists: () => [], jobs: () => [{ id: 'aaaaaaaaaaa', title: 'A long worship recording with piano, strings and choir — evening session', channel: 'Studio', thumbnail: '/fixture-cover.svg', status: 'failed', error: 'ERROR: [youtube] Video unavailable' }], action: (id, action) => actionCalls.push([id, action]) });
|
||||
API.cacheList = async () => ({ ok: true, items: [], total: 0 });
|
||||
view = { type: screen }; render();
|
||||
}, { layout, screen });
|
||||
if (screen === 'settings') await page.getByRole('button', { name: /Downloads & storage/ }).click();
|
||||
const retry = page.getByRole('button', { name: /^Retry A long/ });
|
||||
const cancel = page.getByRole('button', { name: /^Cancel A long/ });
|
||||
await expect(retry).toBeVisible(); await expect(cancel).toBeVisible();
|
||||
await retry.scrollIntoViewIfNeeded();
|
||||
if (before) {
|
||||
mkdirSync('/home/josh/deliverables/ytplayer-done12', { recursive: true });
|
||||
await page.screenshot({ path: `/home/josh/deliverables/ytplayer-done12/before-${layout}-${width}-${screen}-${info.project.name}.png` }); return;
|
||||
}
|
||||
for (const button of [retry, cancel]) {
|
||||
await expect(button.locator('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
const rect = await button.boundingBox(); expect(rect.height).toBeGreaterThanOrEqual(44); expect(rect.width).toBeGreaterThanOrEqual(44);
|
||||
await expect(button.locator('span')).toHaveText(button === retry ? 'Retry' : 'Cancel');
|
||||
}
|
||||
const a = await retry.boundingBox(), b = await cancel.boundingBox();
|
||||
expect(a.x + a.width <= b.x || a.y + a.height <= b.y).toBe(true);
|
||||
const visual = await retry.evaluate(el => { const s = getComputedStyle(el), probe = document.createElement('span'); probe.style.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue(document.documentElement.dataset.layout === 'glass-stage' ? '--tile' : '--bg-2').trim(); document.body.append(probe); const expected = getComputedStyle(probe).backgroundColor; probe.remove(); return { radius: s.borderRadius, background: s.backgroundColor, expected }; });
|
||||
expect(visual.radius).toBe(layout === 'glass-stage' ? '18px' : '8px');
|
||||
expect(visual.background).toBe(visual.expected);
|
||||
await page.keyboard.press('Tab'); await retry.focus(); expect(await retry.evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
|
||||
mkdirSync('/home/josh/deliverables/ytplayer-done12', { recursive: true });
|
||||
await page.screenshot({ path: `/home/josh/deliverables/ytplayer-done12/after-${layout}-${width}-${screen}-${info.project.name}.png` });
|
||||
await retry.click(); expect(await page.evaluate(() => actionCalls)).toContainEqual(['aaaaaaaaaaa', 'retry']);
|
||||
await cancel.click();
|
||||
if (screen === 'downloads') await page.locator('.modal').getByRole('button', { name: 'Cancel save', exact: true }).click();
|
||||
expect(await page.evaluate(() => actionCalls)).toContainEqual(['aaaaaaaaaaa', 'cancel']);
|
||||
});
|
||||
19
tests/related-videos.spec.js
Normal file
19
tests/related-videos.spec.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user