Recover related videos with bounded discovery and retryable fallbacks
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user