Skip to content
Snippets Groups Projects
YoutubeScript.js 141 KiB
Newer Older
Koen's avatar
Koen committed
				"embedUrl": "https://www.youtube.com/watch?v=" + videoId,
			},
			user: {
				"lockedSafetyMode": false
			}
		}
	};
	const headers = {
		"Content-Type": "application/json",
		"User-Agent": USER_AGENT_TVHTML5_EMBED,
		"X-Goog-Api-Format-Version": "2"
	};

	const token = randomString(12);
	const clientContext = getClientContext(false);
	const url = URL_PLAYER + 
		"?key=" + clientContext.INNERTUBE_API_KEY +
		"&prettyPrint=false" + 
		"&t=" + token +
		"&id=" + videoId

	const resp = http.POST(url, JSON.stringify(body), headers, false);
	if(resp.isOk)
		return JSON.parse(resp.body);
	else
		return null;
}
//#endregion

//#region Page Extraction
function getInitialData(html, useAuth = false) {
	const clientContext = getClientContext(useAuth);

	//TODO: Fix regex instead of this temporary workaround.
	/*
	const startIndex = html.indexOf("var ytInitialData = ");
	const endIndex = html.indexOf(";</script>", startIndex);
	if(startIndex > 0 && endIndex > 0) {
	    const raw = html.substring(startIndex + 20, endIndex);
	    const initialDataRaw = raw.startsWith("'") && raw.endsWith("'") ?
            decodeHexEncodedString(raw.substring(1, raw.length - 1))
                //TODO: Find proper decoding strat
                .replaceAll("\\\\\"", "\\\"") :
            raw;
		let initialData = null;
		try{
			initialData = JSON.parse(initialDataRaw);
		}
		catch(ex) {
			console.log("Failed to parse initial data: ", initialDataRaw);
			throw ex;
		}
		if(clientContext?.INNERTUBE_CONTEXT && !clientContext.INNERTUBE_CONTEXT.client.visitorData &&
			initialData.responseContext?.webResponseContextExtensionData?.ytConfigData?.visitorData) {
				clientContext.INNERTUBE_CONTEXT.client.visitorData = initialData.responseContext?.webResponseContextExtensionData?.ytConfigData?.visitorData
			log("Found new visitor (auth) data: " + clientContext.INNERTUBE_CONTEXT.client.visitorData);
		}
		return initialData;
	}*/

Koen's avatar
Koen committed
	const match = html.match(REGEX_INITIAL_DATA);
	if(match) {
		const initialDataRaw = match[1].startsWith("'") && match[1].endsWith("'") ?
Koen's avatar
Koen committed
			decodeHexEncodedString(match[1].substring(1, match[1].length - 1))
				//TODO: Find proper decoding strat
				.replaceAll("\\\\\"", "\\\"") : 
			match[1];
		let initialData = null;
Koen's avatar
Koen committed
		try{
			initialData = JSON.parse(initialDataRaw);
		}
		catch(ex) {
			console.log("Failed to parse initial data: ", initialDataRaw);
			throw ex;
		}
		
		
		if(clientContext?.INNERTUBE_CONTEXT && !clientContext.INNERTUBE_CONTEXT.client.visitorData &&
			initialData.responseContext?.webResponseContextExtensionData?.ytConfigData?.visitorData) {
				clientContext.INNERTUBE_CONTEXT.client.visitorData = initialData.responseContext?.webResponseContextExtensionData?.ytConfigData?.visitorData
			log("Found new visitor (auth) data: " + clientContext.INNERTUBE_CONTEXT.client.visitorData);
		}
		return initialData;
	}
	//if(initialData == null)
	//    log(html);

Koen's avatar
Koen committed
	return null;
}
function getInitialPlayerData(html) {
	const match = html.match(REGEX_INITIAL_PLAYER_DATA);
	if(match) {
		const initialDataRaw = match[1];
		return JSON.parse(initialDataRaw);
	}
	return null;
}
function getClientConfig(html) {
	const matches = html.matchAll(REGEX_YTCFG);
	let match = null;
	for(let m of matches) {
		if(m && m.length >= 2 && m[1].indexOf("INNERTUBE_CONTEXT") > 0) {
			match = m;
		}
	}

	if(!match) throw new ScriptException("Context structure not found");
	return JSON.parse(match[1]);
}
//#endregion

//#region Top-Level Extraction
/**
 * Extract Subscription channels from a submenu obtained from subscriptionsPage
 * @returns  {PlatformAuthorLink[]} Channels
 */
function extractChannelListSubMenuAvatarRenderer_AuthorLink(renderer) {
	const thumbnail = renderer?.thumbnail?.thumbnails && renderer.thumbnail.thumbnails.length > 0 ?
		renderer.thumbnail.thumbnails[renderer.thumbnail.thumbnails.length - 1] :
		null;
	const name = renderer?.accessibility?.accessibilityData?.label ?
		renderer.accessibility.accessibilityData.label.trim() :
		"";
	const url = renderer?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl ?
		URL_BASE + renderer.navigationEndpoint.browseEndpoint.canonicalBaseUrl :
		null;
	if(!url || !name)
		return null;
	else
Kelvin's avatar
Kelvin committed
		return new PlatformAuthorLink(new PlatformID(PLATFORM, null, config?.id, PLATFORM_CLAIMTYPE), name, url, thumbnail);
Koen's avatar
Koen committed
}
/**
 * Extract Subscription channels from a submenu obtained from subscriptionsPage
 * @returns  {String[]} Urls
 */
function extractChannelListSubMenuAvatarRenderer_URL(renderer) {
Koen's avatar
Koen committed
	const canonicalUrl = renderer?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl ?
Koen's avatar
Koen committed
		URL_BASE + renderer.navigationEndpoint.browseEndpoint.canonicalBaseUrl :
		null;
Koen's avatar
Koen committed
	const idUrl = renderer?.navigationEndpoint?.browseEndpoint?.browseId ?
		URL_BASE + "/channel/" + renderer.navigationEndpoint.browseEndpoint.browseId :
		null;
	const url = idUrl ?? canonicalUrl;
Koen's avatar
Koen committed
	if(!url)
		return null;
	else
		return url;
}
/**
 * Extract Subscription channels from a sections[] obtained from guide()
 * @returns {PlatformAuthorLink[]} Channels
 */
function extractGuide_Channels(data) {
	let sections = data.items ?? [];
	let channels = [];

	for(let section of sections) {
		switchKey(section, {
			guideSubscriptionsSectionRenderer(renderer) {
				for(let item of renderer.items) {
					switchKey(item, {
						guideEntryRenderer(guideEntryRenderer) {
							channels.push(extractGuideEntry_AuthorLink(guideEntryRenderer));
						},
						guideCollapsibleEntryRenderer(collapseRenderer) {
							if(collapseRenderer.expandableItems?.length > 0) {
								for(let item of collapseRenderer.expandableItems) {
									switchKey(item, {
										guideEntryRenderer(guideEntryRenderer) {
											channels.push(extractGuideEntry_AuthorLink(guideEntryRenderer));
										}
									})
								}
							}
						}
					});
				}
			}
		});
	}

	return channels;
}
function extractGuideEntry_AuthorLink(guideEntryRenderer) {
	const thumbnail = guideEntryRenderer.thumbnail?.thumbnails?.length > 0 ? 
		guideEntryRenderer.thumbnail.thumbnails[0].url : null;
	const name = guideEntryRenderer.formattedTitle?.simpleText ?? 
		guideEntryRenderer.accessibility?.accessibilityData?.label;
	const url = guideEntryRenderer.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl ?
		URL_BASE + guideEntryRenderer.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl : null;

Kelvin's avatar
Kelvin committed
	return new PlatformAuthorLink(new PlatformID(PLATFORM, null, config.id, PLATFORM_CLAIMTYPE), name, url, thumbnail);
Koen's avatar
Koen committed
}

/**
 * Extract all video results and shelves from a search page's initial data
 * @param data Root-data from search()
 * @param contextData Any context values used to fill out data for resulting objects
 * @returns Object containing videos and shelves
 */
function extractSearch_SearchResults(data, contextData) {
	let searchContents = data.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer ??
		data.contents?.sectionListRenderer;

	if(searchContents) {
		const results = extractSectionListRenderer_Sections(searchContents, contextData);
		return results;
	}
	return {};
}

/**
 * Extracts a PlatformChannel from a channel page's initial data
 * @param initialData Initial data from a ChannelPage 
 * @returns {PlatformChannel}
 */
function extractChannel_PlatformChannel(initialData, sourceUrl = null) {
Kelvin's avatar
Kelvin committed
    if(initialData?.header?.c4TabbedHeaderRenderer) {
        const headerRenderer = initialData?.header?.c4TabbedHeaderRenderer;

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

        const thumbnailTargetWidth = 200;
        const thumbnails = headerRenderer.avatar?.thumbnails;
        const thumbnail = (thumbnails && thumbnails.length > 0) ? thumbnails.sort((a,b)=>Math.abs(a.width - thumbnailTargetWidth) - Math.abs(b.width - thumbnailTargetWidth))[0] : { url: "" };
        const banners = headerRenderer.banner?.thumbnails;
        const bannerTargetWidth = 1080;
        const banner = (banners && banners.length > 0) ? banners.sort((a,b)=>Math.abs(a.width - bannerTargetWidth) - Math.abs(b.width - bannerTargetWidth))[0] : { url: "" };

        const idUrl = headerRenderer?.navigationEndpoint?.browseEndpoint?.browseId ?
            URL_BASE + "/channel/" + headerRenderer.navigationEndpoint.browseEndpoint.browseId :
            null;
        const canonicalUrl = headerRenderer?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl ?
            URL_BASE + headerRenderer.navigationEndpoint.browseEndpoint.canonicalBaseUrl :
            null;

        return new PlatformChannel({
            id: new PlatformID(PLATFORM, headerRenderer.channelId, config.id, PLATFORM_CLAIMTYPE),
            name: headerRenderer.title ?? "",
            thumbnail: thumbnail.url,
            banner: banner.url,
            subscribers: Math.max(0, extractHumanNumber_Integer(extractText_String(headerRenderer.subscriberCountText))),
            description: "",
            url: idUrl,
            urlAlternatives: [idUrl, canonicalUrl],
            links: {}
        });
    }
    else if(initialData?.header?.pageHeaderRenderer) {
        log("New channel model");
        const headerRenderer = initialData?.header?.pageHeaderRenderer;

        if(IS_TESTING)
            console.log("Initial Data (New Model)", initialData);

        const thumbnailTargetWidth = 200;
        const thumbnails = headerRenderer?.content?.pageHeaderViewModel?.image?.decoratedAvatarViewModel?.avatar?.avatarViewModel?.image?.sources;
        const thumbnail = (thumbnails && thumbnails.length > 0) ? thumbnails.sort((a,b)=>Math.abs(a.width - thumbnailTargetWidth) - Math.abs(b.width - thumbnailTargetWidth))[0] : { url: "" };
        const banners = headerRenderer?.content?.pageHeaderViewModel?.banner?.imageBannerViewModel?.image?.sources;
        const bannerTargetWidth = 1080;
        const banner = (banners && banners.length > 0) ? banners.sort((a,b)=>Math.abs(a.width - bannerTargetWidth) - Math.abs(b.width - bannerTargetWidth))[0] : { url: "" };

        const id = initialData?.metadata?.channelMetadataRenderer?.externalId;
        if(!id) {
            log("ID not found in new channel viewmodel:" + JSON.stringify(id, null, "   "));
            throw new ScriptException("ID Not found in new channel view model");
        }
Koen's avatar
Koen committed


Kelvin's avatar
Kelvin committed
        const idUrl = id ?
            URL_BASE + "/channel/" + id:
            null;
        const canonicalUrl = initialData?.metadata?.channelMetadataRenderer?.vanityChannelUrl ?
            initialData?.metadata?.channelMetadataRenderer?.vanityChannelUrl :
            null;

        let subCount = 0;
        const metadataRows = headerRenderer?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows;
        for(let row of metadataRows) {
            const subsStr = row.metadataParts.find(x=>x.text?.content?.indexOf("subscribers") > 0)?.text?.content;
            if(!subsStr)
                continue;
            const subsNum = extractHumanNumber_Integer(extractText_String(subsStr));
            if(!isNaN(subsNum) && subsNum > 0) {
               subCount = subsNum;
                break;
            }
        }
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
        return new PlatformChannel({
            id: new PlatformID(PLATFORM, id, config.id, PLATFORM_CLAIMTYPE),
            name: initialData?.metadata?.channelMetadataRenderer?.title ?? "",
            thumbnail: thumbnail.url,
            banner: banner.url,
            subscribers: Math.max(0, subCount),
            description: initialData?.metadata?.channelMetadataRenderer?.description,
            url: idUrl,
            urlAlternatives: [idUrl, canonicalUrl].filter(x=>x != null),
            links: {}
        });
    }
    else {
        log("Missing header: (" + sourceUrl + ")\n" + JSON.stringify(initialData, null, "   "));
        throw new ScriptException("No header for " + sourceUrl);
    }
Koen's avatar
Koen committed
}
/**
 * Extracts multiple tabs from a page that contains a tab rendering
 * @param initialData Initial data from a page with a TwoColumnBrowseResultsRenderer
 * @param contextData Any context values used to fill out data for resulting objects
 * @returns 
 */
function extractPage_Tabs(initialData, contextData) {
	const content = initialData.contents;
	if(!content) throw new ScriptException("Missing contents");

	return switchKey(content, {
		twoColumnBrowseResultsRenderer(renderer) {
			return extractTwoColumnBrowseResultsRenderer_Tabs(renderer, contextData);
		},
		singleColumnBrowseResultsRenderer(renderer) {
			return extractSingleColumnBrowseResultsRenderer_Tabs(renderer, contextData);
		},
		default(name) {
			throw new ScriptException("Unknown renderer type: " + name);
		}
	});
}
//#endregion


//#region Layout Extractors
function extractVideoPage_VideoDetails(initialData, initialPlayerData, contextData, jsUrl) {
	const contents = initialData.contents;
	const contentsContainer = contents.twoColumnWatchNextResults?.results?.results ??
		null;
	if(!contentsContainer || !contentsContainer.contents || !initialPlayerData.videoDetails) return null;

	if (IS_TESTING) {
		console.log("initialData: ", initialData);
		console.log("playerData:", initialPlayerData);
		console.log("streamingData:", initialPlayerData?.streamingData);
	}
	const videoDetails = initialPlayerData.videoDetails;
	const nonce = randomString(16);

	const hlsSource = (initialPlayerData?.streamingData?.hlsManifestUrl) ?
		new HLSSource({
			url: initialPlayerData?.streamingData?.hlsManifestUrl
		}) : null;
	const dashSource = (initialPlayerData?.streamingData?.dashManifestUrl) ?
		new DashSource({
			url: initialPlayerData?.streamingData?.dashManifestUrl
		}) : null;

	const video = {
		id: new PlatformID(PLATFORM, videoDetails.videoId, config.id),
		name: videoDetails.title,
		thumbnails: new Thumbnails(videoDetails.thumbnail?.thumbnails.map(x=>new Thumbnail(x.url, x.height)) ?? []),
		author: new PlatformAuthorLink(new PlatformID(PLATFORM, videoDetails.channelId, config.id, PLATFORM_CLAIMTYPE), videoDetails.author, URL_BASE + "/channel/" + videoDetails.channelId, null, null),
Koen's avatar
Koen committed
		duration: parseInt(videoDetails.lengthSeconds),
		viewCount: parseInt(videoDetails.viewCount),
		url: contextData.url,
		isLive: videoDetails?.isLive ?? false,
		description: videoDetails.shortDescription,
		hls: (videoDetails?.isLive ?? false) ? hlsSource : null,
		dash: (videoDetails?.isLive ?? false) ? dashSource : null,
		live: (videoDetails?.isLive ?? false) ? (hlsSource ?? dashSource) : null,
		video: initialPlayerData?.streamingData?.adaptiveFormats ? new UnMuxVideoSourceDescriptor(
			initialPlayerData.streamingData.adaptiveFormats.filter(x=>x.mimeType.startsWith("video/")).map(y=>{
				const codecs = y.mimeType.substring(y.mimeType.indexOf('codecs=\"') + 8).slice(0, -1);
				const container = y.mimeType.substring(0, y.mimeType.indexOf(';'));
				if(codecs.startsWith("av01"))
					return null; //AV01 is unsupported.

				const logItag = y.itag ==  134;
				if(logItag) {
					log(videoDetails.title + " || Format " + container + " - " + y.itag + " - " + y.width);
					log("Source Parameters:\n" + JSON.stringify({
						url: y.url,
						cipher: y.cipher,
						signatureCipher: y.signatureCipher
					}, null, "   "));
				}
				
				let url = decryptUrlN(y.url, jsUrl, logItag) ?? decryptUrl(y.cipher, jsUrl, logItag) ?? decryptUrl(y.signatureCipher, jsUrl, logItag);
				if(url.indexOf("&cpn=") < 0)
					url = url + "&cpn=" + nonce;

				const duration = parseInt(parseInt(y.approxDurationMs) / 1000) ?? 0;
				if(isNaN(duration))
					return null;

                if(!y.initRange?.end || !y.indexRange?.end)
                    return null;

				return new YTVideoSource({
					name: y.height + "p" + (y.fps ? y.fps : "") + " " + container,
					url: url,
					width: y.width,
					height: y.height,
					duration: (!isNaN(duration)) ? duration : 0,
					container: y.mimeType.substring(0, y.mimeType.indexOf(';')),
					codec: codecs,
					bitrate: y.bitrate,

					itagId: y.itag,
					initStart: parseInt(y.initRange?.start),
					initEnd: parseInt(y.initRange?.end),
					indexStart: parseInt(y.indexRange?.start),
					indexEnd: parseInt(y.indexRange?.end)
				});
			}).filter(x=>x != null),
			initialPlayerData.streamingData.adaptiveFormats.filter(x=>x.mimeType.startsWith("audio/")).map(y=>{
				const codecs = y.mimeType.substring(y.mimeType.indexOf('codecs=\"') + 8).slice(0, -1);
				const container = y.mimeType.substring(0, y.mimeType.indexOf(';'));

				let url = decryptUrlN(y.url, jsUrl) ?? decryptUrl(y.cipher, jsUrl) ?? decryptUrl(y.signatureCipher, jsUrl);
				if(url.indexOf("&cpn=") < 0)
					url = url + "&cpn=" + nonce;
				
				const duration = parseInt(parseInt(y.approxDurationMs) / 1000);
				if(isNaN(duration))
					return null;

                if(!y.initRange?.end || !y.indexRange?.end)
                    return null;

				return new YTAudioSource({
					name: y.audioTrack?.displayName ? y.audioTrack.displayName : codecs,
					container: container,
					bitrate: y.bitrate,
					url: url,
					duration: (!isNaN(duration)) ? duration : 0,
					container: y.mimeType.substring(0, y.mimeType.indexOf(';')),
					codec: codecs,
					language: ytLangIdToLanguage(y.audioTrack?.id),

					itagId: y.itag,
					initStart: parseInt(y.initRange?.start),
					initEnd: parseInt(y.initRange?.end),
					indexStart: parseInt(y.indexRange?.start),
					indexEnd: parseInt(y.indexRange?.end),
					audioChannels: y.audioChannels
				});
			}).filter(x=>x!=null),
		) : new VideoSourceDescriptor([]),
		subtitles: initialPlayerData
			.captions
			?.playerCaptionsTracklistRenderer
			?.captionTracks
			?.map(x=>{
				let kind = x.baseUrl.match(REGEX_URL_KIND);
				if(kind)
					kind = kind[1];

				if(!kind || kind == "asr") {
					return {
						name: extractText_String(x.name),
						url: x.baseUrl,
						format: "text/vtt",

						getSubtitles() {
							const subResp = http.GET(x.baseUrl, {});
							if(!subResp.isOk)
								return "";
							const asr = subResp.body;
							let lines = asr.match(REGEX_ASR);
							const newSubs = [];
							let skipped = 0;
							for(let i = 0; i < lines.length; i++) {
								const line = lines[i];
								const lineParsed = /<text .*?start="(.*?)" .*?dur="(.*?)".*?>(.*?)<\/text>/gms.exec(line);

								const start = parseFloat(lineParsed[1]);
								const dur = parseFloat(lineParsed[2]);
								const end = start + dur;
								const text = decodeHtml(lineParsed[3]);

								newSubs.push((i - skipped + 1) + "\n" +
									toSRTTime(start, true) + " --> " + toSRTTime(end, true) + "\n" +
									text + "\n");
							}
							console.log(newSubs);
							return "WEBVTT\n\n" + newSubs.join('\n');
						}
					};
				}
				else if(kind == "vtt") {
					return {
						name: extractText_String(x.name),
						url: x.baseUrl,
						format: "text/vtt",
					};
				}
				else return null;
			})?.filter(x=>x != null) ?? []
	};

	//Adds HLS stream if any other format is not yet available, mostly relevant for recently ended livestreams.
Kelvin's avatar
Kelvin committed
	if(video.video.videoSources !== null && video.video.videoSources.length == 0 && initialPlayerData?.streamingData?.hlsManifestUrl)
	    video.video.videoSources.push(new HLSSource({url: initialPlayerData.streamingData.hlsManifestUrl}));


Koen's avatar
Koen committed
	//Add additional/better details
	for(let i = 0; i < contentsContainer.contents.length; i++) {
		const content = contentsContainer.contents[i];
		switchKey(content, {
			videoPrimaryInfoRenderer(renderer) {
				//if(renderer.title?.runs)
				//	video.name = extractString_Runs(renderer.title.runs);
				if(renderer.viewCount?.videoViewCountRenderer?.viewCount?.simpleText)
					video.viewCount = extractFirstNumber_Integer(renderer.viewCount?.videoViewCountRenderer?.viewCount.simpleText)
				else if(renderer.viewCount?.videoViewCountRenderer?.viewCount?.runs) {
					video.viewCount = parseInt(extractFirstNumber_Integer(extractRuns_String(renderer.viewCount?.videoViewCountRenderer?.viewCount?.runs)));
				}
				if(renderer.viewCount?.videoViewCountRenderer?.isLive || renderer.viewCount?.videoViewCountRenderer?.viewCount?.isLive)
					video.isLive = true;
				else
					video.isLive = false;
				

				if(renderer.videoActions?.menuRenderer?.topLevelButtons)
					renderer.videoActions.menuRenderer.topLevelButtons.forEach((button)=>{
						switchKey(button, {
							segmentedLikeDislikeButtonRenderer(renderer) {
								const likeButtonRenderer = renderer?.likeButton?.toggleButtonRenderer;
								if(likeButtonRenderer) {
									const likeTextData = likeButtonRenderer.defaultText;
									if(likeTextData){
										if(likeTextData.accessibility?.accessibilityData?.label)
											video.rating = new RatingLikes(extractFirstNumber_Integer(likeTextData.accessibility.accessibilityData.label));
										else if(likeTextData.simpleText)
											video.rating = new RatingLikes(extractHumanNumber_Integer(likeTextData.simpleText));

									}
								}
Kelvin's avatar
Kelvin committed
							},
							segmentedLikeDislikeButtonViewModel(renderer) {
							    if(IS_TESTING)
							        console.log("Found new likes model:", renderer);
							    let likeButtonViewModel = renderer?.likeButtonViewModel;
							    if(likeButtonViewModel.likeButtonViewModel) //Youtube double nested, not sure if a bug on their end which may be removed
							        likeButtonViewModel = likeButtonViewModel.likeButtonViewModel;
							    let toggleButtonViewModel = likeButtonViewModel?.toggleButtonViewModel;
							    if(toggleButtonViewModel.toggleButtonViewModel) //Youtube double nested, not sure if a bug on their end which may be removed
							        toggleButtonViewModel = toggleButtonViewModel.toggleButtonViewModel;

							    const buttonViewModel = toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel;
							    if(buttonViewModel?.title) {
							        let num = parseInt(buttonViewModel.title);
							        if(!isNaN(num))
							            video.rating = new RatingLikes(num);
                                    num = extractHumanNumber_Integer(buttonViewModel.title);
                                    if(!isNaN(num) && num >= 0)
                                        video.rating = new RatingLikes(num);
Kelvin's avatar
Kelvin committed
                                    else if(buttonViewModel.title?.toLowerCase() == "like")
                                        video.rating = new RatingLikes(0);
Kelvin's avatar
Kelvin committed
                                    else
                                        throw new ScriptException("Found unknown likes model, please report to dev:\n" + JSON.stringify(buttonViewModel.title));
							    }
							    else
							        log("UNKNOWN LIKES MODEL:\n" + JSON.stringify(renderer, null, "   "));
Koen's avatar
Koen committed
							}
						});
					});


				if(!video.datetime || video.datetime <= 0) {
					let date = 0;
					
Koen's avatar
Koen committed
					if (date <= 0 && renderer.relativeDateText?.simpleText)
						date = extractAgoText_Timestamp(renderer.relativeDateText.simpleText);
Koen's avatar
Koen committed
					if(date <= 0 && renderer.dateText?.simpleText)
						date = extractDate_Timestamp(renderer.dateText.simpleText);
Koen's avatar
Koen committed
					video.datetime = date;
				}
			},
			videoSecondaryInfoRenderer(renderer) {
				if(renderer.owner.videoOwnerRenderer)
					video.author = extractVideoOwnerRenderer_AuthorLink(renderer.owner.videoOwnerRenderer);
				if(renderer.description?.runs)
					video.description = extractRuns_Html(renderer.description.runs);
			},
			itemSectionRenderer() {
				//Comments
			}
		});
	}

	const scheduledTime = initialPlayerData?.playabilityStatus?.liveStreamability?.liveStreamabilityRenderer?.offlineSlate?.liveStreamOfflineSlateRenderer?.scheduledStartTime;
	
	if(scheduledTime && !isNaN(scheduledTime))
		video.datetime = parseInt(scheduledTime);

    const result = new PlatformVideoDetails(video);
    result.getComments = function() {
        return extractTwoColumnWatchNextResultContents_CommentsPager(contextData.url, contentsContainer?.contents)
    };
    return result;
}
function toSRTTime(sec, withDot) {
	let hours = 0;
	let minutes = 0;
	let seconds = sec;
	let remainder = 0;

	remainder = parseInt((seconds % 1) * 100);
	minutes = parseInt(seconds / 60);
	seconds = parseInt(seconds % 60);
	hours = parseInt(minutes / 60);
	minutes = minutes % 60;

	return ("" + hours).padStart(2, '0') + ":" +
		("" + minutes).padStart(2, '0') + ":" +
		("" + seconds).padStart(2, '0') + ((withDot) ? "." : ",") +
		("" + remainder).padEnd(3, '0');
}

function extractVideoOwnerRenderer_AuthorLink(renderer) {
	const id = renderer?.navigationEndpoint?.browseEndpoint?.browseId;
    const url = (!id) ? extractRuns_Url(renderer.title.runs) : URL_BASE + "/channel/" + id;

    const hasMembership = !!(renderer?.membershipButton?.buttonRenderer)
    let membershipUrl = (hasMembership) ? url + "/join" : null;

Koen's avatar
Koen committed
	let bestThumbnail = null;
	if(renderer.thumbnail?.thumbnails)
		bestThumbnail = renderer.thumbnail.thumbnails[renderer.thumbnail.thumbnails.length - 1].url;

	let subscribers = null;
	if(renderer.subscriberCountText)
		subscribers = extractHumanNumber_Integer(extractText_String(renderer.subscriberCountText));
Koen's avatar
Koen committed
	return new PlatformAuthorLink(new PlatformID(PLATFORM, id, config.id, PLATFORM_CLAIMTYPE), 
Koen's avatar
Koen committed
		extractRuns_String(renderer.title.runs),
Koen's avatar
Koen committed
		bestThumbnail,
		subscribers, membershipUrl);
Koen's avatar
Koen committed
}
function extractTwoColumnWatchNextResultContents_CommentsPager(contextUrl, contents) {
	//Add additional/better details

	let totalComments = 0;
	let commentsToken = null;
	for(let i = 0; i < contents.length; i++) {
		const content = contents[i];
		switchKey(content, {
			videoPrimaryInfoRenderer(renderer) { },
			videoSecondaryInfoRenderer(renderer) { },
			itemSectionRenderer(itemSectionRenderer) {
				const contents = itemSectionRenderer.contents;
				const content = contents && contents.length > 0 ? contents[0] : null;
				if(content)
					switchKey(content, {
						commentsEntryPointHeaderRenderer(renderer) {
							if(renderer?.commentCount?.simpleText) {
								totalComments = parseInt(renderer.commentCount.simpleText);
							}
						},
						continuationItemRenderer(continueRenderer) {
							if(totalComments > 0 && itemSectionRenderer.targetId == 'comments-section' && continueRenderer?.continuationEndpoint?.continuationCommand) {
								commentsToken = continueRenderer.continuationEndpoint.continuationCommand.token;
							}
						}
					});
			}
		});
	}
	if(!commentsToken)
		return new CommentPager([], false);
	return requestCommentPager(contextUrl, commentsToken) ??  new CommentPager([], false);
Koen's avatar
Koen committed
}
function requestCommentPager(contextUrl, continuationToken) {
	const data = requestNext({
		continuation: continuationToken
	});
	if(IS_TESTING)
	    console.log("data", data);
	const endpoints = data?.onResponseReceivedCommands ?? data?.onResponseReceivedActions ?? data?.onResponseReceivedEndpoints;
Kelvin's avatar
Kelvin committed
	if(!endpoints) {
	    log("Comment object:\n" + JSON.stringify(data, null, "   "));
	    throw new ScriptException("No comment endpoints provided by Youtube");
	}
Koen's avatar
Koen committed
	for(let i = 0; i < endpoints.length; i++) {
		const endpoint = endpoints[i];
		const continuationItems = endpoint.reloadContinuationItemsCommand?.continuationItems ??
			endpoint.appendContinuationItemsAction?.continuationItems;
		if(continuationItems && continuationItems.length > 0) {
			let commentsContinuation = null;
			let comments = [];

			if(continuationItems && continuationItems.length > 0) {
				for(let continuationItem of continuationItems) {
					switchKey(continuationItem, {
						commentThreadRenderer(renderer) {
							const commentRenderer = renderer?.comment?.commentRenderer;
							if(!commentRenderer)
								return;

							const replyCount = (commentRenderer.replyCount ? commentRenderer?.replyCount : 0);
							const replyContinuation = renderer.replies?.commentRepliesRenderer?.contents?.length == 1 ?
								renderer.replies.commentRepliesRenderer.contents[0]?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token :
								null;

							comments.push(extractCommentRenderer_Comment(contextUrl, commentRenderer, replyCount, replyContinuation));
						},
						commentRenderer(renderer) {
							comments.push(extractCommentRenderer_Comment(contextUrl, renderer, 0, null));
						},
						continuationItemRenderer(renderer) {
							if(renderer?.continuationEndpoint?.continuationCommand?.token)
								commentsContinuation = renderer?.continuationEndpoint?.continuationCommand?.token;
							else if(renderer?.button?.buttonRenderer?.command?.continuationCommand?.token)
							    commentsContinuation = renderer.button.buttonRenderer.command.continuationCommand.token
						}
					});
				}
				if(comments.length > 0) {
					return new YTCommentPager(comments, commentsContinuation, contextUrl);
				}
			}
		}
	}
Kelvin's avatar
Kelvin committed
	log("Comment object:\n" + JSON.stringify(data, null, "   "));
	throw new ScriptException("No valid comment endpoint provided by Youtube");
Koen's avatar
Koen committed
}

function extractSingleColumnBrowseResultsRenderer_Tabs(renderer, contextData) {
	const tabs = [];
	if(!renderer.tabs) throw new ScriptException("No tabs found");

	for(let i = 0; i < renderer.tabs.length; i++) {
		const tab = renderer.tabs[i];

		if(!tab.tabRenderer)
			continue;

		const tabRenderer = tab.tabRenderer; //TODO: Check if this is ever null?
		const isDefault = tabRenderer.selected;
		const title = tabRenderer.title;
		const content = tabRenderer.content;
		
		if(!content)
			continue; //.endpoint

		const tabContentRendererName = Object.keys(content)[0];
		if(!tabContentRendererName) continue;
		let tabResult = undefined;
		switchKey(content, {
			richGridRenderer(renderer) {
				tabResult = extractRichGridRenderer_Shelves(content[tabContentRendererName], contextData);
			},
			sectionListRenderer(renderer) {
				if(!renderer.contents)
					return;
				tabResult = extractSectionListRenderer_Sections(renderer, contextData);
			},
			default() {
				throw new ScriptException("Unknown tab renderer: " + tabContentRendererName);
			}
		});
		if(tabResult) {
			tabResult.isDefault = !!isDefault;
			tabResult.title = title;
			tabs.push(tabResult);
		}
	}

	return tabs;
}
function extractTwoColumnBrowseResultsRenderer_Tabs(renderer, contextData) {
	const tabs = [];
	if(!renderer.tabs) throw new ScriptException("No tabs found");

	for(let i = 0; i < renderer.tabs.length; i++) {
		const tab = renderer.tabs[i];

		if(!tab.tabRenderer && !tab.expandableTabRenderer)
			continue;

		const tabRenderer = tab.tabRenderer ?? tab.expandableTabRenderer;
		const isDefault = tabRenderer.selected;
		const title = tabRenderer.title;
		const content = tabRenderer.content;
		
		if(!content)
			continue; //.endpoint

		const tabContentRendererName = Object.keys(content)[0];
		if(!tabContentRendererName) continue;
		let tabResult = undefined;
		switchKey(content, {
			richGridRenderer(renderer) {
				tabResult = extractRichGridRenderer_Shelves(renderer, contextData);
			},
			sectionListRenderer(renderer) {
				//Channel sectioned tabs..
				tabResult = extractSectionListRenderer_Sections(renderer, contextData)
			},
			default() {
				throw new ScriptException("Unknown tab renderer: " + tabContentRendererName);
			}
		});
		if(tabResult) {
			tabResult.isDefault = !!isDefault;
			tabResult.title = title;
			tabs.push(tabResult);
		}
	}

	return tabs;
}
function extractRichGridRenderer_Shelves(richGridRenderer, contextData) {
	const contents = richGridRenderer.contents;

Kelvin's avatar
Kelvin committed
	let shelves = [];
	let videos = [];
Koen's avatar
Koen committed

	let continuation = null;

	for(let ci = 0; ci < contents.length; ci++) {
		const content = contents[ci];		
		switchKey(content, {
			richSectionRenderer(renderer) {
				shelves.push(extractRichSectionRenderer_Shelf(renderer, contextData));
			},
			richItemRenderer(renderer) {
				videos.push(extractRichItemRenderer_Video(renderer, contextData));
			},
			continuationItemRenderer(renderer) {
				continuation = extractContinuationItemRenderer_Continuation(renderer, contextData);
			},
Kelvin's avatar
Kelvin committed
			itemSectionRenderer(renderer) {
		        const items = extractItemSectionRenderer_Shelves(renderer);

		        if(items.shelves)
		            shelves = shelves.concat(items.shelves);
		        if(items.videos)
		            videos = videos.concat(items.videos);
			},
Koen's avatar
Koen committed
			default(name) {
				log("Unknown shelf/section renderer in extractRichGridRenderer_Shelves: " + name);
			}
		});
	}
	
	return {
		shelves: shelves.filter(x=>x != null),
		videos: videos.filter(x=>x != null),
		continuation: continuation
	};
}
function extractSectionListRenderer_Sections(sectionListRenderer, contextData) {
	const contents = sectionListRenderer.contents;

	let shelves = [];
	let videos = [];
	let channels = [];
	let playlists = [];
	let continuation = null;

	for(let i = 0; i < contents.length; i++) {
		const item = contents[i];
		switchKey(item, {
			itemSectionRenderer(renderer) {
				const items = extractItemSectionRenderer_Shelves(renderer);
				if(items.videos.length > 0)
					videos.push(...items.videos);
				if(items.channels.length > 0)
					channels.push(...items.channels);
				if(items.playlists.length > 0)
					playlists.push(...items.playlists);
				if(items.shelves)
					shelves.push(...items.shelves);
			},
			continuationItemRenderer(renderer) {
				continuation = extractContinuationItemRenderer_Continuation(renderer, contextData);
			}
		});
	}
	return {
		shelves: shelves,
		videos: videos,
		channels: channels,
		playlists: playlists,
		continuation: continuation
	};
}
function extractItemSectionRenderer_Shelves(itemSectionRenderer, contextData) {
	const contents = itemSectionRenderer.contents;
	let shelves = [];
	let videos = [];
	let channels = [];
	let playlists = [];

	contents.forEach((item)=>{
		switchKey(item, {
			channelFeaturedContentRenderer(renderer) {
				if(renderer.items) {
					let videos = switchKeyVideos(renderer.items, contextData);
					if(videos && videos.length > 0) {
						shelves.push({
							name: "Featured",
							type: "Shelf",
							videos: videos
						});
					}
				}
			},
			channelRenderer(renderer) {
			    const channel = extractChannelRenderer_AuthorLink(renderer);
			    if(channel)
			        channels.push(channel);
			},
			playlistRenderer(renderer) {
			    const playlist = extractPlaylistRenderer_Playlist(renderer);
			    if(playlist)
			        playlists.push(playlist);
			},
Kelvin's avatar
Kelvin committed
			shelfRenderer(renderer) {
			    const shelf = extractShelfRenderer_Shelf(renderer);
			    if(shelf)
				    shelves.push(shelf);
			},
Koen's avatar
Koen committed
			default() {
				const video = switchKeyVideo(item, contextData);
				if(video)
					videos.push(video);
			}
		});
		
	});

	return {
		shelves: shelves.filter(x=>x != null),
		videos: videos.filter(x=>x != null),
		channels: channels.filter(x=>x != null),
		playlists: playlists.filter(x=>x != null)
	};
}
function switchKeyVideos(contents, contextData) {
	let videos = [];
	for(let content of contents) {
		const video = switchKeyVideo(content, contextData);
		if(video)
			videos.push(video);
	}
	return videos;
}
function switchKeyVideo(content, contextData) {
	return switchKey(content, {
		channelFeaturedContentRenderer(renderer) {
			return extractVideoRenderer_Video(renderer, contextData);
		},
		videoRenderer(renderer) {
			return extractVideoRenderer_Video(renderer, contextData);
		},
		compactVideoRenderer(renderer) {
			return extractVideoWithContextRenderer_Video(renderer, contextData);
		},
		videoWithContextRenderer(renderer) {
			return extractVideoWithContextRenderer_Video(renderer, contextData);
		},
		reelItemRenderer(renderer) {
			return extractReelItemRenderer_Video(renderer, contextData);
		},
		adSlotRenderer(adSlot) {
			return null;
		},
		default(name) {
			return null;
		}
	});
}
//#endregion

//#region Element Extractors
function extractShelfRenderer_Shelf(shelfRenderer, contextData) {
Kelvin's avatar
Kelvin committed
    let name = extractText_String(shelfRenderer.title);
    return switchKey(shelfRenderer.content, {
        expandedShelfContentsRenderer(renderer) {
            return {
                name: name,
                type: "Shelf",
                videos: switchKeyVideos(renderer.items)
            };
        },
        default() {
            return null;
        }
    });
Koen's avatar
Koen committed
}
function extractContinuationItemRenderer_Continuation(continuationItemRenderer) {
	return  {
		url: continuationItemRenderer.continuationEndpoint.commandMetadata.apiUrl, //TODO: See if this is useful at all
		token: continuationItemRenderer.continuationEndpoint.continuationCommand.token
	};