// Runs in page context. // Fetches all playlist videos using YouTube's browse API from page context // (includes cookies/auth via same-origin credentials). // Replicates YouTube's internal request format including auth headers. // // Injected once and persists across SPA navigations. // - Initial load: reads window.ytInitialData // - SPA navigation: fetches playlist data via browse API (ytInitialData is stale) (() => { const LOG = "[yt-playlist-features]"; const w = window as any; // Prevent double-injection if (w.__yt_playlist_ext_injected) return; w.__yt_playlist_ext_injected = true; let extractingUrl: string | null = null; let isInitialLoad = true; let collabCache: Map = new Map(); let collabCachePlaylistId: string | null = null; function runExtraction() { if (!isPlaylistUrl()) return; const url = window.location.href; if (url === extractingUrl) return; extractingUrl = url; extractAndSend(); } // Run extraction for the current page if it's a playlist runExtraction(); // Listen for SPA navigation document.addEventListener("yt-navigate-finish", runExtraction); // Listen for explicit trigger from content script document.addEventListener("__yt_playlist_ext_trigger", runExtraction); // Reset on navigation away document.addEventListener("yt-navigate-start", () => { extractingUrl = null; }); function isPlaylistUrl(): boolean { const url = new URL(window.location.href); return url.pathname === "/playlist" && url.searchParams.has("list"); } function getPlaylistId(): string | null { const url = new URL(window.location.href); return url.searchParams.get("list"); } function getConfig() { const cfg = w.ytcfg?.data_ ?? {}; return { cfg, apiKey: cfg.INNERTUBE_API_KEY ?? "", baseContext: cfg.INNERTUBE_CONTEXT ?? null, }; } async function extractAndSend(): Promise { const { cfg, apiKey, baseContext } = getConfig(); const authHeaders = buildAuthHeaders(cfg, baseContext); // Try ytInitialData only on initial page load. // On SPA navigation ytInitialData is stale (still holds the previous page's data), // so we must always fetch fresh data via the browse API. let initialData = isInitialLoad ? w.ytInitialData : null; let videoList = initialData ? findVideoListContents(initialData) : null; isInitialLoad = false; if (!videoList) { const playlistId = getPlaylistId(); if (!playlistId) { sendResult({ error: "no-playlist-id" }); return; } console.log(LOG, "ytInitialData stale, fetching via browse API"); const browseData = await fetchBrowseData( `VL${playlistId}`, baseContext, authHeaders, ); if (!browseData) { sendResult({ error: "browse-fetch-failed" }); return; } initialData = browseData; videoList = findVideoListContents(browseData); if (!videoList) { sendResult({ error: "no-video-list" }); return; } } const allRenderers: any[] = []; let continuation: string | null = null; let continuationClickTracking: string | null = null; for (const item of videoList) { if (item.playlistVideoRenderer) { allRenderers.push(item.playlistVideoRenderer); } if (item.continuationItemRenderer) { const extracted = extractContinuationInfo(item.continuationItemRenderer); continuation = extracted.token; continuationClickTracking = extracted.clickTracking; } } // Fetch remaining pages let page = 1; while (continuation && page < 50) { try { const body = buildRequestBody(baseContext, continuation, continuationClickTracking); const res = await fetch( `https://www.youtube.com/youtubei/v1/browse?prettyPrint=false`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders, }, credentials: "same-origin", body: JSON.stringify(body), }, ); if (!res.ok) break; const data = await res.json(); continuation = null; continuationClickTracking = null; for (const action of data.onResponseReceivedActions ?? []) { const items = action?.appendContinuationItemsAction?.continuationItems; if (!items) continue; for (const item of items) { if (item.playlistVideoRenderer) { allRenderers.push(item.playlistVideoRenderer); } if (item.continuationItemRenderer) { const extracted = extractContinuationInfo(item.continuationItemRenderer); continuation = extracted.token; continuationClickTracking = extracted.clickTracking; } } } page++; } catch (e) { console.error(LOG, `Continuation page ${page} failed:`, e); break; } } // Fetch collaborator names for collaborative playlists const avatarToName = await fetchCollaboratorMap(initialData, cfg, baseContext, authHeaders); const videos = allRenderers.map((d, i) => ({ videoId: d.videoId, title: textOf(d.title), index: i, lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null, lengthText: textOf(d.lengthText), thumbnails: d.thumbnail?.thumbnails ?? [], shortBylineText: d.shortBylineText, isPlayable: d.isPlayable !== false, badges: d.badges ?? [], voteCount: typeof d.voteCount === "number" ? d.voteCount : null, addedBy: resolveAddedBy(d, avatarToName), })); console.log(LOG, `Extraction: ${videos.length} videos (${page - 1} continuation pages)`); sendResult({ videos, initialData, innertubeApiKey: apiKey, innertubeContext: baseContext, }); } // --- helpers --- function normalizeAvatarUrl(url: string): string { // Strip size parameter to make URLs comparable (e.g. =s48 vs =s176) return url.replace(/=s\d+/, "=s0"); } function extractCollabParams(initialData: any): string | null { const vm = initialData?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel; const rows = vm?.metadata?.contentMetadataViewModel?.metadataRows; if (!rows) return null; for (const row of rows) { for (const part of row.metadataParts ?? []) { const cmd = part?.avatarStack?.avatarStackViewModel?.rendererContext ?.commandContext?.onTap?.innertubeCommand; const params = cmd?.showEngagementPanelEndpoint?.globalConfiguration?.params; if (params) return params; } } return null; } async function fetchCollaboratorMap( initialData: any, cfg: any, baseContext: any, authHeaders: Record, ): Promise> { const params = extractCollabParams(initialData); if (!params) return new Map(); // Return cached map if same playlist const playlistId = new URL(window.location.href).searchParams.get("list"); if (playlistId && playlistId === collabCachePlaylistId && collabCache.size > 0) { return collabCache; } const map = new Map(); try { const res = await fetch( "https://www.youtube.com/youtubei/v1/get_panel?prettyPrint=false", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders, "X-Origin": window.location.origin, "X-Youtube-Bootstrap-Logged-In": "true", "X-Youtube-Client-Name": String(cfg.INNERTUBE_CONTEXT_CLIENT_NAME ?? "1"), "X-Youtube-Client-Version": cfg.INNERTUBE_CLIENT_VERSION ?? "", }, credentials: "same-origin", body: JSON.stringify({ context: baseContext, panelId: "PAplaylist_collaborate", params, }), }, ); if (!res.ok) return map; const data = await res.json(); const collaborators = data?.content?.engagementPanelSectionListRenderer?.content ?.playlistCollaborationViewModel?.playlistCollaborators ?? []; for (const c of collaborators) { const item = c.contentListItemViewModel; if (!item) continue; const name = item.title?.content ?? ""; const avatarUrl = item.avatar?.avatarViewModel?.image?.sources?.[0]?.url; if (name && avatarUrl) { map.set(normalizeAvatarUrl(avatarUrl), name); } } console.log(LOG, `Fetched ${map.size} collaborators`); collabCache = map; collabCachePlaylistId = playlistId; } catch (e) { console.error(LOG, "Failed to fetch collaborators:", e); } return map; } function resolveAddedBy(renderer: any, avatarToName: Map): string | null { if (avatarToName.size === 0) return null; const overlays = renderer.thumbnailOverlays ?? []; for (const o of overlays) { const avatars = o.thumbnailOverlayAvatarStackViewModel?.avatarStack ?.avatarStackViewModel?.avatars; if (!avatars) continue; for (const a of avatars) { const url = a.avatarViewModel?.image?.sources?.[0]?.url; if (url) { const name = avatarToName.get(normalizeAvatarUrl(url)); if (name) return name; } } } return null; } async function fetchBrowseData( browseId: string, baseContext: any, authHeaders: Record, ): Promise { try { const body = { context: baseContext ? JSON.parse(JSON.stringify(baseContext)) : {}, browseId, }; const res = await fetch( `https://www.youtube.com/youtubei/v1/browse?prettyPrint=false`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders, }, credentials: "same-origin", body: JSON.stringify(body), }, ); if (!res.ok) return null; return await res.json(); } catch (e) { console.error(LOG, "Browse fetch failed:", e); return null; } } function buildAuthHeaders(cfg: any, baseContext: any): Record { const headers: Record = {}; // SAPISIDHASH authorization const sapisid = getCookie("SAPISID") || getCookie("__Secure-3PAPISID"); if (sapisid) { const timestamp = Math.floor(Date.now() / 1000); const origin = window.location.origin; // SHA-1 hash of "timestamp SAPISID origin" headers["Authorization"] = `SAPISIDHASH ${timestamp}_${sha1(`${timestamp} ${sapisid} ${origin}`)}`; } // Auth user const authUser = cfg.SESSION_INDEX ?? "0"; headers["X-Goog-AuthUser"] = String(authUser); // Visitor ID const visitorData = baseContext?.client?.visitorData; if (visitorData) { headers["X-Goog-Visitor-Id"] = visitorData; } return headers; } function getCookie(name: string): string | null { const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : null; } function sha1(str: string): string { const utf8 = new TextEncoder().encode(str); const len = utf8.length; // Pre-processing const bitLen = len * 8; const padLen = (((len + 8) >> 6) + 1) << 6; const padded = new Uint8Array(padLen); padded.set(utf8); padded[len] = 0x80; // Length in bits as big-endian 64-bit const view = new DataView(padded.buffer); view.setUint32(padLen - 4, bitLen, false); let h0 = 0x67452301; let h1 = 0xefcdab89; let h2 = 0x98badcfe; let h3 = 0x10325476; let h4 = 0xc3d2e1f0; const w = new Uint32Array(80); for (let offset = 0; offset < padLen; offset += 64) { for (let i = 0; i < 16; i++) { w[i] = view.getUint32(offset + i * 4, false); } for (let i = 16; i < 80; i++) { const x = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]; w[i] = (x << 1) | (x >>> 31); } let a = h0, b = h1, c = h2, d = h3, e = h4; for (let i = 0; i < 80; i++) { let f: number, k: number; if (i < 20) { f = (b & c) | (~b & d); k = 0x5a827999; } else if (i < 40) { f = b ^ c ^ d; k = 0x6ed9eba1; } else if (i < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8f1bbcdc; } else { f = b ^ c ^ d; k = 0xca62c1d6; } const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[i]) | 0; e = d; d = c; c = (b << 30) | (b >>> 2); b = a; a = temp; } h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0; h4 = (h4 + e) | 0; } return [h0, h1, h2, h3, h4] .map((v) => (v >>> 0).toString(16).padStart(8, "0")) .join(""); } function buildRequestBody( ctx: any, token: string, clickTracking: string | null, ): any { const enrichedContext = JSON.parse(JSON.stringify(ctx)); // Use continuation endpoint's clickTrackingParams if (clickTracking) { enrichedContext.clickTracking = { clickTrackingParams: clickTracking }; } return { context: enrichedContext, continuation: token }; } function findVideoListContents(raw: any): any[] | null { const tabs = raw?.contents?.twoColumnBrowseResultsRenderer?.tabs; if (!tabs?.length) return null; const tabContent = tabs[0]?.tabRenderer?.content; const section = tabContent?.sectionListRenderer?.contents; if (!section?.length) return null; const items = section[0]?.itemSectionRenderer?.contents; if (!items?.length) return null; return items[0]?.playlistVideoListRenderer?.contents ?? null; } function extractContinuationInfo(cir: any): { token: string | null; clickTracking: string | null } { const ep = cir?.continuationEndpoint; if (!ep) return { token: null, clickTracking: null }; const clickTracking = ep.clickTrackingParams ?? null; if (ep.continuationCommand?.token) { return { token: ep.continuationCommand.token, clickTracking }; } const cmds = ep.commandExecutorCommand?.commands; if (cmds) { for (const cmd of cmds) { if (cmd.continuationCommand?.token) { return { token: cmd.continuationCommand.token, clickTracking }; } } } return { token: deepFindToken(ep), clickTracking }; } function deepFindToken(obj: any, depth = 0): string | null { if (!obj || typeof obj !== "object" || depth > 6) return null; if (typeof obj.token === "string" && obj.token.length > 10) return obj.token; for (const k of Object.keys(obj)) { const r = deepFindToken(obj[k], depth + 1); if (r) return r; } return null; } function textOf(obj: any): string { if (!obj) return ""; if (typeof obj === "string") return obj; if (obj.simpleText) return obj.simpleText; if (obj.runs) return obj.runs.map((r: any) => r.text).join(""); return ""; } function sendResult(payload: any) { document.dispatchEvent( new CustomEvent("__yt_playlist_ext_data", { detail: JSON.stringify(payload), }), ); } })();