/* 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); } export function parseSearch(json) { const sections = json?.contents?.twoColumnSearchResultsRenderer?.primaryContents ?.sectionListRenderer?.contents; if (!Array.isArray(sections)) throw new Error('innertube: unexpected response shape'); 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; } 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()); }