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