Files
ytplayer/server/innertube.js

146 lines
7.3 KiB
JavaScript

/* innertube.js — YouTube search via the InnerTube JSON API (no yt-dlp spawn).
* parseSearch() is pure (tested against fixtures/innertube-search.json);
* search() does the HTTP call. Callers MUST fall back to yt-dlp on any throw. */
const CLIENT = { clientName: 'WEB', clientVersion: '2.20250101.00.00', hl: 'en', gl: 'US' };
export function lengthToSeconds(s) {
if (typeof s !== 'string' || !/^\d+(:\d{1,2}){0,2}$/.test(s.trim())) return 0;
return s.trim().split(':').map(Number).reduce((acc, n) => acc * 60 + n, 0);
}
// Cards from a list of itemSectionRenderer-style sections.
function cardsFromSections(sections) {
const out = [];
for (const s of sections) {
for (const it of s?.itemSectionRenderer?.contents || []) {
const v = it && it.videoRenderer;
if (!v || typeof v.videoId !== 'string') continue;
const owner = v.ownerText?.runs?.[0] || {};
const be = owner.navigationEndpoint?.browseEndpoint || {};
const path = be.canonicalBaseUrl || (be.browseId ? `/channel/${be.browseId}` : '');
out.push({
id: v.videoId,
title: v.title?.runs?.map((r) => r.text).join('') || '(untitled)',
channel: owner.text || '',
channelId: be.browseId || '',
channelUrl: path ? `https://www.youtube.com${path}` : '',
duration: lengthToSeconds(v.lengthText?.simpleText),
thumbnail: `https://i.ytimg.com/vi/${v.videoId}/mqdefault.jpg`,
});
}
}
return out;
}
const tokenOf = (sections) => {
for (const s of sections || []) {
const t = s?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
if (t) return t;
}
return '';
};
export function parseSearch(json) {
const sections = json?.contents?.twoColumnSearchResultsRenderer?.primaryContents
?.sectionListRenderer?.contents;
if (!Array.isArray(sections)) throw new Error('innertube: unexpected response shape');
return cardsFromSections(sections);
}
// First page plus the token for the next one ('' when there is none).
export function parseSearchPage(json) {
const sections = json?.contents?.twoColumnSearchResultsRenderer?.primaryContents
?.sectionListRenderer?.contents;
if (!Array.isArray(sections)) throw new Error('innertube: unexpected response shape');
return { cards: cardsFromSections(sections), next: tokenOf(sections) };
}
// A continuation response: more cards plus the next token.
export function parseContinuation(json) {
const items = (json?.onResponseReceivedCommands || [])
.flatMap((c) => c?.appendContinuationItemsAction?.continuationItems || []);
if (!Array.isArray(items) || !items.length) return { cards: [], next: '' };
return { cards: cardsFromSections(items), next: tokenOf(items) };
}
export async function search(q, { fetchImpl = fetch, timeoutMs = 6000 } = {}) {
const res = await fetchImpl('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: { client: CLIENT }, query: q }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`innertube: HTTP ${res.status}`);
return parseSearch(await res.json());
}
// Up to `limit` results (default 200) by following continuation tokens. The pages
// are a chain, so they are fetched one after another; every page is optional —
// a failure part-way returns what was gathered. `onPage(cards)` lets a caller
// save progress.
export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs = 6000, maxPages = 14, onPage } = {}) {
const post = async (body) => {
const res = await fetchImpl('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: { client: CLIENT }, ...body }), signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`innertube: HTTP ${res.status}`);
return res.json();
};
const first = parseSearchPage(await post({ query: q }));
const seen = new Set();
const all = [];
const add = (cards) => { for (const c of cards) if (!seen.has(c.id)) { seen.add(c.id); all.push(c); } };
add(first.cards);
if (onPage) onPage(all.slice());
let next = first.next;
for (let i = 0; i < maxPages && next && all.length < limit; i++) {
let page;
try { page = parseContinuation(await post({ continuation: next })); } catch { break; }
if (!page.cards.length) break;
add(page.cards);
if (onPage) onPage(all.slice());
next = page.next;
}
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());
}