493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
// 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<string, string> = 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<void> {
|
|
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<string, string>,
|
|
): Promise<Map<string, string>> {
|
|
const params = extractCollabParams(initialData);
|
|
if (!params) return new Map<string, string>();
|
|
|
|
// 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<string, string>();
|
|
|
|
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, string>): 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<string, string>,
|
|
): Promise<any | null> {
|
|
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<string, string> {
|
|
const headers: Record<string, string> = {};
|
|
|
|
// 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),
|
|
}),
|
|
);
|
|
}
|
|
})();
|