Skip to content
Snippets Groups Projects
YoutubeScript.js 171 KiB
Newer Older
Koen's avatar
Koen committed
		}
	}
	return url;
}

source.getComments = (url) => {
Kelvin's avatar
Kelvin committed
    url = convertIfOtherUrl(url);
	const useLogin = (!!_settings?.authDetails) && bridge.isLoggedIn();
	if(useLogin)
		log("USING AUTH FOR COMMENTS");
	if(useLogin && url.indexOf("/www.") >= 0)
		url = url.replace("www", "m");
Koen's avatar
Koen committed

	//const html = requestPage(url, {}, useLogin);
	const initialData = requestInitialData(url, useLogin, useLogin);
Koen's avatar
Koen committed
	const contents = initialData.contents;
	const result = contents.twoColumnWatchNextResults?.results?.results?.contents ??
		contents.singleColumnWatchNextResults?.results?.results?.contents ??
Koen's avatar
Koen committed
		null;	//Add any alternative containers
	if(!result)
		return new CommentPager([], false);
	const engagementPanels = initialData.engagementPanels ?? [];
	return extractTwoColumnWatchNextResultContents_CommentsPager(url, result, useLogin, engagementPanels);
Koen's avatar
Koen committed
};
source.getSubComments = (comment) => {
	if(typeof comment === 'string')
		comment = JSON.parse(comment);
	if(comment.context?.replyContinuation) {
		return requestCommentPager(comment.contextUrl, comment.context.replyContinuation, comment?.context?.useLogin == 'true', comment?.context?.useMobile == 'true');
Koen's avatar
Koen committed
	}
	else
		return new CommentPager([], false);
};

Kelvin's avatar
Kelvin committed
source.getContentRecommendations = (url, initialData) => {
Koen J's avatar
Koen J committed
	const useAuth = !!_settings?.authDetails;
Kelvin's avatar
Kelvin committed
	url = convertIfOtherUrl(url);

	if(!initialData) {
		const videoId = extractVideoIDFromUrl(url);
		if(IS_TESTING)
			console.log("VideoID:", videoId);

		const useLogin = useAuth && bridge.isLoggedIn();

		const headersUsed = (useLogin) ? getAuthContextHeaders(false) : {};
		headersUsed["Accept-Language"] = "en-US";
		headersUsed["Cookie"] = "PREF=hl=en&gl=US"
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		const resp = http.GET(url, headersUsed, useLogin);

		throwIfCaptcha(resp);
		if(!resp.isOk) {
			throw new ScriptException("Failed to request page [" + resp.code + "]");
		}

		const html = resp.body;//requestPage(url);
		initialData = getInitialData(html);
	}

	const contents = initialData.contents;
	let watchNextFeed = contents.twoColumnWatchNextResults?.secondaryResults?.secondaryResults ?? null;
	//log("Recommendations twoColumnWatchNextResults: " + !!contents.twoColumnWatchNextResults);
Kelvin's avatar
Kelvin committed
	if(!watchNextFeed) 
		return new VideoPager([], false);
	//log("Recommendations watchNextFeed: " + !!watchNextFeed + "\n" + JSON.stringify(watchNextFeed));
	const originalItems = watchNextFeed.results;
Kelvin's avatar
Kelvin committed
	if(watchNextFeed.targetId != 'watch-next-feed' && watchNextFeed.results)
		watchNextFeed = watchNextFeed.results.find(x=>x.targetId == 'watch-next-feed' || x.itemSectionRenderer?.targetId == 'watch-next-feed');
	if(!watchNextFeed) {
		log("No videos found?\n" + originalItems.map(x=>JSON.stringify(x)).join("\n\n"));
Kelvin's avatar
Kelvin committed
		return new VideoPager([], false);
	}
	if(watchNextFeed.itemSectionRenderer?.targetId == 'watch-next-feed') {
		log("Recommendations in sub-section renderer");
		watchNextFeed = watchNextFeed.itemSectionRenderer;
	}
Kelvin's avatar
Kelvin committed
	
	const itemSectionRenderer = extractItemSectionRenderer_Shelves(watchNextFeed);

	//TODO: pages
	return new VideoPager(itemSectionRenderer?.videos ?? [], false);
};
Koen's avatar
Koen committed

//Channel
source.isChannelUrl = (url) => {
	return REGEX_VIDEO_CHANNEL_URL.test(url) || 
		REGEX_VIDEO_CHANNEL_URL2.test(url) || 
		REGEX_VIDEO_CHANNEL_URL3.test(url) ||
		REGEX_VIDEO_CHANNEL_URL4.test(url);
Koen's avatar
Koen committed
};
source.getChannel = (url) => {
	const initialData = requestInitialData(url);
	if(!initialData)
	    throw new ScriptException("No channel data found for: " + url);
Koen's avatar
Koen committed
	return extractChannel_PlatformChannel(initialData, url);
};

source.getChannelCapabilities = () => {
	return {
		types: [Type.Feed.Videos, Type.Feed.Streams],
		sorts: [Type.Order.Chronological, "Popular"]
	};
}
function filterChannelUrl(url) {
	url = removeQuery(url);
	//Filter out known suffixes..prob need something better
	const channelSuffixes = [
		"featured",
		"videos",
		"shorts",
		"streams",
		"podcasts",
		"playlists",
		"community"
	];
	for(let suffix of channelSuffixes) {
		if(url.endsWith("/" + suffix)) {
			url = url.substring(0, url.length - suffix.length + 1);
			break;
		}
	}
	return url;
}
Koen's avatar
Koen committed
source.getChannelContents = (url, type, order, filters) => {
	let targetTab = null;
Koen's avatar
Koen committed

	switch(type) {
		case undefined:
		case null:
Koen's avatar
Koen committed
		case Type.Feed.Videos:
			targetTab = "Videos";
			url = url + URL_CHANNEL_VIDEOS;
			break;
		case Type.Feed.Streams:
			targetTab = "Live";
			url = url + URL_CHANNEL_STREAMS;
			break;
		case Type.Feed.Live:
			targetTab = "Home";
			url = url;
			break;
		default:
			throw new ScriptException("Unsupported type: " + type);
	}

	const useAuth = bridge.isLoggedIn() && !!_settings?.authChannels;
	if(useAuth)
		log("USING AUTH FOR CHANNEL");

	const initialData = requestInitialData(url, useAuth, useAuth);
	if(!initialData)
	    throw new ScriptException("No channel data found for: " + url);
Koen's avatar
Koen committed
	const channel = extractChannel_PlatformChannel(initialData, url);
	const contextData = {
Kelvin's avatar
Kelvin committed
		authorLink: new PlatformAuthorLink(new PlatformID(PLATFORM, channel.id.value, config.id, PLATFORM_CLAIMTYPE), channel.name, channel.url, channel.thumbnail)
Koen's avatar
Koen committed
	};
	const tabs = extractPage_Tabs(initialData, contextData);
	
	const tab = tabs.find(x=>x.title == targetTab);
	if(!tab) 
		return new VideoPager([], false);
	if(IS_TESTING)
		console.log("Tab", tab);

	if(type == Type.Feed.Live) {
		if(tab.shelves) {
			const featured = tab.shelves.find(x=>x?.name == "Featured");
			if(featured && featured.videos && featured.videos.length > 0) {
				const live = featured.videos.find(x=>x.isLive);
				if(live)
					return new VideoPager([live], false);
			}
		}
		return new VideoPager([], false);
	}
	//throw new ScriptException("Could not find tab: " + targetTab);

	return new RichGridPager(tab, contextData, useAuth, useAuth);
Koen's avatar
Koen committed
};
Kelvin's avatar
Kelvin committed
source.getChannelPlaylists = (url) => {

	const targetTab = "Playlists";
	const useAuth = bridge.isLoggedIn() && !!_settings?.authChannels;
	if(useAuth)
		log("USING AUTH FOR CHANNEL");

	const initialData = requestInitialData(url + URL_CHANNEL_PLAYLISTS, useAuth, useAuth);
	if(!initialData)
	    throw new ScriptException("No channel data found for: " + url);
	const channel = extractChannel_PlatformChannel(initialData, url);
	const contextData = {
		authorLink: new PlatformAuthorLink(new PlatformID(PLATFORM, channel.id.value, config.id, PLATFORM_CLAIMTYPE), channel.name, channel.url, channel.thumbnail)
	};
	const tabs = extractPage_Tabs(initialData, contextData);
	
	const tab = tabs.find(x=>x.title == targetTab);
	if(!tab) 
		return new PlaylistPager([], false);

	return new RichGridPlaylistPager(tab, contextData, useAuth, useAuth);
}

source.getPeekChannelTypes = () => {
	return [Type.Feed.Videos, Type.Feed.Mixed];
}
source.peekChannelContents = function(url, type) {
    if(type != Type.Feed.Mixed && type != Type.Feed.Videos)
        return [];

    const match = url.match(REGEX_VIDEO_CHANNEL_URL);
    if(!match || match.length != 3)
        return {};
    const id = removeQuery(match[2]);
    if(!id)
        return {};
    const rssUrl = URL_YOUTUBE_RSS + id;

    const xmlResp = http.GET(rssUrl, {});

    if(!xmlResp.isOk)
        return null;

    const xml = domParser.parseFromString(xmlResp.body).querySelector("feed");
    const xmlTree = JSON.parse(xml.toNodeTreeJson())?.children;
    console.log(xmlTree);
    if(!xmlTree || xmlTree.length <= 0)
        return {};
    const authorNode = xmlTree?.find(x=>x.name == "author");
    const entryNodes = xmlTree?.filter(x=>x.name == "entry") ?? [];
    const videos = [];

    const author = new PlatformAuthorLink(
        new PlatformID(PLATFORM, null, id, PLATFORM_CLAIMTYPE),
        authorNode.children.find(x=>x.name == "name").value,
        authorNode.children.find(x=>x.name == "uri").value,
        ""
    )

    for(let entry of entryNodes) {
        const group = entry.children.find(x=>x.name == 'media:group');
        const community = group.children.find(x=>x.name == "media:community");

        videos.push(new PlatformVideo({
			id: new PlatformID(PLATFORM, entry.children.find(x=>x.name == 'yt:videoid').value, config.id),
			name: escapeUnicode(group.children.find(x=>x.name == 'media:title').value),
			thumbnails: new Thumbnails([
			    new Thumbnail(group.children.find(x=>x.name == 'media:thumbnail')?.attributes["url"], 1)
			]),
			author: author,
			uploadDate: parseInt(new Date(entry.children.find(x=>x.name == "updated").value).getTime() / 1000),
			duration: 0,
			viewCount: parseInt(community.children.find(x=>x.name == "media:statistics").attributes["views"]) ?? 0,
			url: entry.children.find(x=>x.name == "link").attributes["href"],
			isLive: false
		}));
    }

    return videos;
};
Koen's avatar
Koen committed

source.searchPlaylists = function(query, type, order, filters) {
    const data = requestSearch(query, false, SEARCH_PLAYLISTS_PARAM);
    if(IS_TESTING)
        console.log("Search data:", data);
    const searchResults = extractSearch_SearchResults(data);

    if(searchResults.playlists)
        return new SearchItemSectionPlaylistPager(searchResults);
    return new PlaylistPager([]);
};
source.isPlaylistUrl = function(url) {
    return REGEX_VIDEO_PLAYLIST_URL.test(url);
};
source.getPlaylist = function (url) {
	log(`Getting playlist: ${url}`);

	const initialData = requestInitialData(url, USE_MOBILE_PAGES, true);
	const contents = initialData.contents;

	if(IS_TESTING)
	    console.log("Initial data", initialData);

    const playlistHeaderRenderer = initialData?.header?.playlistHeaderRenderer;
    if(!playlistHeaderRenderer) {
        throw new ScriptException("No playlist header found");
Koen's avatar
Koen committed
        return null;
    }

	if(IS_TESTING)
	    console.log("initialData", initialData);

    const renderer = initialData?.contents?.singleColumnBrowseResultsRenderer ?? initialData?.contents?.twoColumnBrowseResultsRenderer;
    if(renderer) {
        if(!renderer.tabs) {
            throw new ScriptException("No tabs found");
Koen's avatar
Koen committed
            return null;
        }
        const tab = renderer.tabs[0];
        const tabRenderer = tab.tabRenderer;
        const playlistList = findRenderer(tab, "playlistVideoListRenderer");
        if(!playlistList || !playlistList.contents) {
            throw new ScriptException("playlistVideoListRenderer not found");
Koen's avatar
Koen committed
            return null;
		}
		
        const id = playlistHeaderRenderer.playlistId;
		const title = extractText_String(playlistHeaderRenderer.title);
		const videos = [];
		let continuationToken = null;
        for(let playlistRenderer of playlistList.contents) {
            switchKey(playlistRenderer, {
                playlistVideoRenderer(renderer) {
                    const video = extractPlaylistVideoRenderer_Video(renderer);
                    if(video)
                        videos.push(video);
				},
				continuationItemRenderer(continueRenderer) {
					continuationToken = continueRenderer?.continuationEndpoint?.continuationCommand?.token;
				}
            });
		}

		//Fallback for old apps
Kelvin's avatar
Kelvin committed
		if(!bridge.buildVersion || bridge.buildVersion < 245) {
			log("Using legacy remote playlist (all videos first page)");
			while (continuationToken) {
				const newData = validateContinuation(()=>requestBrowse({
					continuation: continuationToken
				}, USE_MOBILE_PAGES, true));

				if (newData.length < 1) {
					break;
				}
Koen's avatar
Koen committed

				continuationToken = null;
				for(let playlistRenderer of newData) {
					switchKey(playlistRenderer, {
						playlistVideoRenderer(renderer) {
							const video = extractPlaylistVideoRenderer_Video(renderer);
							if(video)
								videos.push(video);
						},
						continuationItemRenderer(continueRenderer) {
							continuationToken = continueRenderer?.continuationEndpoint?.continuationCommand?.token;
						}
					});
				}
Koen's avatar
Koen committed
			}
Koen's avatar
Koen committed

		let thumbnail = null;
		if(videos && videos.length > 0 && 
			videos[0].thumbnails?.sources && 
			videos[0].thumbnails.sources.length > 0)
			thumbnail = videos[0].thumbnails.sources[videos[0].thumbnails.sources.length - 1].url;

		let author = extractRuns_AuthorLink(playlistHeaderRenderer?.ownerText?.runs);
		if(!author && videos && videos.length > 0 && videos.filter(x=>x.author.url != videos[0].author.url).length == 0) {
			//Assume author = video owner if all videos by same & author null
			author = videos[0].author;
		}
Koen's avatar
Koen committed
		
        return new PlatformPlaylistDetails({
            url: url,
			id: new PlatformID(PLATFORM, playlistHeaderRenderer?.playlistId, config.id),
Koen's avatar
Koen committed
            name: title,
            thumbnail: thumbnail,
Kelvin's avatar
Kelvin committed
            videoCount: extractFirstNumber_Integer(extractText_String(playlistHeaderRenderer?.numVideosText)),
            contents: new PlaylistVideoPager(videos, continuationToken)
Koen's avatar
Koen committed
        });
    }
	else
		throw new ScriptException("No playlist renderer found?");
Koen's avatar
Koen committed
    return null;
};
Kelvin's avatar
Kelvin committed

class PlaylistVideoPager extends VideoPager {
	constructor(videos, continuation, useMobile = false, useAuth = false) {
		super(videos, !!continuation);
		this.continuation = continuation;
		this.useMobile = useMobile;
		this.useAuth = useAuth;
	}
	
	nextPage() {
		if(!this.continuation) {
			this.hasMore = false;
			this.results = [];
			return this;
		}
		const newData = validateContinuation(()=>requestBrowse({
			continuation: this.continuation
		}, USE_MOBILE_PAGES, true));

		if (newData.length < 1) {
			this.hasMore = false;
			this.results = [];
			return this;
		}
		this.continuation = null;
		let me = this;
		let videos = [];
		for(let playlistRenderer of newData) {
			switchKey(playlistRenderer, {
				playlistVideoRenderer(renderer) {
					const video = extractPlaylistVideoRenderer_Video(renderer);
					if(video)
						videos.push(video);
				},
				continuationItemRenderer(continueRenderer) {
					me.continuation = continueRenderer?.continuationEndpoint?.continuationCommand?.token;
				}
			});
		}
		this.results = videos;
		this.hasMore = !!this.continuation;
		return this;
	}
}
Koen's avatar
Koen committed
source.getUserPlaylists = function() {
	if (!bridge.isLoggedIn()) {
		bridge.log("Failed to retrieve subscriptions page because not logged in.");
		return [];
	}

	let subsPage = requestPage(URL_PLAYLISTS_M, { "User-Agent": USER_AGENT_PHONE }, true);
	let result = getInitialData(subsPage);
	if(IS_TESTING)
	    console.log("Initial Data", result);

    return switchKey(result.contents, {
        singleColumnBrowseResultsRenderer(renderer) {
			if(!renderer.tabs || renderer.tabs <= 0)
				return [];
			const tab = renderer.tabs[0];
			const sections = tab?.tabRenderer?.content?.sectionListRenderer?.contents;
			if(!sections) {
				log("No sections found on library page");
				return [];
			}

            let playlistsItems = null;

			let playlistShelf = sections.find(x=>x.shelfRenderer && extractText_String(x.shelfRenderer.title) == "Playlists");

			let playlistItemSection = playlistShelf != null ? null : sections.find(x=>
			    x.itemSectionRenderer &&
			    x.itemSectionRenderer.contents && x.itemSectionRenderer.contents.length > 0 &&
			    x.itemSectionRenderer.contents[0].horizontalCardListRenderer &&
			    x.itemSectionRenderer.contents[0].horizontalCardListRenderer.cards &&
			    extractText_String(x.itemSectionRenderer.contents[0].horizontalCardListRenderer.header?.richListHeaderRenderer?.title) == "Playlists");

			if(playlistShelf != null)
                playlistsItems = playlistShelf.shelfRenderer?.content?.verticalListRenderer?.items;
            else if(playlistItemSection != null)
                playlistsItems = playlistItemSection.itemSectionRenderer.contents[0].horizontalCardListRenderer.cards;
            else {
                log("No playlists found");
                return [];
            }

			if(!playlistsItems) {
			    log("No container with playlists found");
			    return [];
			}
			if(IS_TESTING)
			    console.log("Playlist Items:", playlistsItems);

			let playlistUrls = [];
			for(let playlist of playlistsItems) {
			    switchKey(playlist, {
			        compactPlaylistRenderer(renderer) {
                        const playlistUrl = extractNavigationEndpoint_Url(renderer.navigationEndpoint);
                        if(playlistUrl)
                            playlistUrls.push(playlistUrl);
			        },
			        playlistCardRenderer(renderer) {
			            const playlistUrl = extractNavigationEndpoint_Url(renderer.navigationEndpoint);
                        if(playlistUrl)
                            playlistUrls.push(playlistUrl);
			        }
			    });
			}
            return playlistUrls;
        },
        default(name) {
		    log("No submenu found on subscriptions page");
            return [];
        }
    });


	return result;
};

source.getUserSubscriptions = function() {
	if (!bridge.isLoggedIn()) {
		bridge.log("Failed to retrieve subscriptions page because not logged in.");
		throw new ScriptException("Not logged in");
Koen's avatar
Koen committed
	}
	
	let subsPage = requestPage(URL_SUB_CHANNELS_M, { "User-Agent": USER_AGENT_PHONE }, true);
	let result = getInitialData(subsPage);

	if(!result) {
	    log(subsPage);
		throw new ScriptException("No initial data found or page unavailable");
    }
Koen's avatar
Koen committed
	if(IS_TESTING) {
		console.log("Initial Data:", result);
	}

	return switchKey(result.contents, {
		singleColumnBrowseResultsRenderer(renderer) {
			if(!renderer.tabs || renderer.tabs <= 0)
				return [];
			let tab = renderer.tabs[0];
			let sectionListRenderer = tab?.tabRenderer?.content?.sectionListRenderer;
			let subMenu = sectionListRenderer?.subMenu;
			if(sectionListRenderer?.targetId == "browse-feedFEchannels") {
				const sectionContents = sectionListRenderer?.contents;
				const itemContents = sectionContents ? sectionContents[0].itemSectionRenderer?.contents : null;
				if(!itemContents || itemContents[0].channelListItemRenderer) {
					let subs = [];
					for(let item of itemContents) {
						switchKey(item, {
							channelListItemRenderer(itemRenderer) {
								const authorUrl = extractNavigationEndpoint_Url(itemRenderer.navigationEndpoint);
								if(authorUrl)
									subs.push(authorUrl);
							}
						});
					}
					return subs;
				}
			}
			if(!subMenu) {
				bridge.log("No subscriptions found..");
				return [];
			}
			return switchKey(subMenu, {
				channelListSubMenuRenderer(menuRenderer) {
					let subs = [];
					if(menuRenderer.contents) {
						for(let item of menuRenderer.contents) {
							switchKey(item, {
								channelListSubMenuAvatarRenderer(itemRenderer) {
									const author = extractChannelListSubMenuAvatarRenderer_URL(itemRenderer);
									if(author)
										subs.push(author);
								},
								default(name) { 
									log("Unknown menu item renderer: " + name);
								}
							});
						}
					}
					return subs;
				},
				default(name) {
					log("Unknown menu renderer: " + name);
					return [];
				}
			});
		},
		default() {
			log("Failed to retrieve subscriptions page, wrong items found")
			return [];
		}
	});

	/*
	const data = requestGuide(clientContext.DELEGATED_SESSION_ID);
	const channels = extractGuide_Channels(data);

	if(IS_TESTING) {
		console.log(data);
		console.log(channels);
	}

	return channels.map(x=>x.url); */
}



//#endregion

Kelvin's avatar
Kelvin committed
function throwIfCaptcha(resp) {
    if (resp != null && resp.code === 429 && resp.body != null && resp.body.includes("captcha")) {
        throw new CaptchaRequiredException(resp.url, resp.body);
    }
Kelvin's avatar
Kelvin committed
    return true;
Koen's avatar
Koen committed
function extractVideoIDFromUrl(url) {
	let match = url.match(REGEX_VIDEO_URL_DESKTOP);
	if(match)
		return removeQuery(match[2]);

	match = url.match(REGEX_VIDEO_URL_SHARE);
	if(match)
		return removeQuery(match[1]);

	match = url.match(REGEX_VIDEO_URL_SHARE_LIVE);
	if(match)
		return removeQuery(match[2]);

	match = url.match(REGEX_VIDEO_URL_SHORT);
	if(match)
		return removeQuery(match[2]);

	return null;
}
function removeQuery(urlPart) {
	if(!urlPart)
		return urlPart;
	if(urlPart.indexOf("?") > 0)
		return urlPart.substring(0, urlPart.indexOf("?"));
	else if(urlPart.indexOf("&") > 0)
		return urlPart.substring(0, urlPart.indexOf("&"));
	return urlPart;
}


//#region Objects
class YTVideoSource extends VideoUrlRangeSource {
Kelvin's avatar
Kelvin committed
    constructor(obj, originalUrl) {
Koen's avatar
Koen committed
		super(obj);
Kelvin's avatar
Kelvin committed
		this.originalUrl = originalUrl;
Koen's avatar
Koen committed
    }

    getRequestModifier() {
Kelvin's avatar
Kelvin committed
        return new YTRequestModifier(this.originalUrl);
Koen's avatar
Koen committed
    }
}

class YTAudioSource extends AudioUrlRangeSource {
Kelvin's avatar
Kelvin committed
    constructor(obj, originalUrl) {
Koen's avatar
Koen committed
		super(obj);
Kelvin's avatar
Kelvin committed
		this.originalUrl = originalUrl;
Koen's avatar
Koen committed
    }

    getRequestModifier() {
Kelvin's avatar
Kelvin committed
        return new YTRequestModifier(this.originalUrl);
Koen's avatar
Koen committed
    }
}

class YTRequestModifier extends RequestModifier {
Kelvin's avatar
Kelvin committed
	constructor(originalUrl) {
Koen's avatar
Koen committed
		super({ allowByteSkip: false });
        this.requestNumber = 0;
Kelvin's avatar
Kelvin committed
		this.originalUrl = originalUrl;
		this.newUrl = null;
		this.newUrlCount = 0;
Koen's avatar
Koen committed
    }

	/**
	 * Modifies the request
	 * @param {string} url The URL string used
	 * @param {{[key: string]: string}} headers The headers used
	 * @returns {Request}
	 */
	modifyRequest(url, headers) {
		const u = new URL(url);
Kelvin's avatar
Kelvin committed
		const actualUrl = (this.newUrl) ? new URL(this.newUrl) : u;
Koen's avatar
Koen committed
		const isVideoPlaybackUrl = u.pathname.startsWith('/videoplayback');

		if (isVideoPlaybackUrl && !u.searchParams.has("rn")) {
Kelvin's avatar
Kelvin committed
			actualUrl.searchParams.set("rn", this.requestNumber.toString());
Koen's avatar
Koen committed
		}
		this.requestNumber++;

Kelvin's avatar
Kelvin committed
		if(this.newUrl) {
			log("BYPASS: Using NewURL For sources");
			log("BYPASS: OldUrl: " + u.toString());
			log("BYPASS: NewUrl: " + actualUrl.toString());
			log("BYPASS: Headers: " + JSON.stringify(headers));
		}
		

		let removedRangeHeader = undefined;
Koen's avatar
Koen committed
		if (headers["Range"] && !u.searchParams.has("range")) {
			let range = headers["Range"];
			if (range.startsWith("bytes=")) {
				range = range.substring("bytes=".length);
			}
Kelvin's avatar
Kelvin committed
			removedRangeHeader = headers["Range"];
Koen's avatar
Koen committed
			delete headers["Range"];
Kelvin's avatar
Kelvin committed
			actualUrl.searchParams.set("range", range);
Koen's avatar
Koen committed
		}

		const c = u.searchParams.get("c");
		if (c === "WEB" || c === "TVHTML5_SIMPLY_EMBEDDED_PLAYER") {
			headers["Origin"] = URL_BASE;
			headers["Referer"] = URL_BASE;
			headers["Sec-Fetch-Dest"] = "empty";
			headers["Sec-Fetch-Mode"] = "cors";
			headers["Sec-Fetch-Site"] = "cross-site";
		}
	
		headers['TE'] = "trailers";
Kelvin's avatar
Kelvin committed
		
		
		//I hate this
		//Workaround for seemingly active blocking
		/*
		const isValid = refetchClient.request("HEAD", actualUrl.toString(), headers);
		if(isValid.code == 403 && this.newUrlCount < 3) {
			const itag = actualUrl.searchParams.get("itag");
			bridge.toast("Youtube block detected (" + (this.newUrlCount + 1) + "), bypassing..");
			log("Detected 403, attempting bypass");
			try {
				const newDetailsResp = source.getContentDetails(this.originalUrl, false, true);
				if(newDetailsResp) {
					let source = newDetailsResp.video.videoSources.find(x=>x.itagId == itag);
					if(!source)
						source = newDetailsResp.video.audioSources.find(x=>x.itagId == itag);
					if(source) {
						this.newUrl = source.url;
						this.newUrlCount++;
						this.requestNumber = 0;
						log("Injecting new source url[" + source.name + "]: " + source.url);
						bridge.toast("Injecting new source url");
						if(removedRangeHeader)
							headers["Range"] = removedRangeHeader;
						return this.modifyRequest(url, headers);
					}
				}
				else
					bridge.toast("Bypass failed, couldn't reload [" + newDetailsResp.code + "]");
			}
			catch(ex) {
				bridge.toast("Bypass failed\n" + ex);
			}
		}
		*/
Koen's avatar
Koen committed

		if (c) {
			switch (c) {
				case "ANDROID":
					headers["User-Agent"] = USER_AGENT_ANDROID;
					break;
				case "IOS":
					headers["User-Agent"] = USER_AGENT_IOS;
					break;
				default:
					headers["User-Agent"] = USER_AGENT_WINDOWS;
					break;
			}
		}

        return {
Kelvin's avatar
Kelvin committed
            url: actualUrl.toString(),
Koen's avatar
Koen committed
			headers: headers
		}
    }
}

class YTLiveEventPager extends LiveEventPager {
	constructor(key, continuation) {
		super([], continuation != null);
		this.key = key;
		this.continuation = continuation;
		this.hasMore = true;
		this.knownEmojis = {};
		this.nextPage();
	}
	nextPage() {
		const newResult = http.POST(URL_LIVE_CHAT + "?key=" + this.key + "&prettyPrint=false", 
		JSON.stringify({
			context: {
				client: {
					clientName: "WEB",
					clientVersion: "2.20220901.00.00",
					clientFormFactor: "UNKNOWN_FORM_FACTOR",
					utcOffsetMinutes: 0,
					memoryTotalKbytes: 100000,
					timeZone: "ETC/UTC"
				},
				user: {
					lockedSafetyMode: false
				}
			},
			continuation: this.continuation,
			webClientInfo: {
				isDocumentHidden: false
			}
		}), {
			"Content-Type": "application/json",
			"User-Agent": USER_AGENT_WINDOWS
		}, false);
		if(!newResult.isOk)
			throw new ScriptException("Failed chat: " + newResult.body);
		const json = JSON.parse(newResult.body);
		//if(IS_TESTING)
		//	console.log("Live Chat Json:", json);
	
		const continuationArr = json?.continuationContents?.liveChatContinuation?.continuations;
		if(!continuationArr || continuationArr.length == 0) {
			this.hasMore = false;
			throw new ScriptException("No chat continuation found");
		}
		const continuation = continuationArr[0]?.timedContinuationData?.continuation ?? continuationArr[0]?.invalidationContinuationData?.continuation
		if(!continuation) {
			this.hasMore = false;
			throw new ScriptException("No chat continuation found");
		}
		this.continuation = continuation;
	
		const actions = json.continuationContents?.liveChatContinuation?.actions;
		if(IS_TESTING)
			console.log("Live Chat Actions:", actions);
Koen's avatar
Koen committed
		let events = [];
Koen's avatar
Koen committed
		if(actions && actions.length > 0) {
Koen's avatar
Koen committed
			const actionResults = handleYoutubeLiveEvents(actions);
			const emojiMap = actionResults.emojis;
			events = actionResults.events;
Koen's avatar
Koen committed

			let newEmojiCount = 0;
			for(let kv in emojiMap) {
				if(this.knownEmojis[kv])
					delete emojiMap[kv];
				else {
					this.knownEmojis[kv] = emojiMap[kv];
					newEmojiCount++;
				}
			}
			if(newEmojiCount > 0) {
				console.log("New Emojis:", emojiMap);
				events.unshift(new LiveEventEmojis(emojiMap));
			}
		}
		this.results = events;

		//if(IS_TESTING)
		//	console.log("LiveEvents:", this.results);
	
		return this;
	}
}
Koen's avatar
Koen committed
function handleYoutubeLiveEvents(actions) {
	let emojiMap = {};
	let events = [];
	for(let action of actions) {
		try {
			if(action.addChatItemAction) {
				const obj = action.addChatItemAction;

				const isPaid = !!obj.item?.liveChatPaidMessageRenderer

				const renderer = (isPaid) ? obj.item?.liveChatPaidMessageRenderer : obj.item?.liveChatTextMessageRenderer;
				const msgObj = extractLiveMessage_Obj(renderer);

				if(!msgObj)
					continue;

				if(msgObj.emojis)
					for(let emojiKey in msgObj.emojis)
						emojiMap[emojiKey] = msgObj.emojis[emojiKey];

				if(msgObj && msgObj.name && (msgObj.message || isPaid)) {
					if(!isPaid)
						events.push(new LiveEventComment(msgObj.name, msgObj.message, msgObj.thumbnail, msgObj.colorName, msgObj.badges));
					else {
						const amount = extractText_String(renderer.amount ?? renderer.purchaseAmountText ?? paidMessageRenderer?.amount ?? paidMessageRenderer?.purchaseAmountText);
						events.push(new LiveEventDonation(amount, msgObj.name, msgObj.message ?? "", msgObj.thumbnail, 0, renderer.bodyBackgroundColor ? "#" + Number(renderer.bodyBackgroundColor).toString(16) : null));
					}
				}
			}
			else if(action.ReplaceChatItemAction) {}
			else if(action.RemoveChatItemAction) {}
			else if(action.addLiveChatTickerItemAction) {
				const obj = action.addLiveChatTickerItemAction;
				if(obj.item?.liveChatTickerSponsorItemRenderer) {
					const renderer = obj.item?.liveChatTickerSponsorItemRenderer;
					const membershipRenderer = renderer.showItemEndpoint?.showLiveChatItemEndpoint?.renderer?.liveChatMembershipItemRenderer;
					const msgObj = extractLiveMessage_Obj(membershipRenderer);
					if(msgObj && msgObj.name)
						events.push(new LiveEventDonation("Member", msgObj.name, msgObj.message, msgObj.thumbnail, (renderer.durationSec ?? 10) * 1000, membershipRenderer.bodyBackgroundColor ? "#" + Number(membershipRenderer.bodyBackgroundColor).toString(16) : null));
				}
				else if(obj.item?.liveChatTickerPaidMessageItemRenderer) {
					const renderer = obj.item?.liveChatTickerPaidMessageItemRenderer
					const paidMessageRenderer = renderer.showItemEndpoint?.showLiveChatItemEndpoint?.renderer?.liveChatPaidMessageRenderer;
					const msgObj = extractLiveMessage_Obj(paidMessageRenderer);
					const amount = extractText_String(renderer.amount ?? renderer.purchaseAmountText ?? paidMessageRenderer?.amount ?? paidMessageRenderer?.purchaseAmountText);
					if(msgObj && msgObj.name)
						events.push(new LiveEventDonation(amount, msgObj.name, msgObj.message, msgObj.thumbnail, (renderer.durationSec ?? 10) * 1000, paidMessageRenderer.bodyBackgroundColor ? "#" + Number(paidMessageRenderer.bodyBackgroundColor).toString(16) : null));
				}
			}
			else if(action.addBannerToLiveChatCommand) {
				const bannerRenderer = action.addBannerToLiveChatCommand?.bannerRenderer?.liveChatBannerRenderer;
				const redirectRenderer = bannerRenderer?.contents?.liveChatBannerRedirectRenderer;

				if(bannerRenderer && redirectRenderer && bannerRenderer.bannerType == "LIVE_CHAT_BANNER_TYPE_CROSS_CHANNEL_REDIRECT") {
					
					const url = redirectRenderer.inlineActionButton?.buttonRenderer?.command?.commandMetadata?.webCommandMetadata?.url;
					const name = redirectRenderer.bannerMessage?.runs?.find(x=>x.bold)?.text;
					const thumbnails = redirectRenderer.authorPhoto?.thumbnails;
					
					if(url && name && thumbnails && thumbnails.length && thumbnails.length > 0)
						events.push(new LiveEventRaid(URL_BASE + url, name, thumbnails[thumbnails.length - 1]?.url));
				}
			}
			else {
				const keys = Object.keys(action);
				log("Unknown Event: " + keys.join(",") + JSON.stringify(action, null, "   "));
			}
		}
		catch(ex) {
			log("Failed Youtube live action parse due to [" + ex + "]: " + JSON.stringify(action, null, "   "));
		}
	}
	return {
		events: events,
		emojis: emojiMap
	};
}
source.handleYoutubeLiveEvents = handleYoutubeLiveEvents;

Koen's avatar
Koen committed
function extractLiveMessage_Obj(obj) {
	if(!obj)
		return null;
	const name = extractText_String(obj.authorName);
	const thumbnails = obj?.authorPhoto?.thumbnails;
	let thumbnail = null;
	for(let thumb of thumbnails){
		if(thumb?.url) {
			thumbnail = thumb.url;
			break;
		}
	}
	let message = extractText_String(obj.message);
	const headerMessage = extractText_String(obj.headerPrimaryText);

	const emojiMap = {};

	let isMember = false;
	const badges = [];
    if(obj.authorBadges) {
        for(let badge of obj.authorBadges) {
            const badgeImages = badge.liveChatAuthorBadgeRenderer?.customThumbnail?.thumbnails;
            const badgeName = badge.liveChatAuthorBadgeRenderer?.tooltip;
            if(badgeImages && badgeImages.length > 0 && badgeName) {
                emojiMap[badgeName] = badgeImages[badgeImages.length - 1].url;
                badges.push(badgeName);

Koen's avatar
Koen committed
                if(badgeName.toLowerCase().indexOf("member") >= 0)
Koen's avatar
Koen committed
                    isMember = true;
            }
        }
    }

	if(obj?.message?.runs) {
		for(let part of obj?.message?.runs) {
			if(part.emoji?.image?.accessibility?.accessibilityData?.label && part.emoji?.image?.thumbnails) {
			    const label = part.emoji?.image?.accessibility?.accessibilityData?.label;
			    if(label && !emojiMap[label]) {
                    emojiMap[label] = part.emoji?.image?.thumbnails[0]?.url;
			    }
			}
		}
	}
	return {
		name: name,
		thumbnail: thumbnail,
		message: message,
		headerMessage: headerMessage,
		emojis: emojiMap,
		colorName: isMember ? "#2ba640" : null,
		badges: badges
	};
}

class YTCommentPager extends CommentPager {
	constructor(comments, continuation, contextUrl, useLogin, useMobile) {
Koen's avatar
Koen committed
		super(comments, continuation != null, contextUrl);
		this.useLogin = !!useLogin;
		this.useMobile = !!useMobile;
Koen's avatar
Koen committed
		this.continuation = continuation;
	}
	nextPage() {
		if(!this.continuation)
			return new CommentPager([], false);
		return requestCommentPager(this.context, this.continuation, this.useLogin, this.useMobile) ?? new CommentPager([], false);
Koen's avatar
Koen committed
	}
}
class YTComment extends Comment {
	constructor(obj) {
		super(obj);
	}
}

class RichGridPager extends VideoPager {
	constructor(tab, context, useMobile = false, useAuth = false) {