Skip to content
Snippets Groups Projects
YoutubeScript.js 465 KiB
Newer Older
Koen's avatar
Koen committed
const URL_BASE = "https://www.youtube.com";
const URL_BASE_M = "https://m.youtube.com";
const URL_HOME = "https://www.youtube.com";
Kelvin's avatar
Kelvin committed
const URL_TRENDING = "https://www.youtube.com/feed/trending";
Koen's avatar
Koen committed
const URL_CONTEXT = "https://www.youtube.com";
const URL_CONTEXT_M = "https://m.youtube.com";

const URL_CHANNEL_VIDEOS = "/videos";
const URL_CHANNEL_STREAMS = "/streams";
Kelvin's avatar
Kelvin committed
const URL_CHANNEL_PLAYLISTS = "/playlists";
Koen's avatar
Koen committed
const URL_SEARCH_SUGGESTIONS = "https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&gs_ri=youtube&ds=yt&q=";
const URL_SEARCH = "https://www.youtube.com/youtubei/v1/search";
const URL_BROWSE = "https://www.youtube.com/youtubei/v1/browse";
const URL_BROWSE_MOBILE = "https://m.youtube.com/youtubei/v1/browse";
const URL_NEXT = "https://www.youtube.com/youtubei/v1/next";
const URL_NEXT_MOBILE = "https://m.youtube.com/youtubei/v1/next";
Koen's avatar
Koen committed
const URL_GUIDE = "https://www.youtube.com/youtubei/v1/guide";
const URL_SUB_CHANNELS_M = "https://m.youtube.com/feed/channels";
const URL_SUBSCRIPTIONS_M = "https://m.youtube.com/feed/subscriptions";
const URL_PLAYLIST = "https://youtube.com/playlist?list=";
const URL_PLAYLISTS_M = "https://m.youtube.com/feed/library";

const URL_VERIFY_AGE = URL_BASE + "/youtubei/v1/verify_age";

Kelvin's avatar
Kelvin committed
const URL_CHANNEL_BASE = URL_BASE + "/channel/";

const URL_LIVE_CHAT_HTML = "https://www.youtube.com/live_chat";
Koen's avatar
Koen committed
const URL_LIVE_CHAT = "https://www.youtube.com/youtubei/v1/live_chat/get_live_chat";

const URL_WATCHTIME = "https://www.youtube.com/api/stats/watchtime";

const URL_PLAYER = "https://youtubei.googleapis.com/youtubei/v1/player";

const URL_YOUTUBE_DISLIKES = "https://returnyoutubedislikeapi.com/votes?videoId=";
Kelvin's avatar
Kelvin committed
const URL_YOUTUBE_SPONSORBLOCK = "https://sponsor.ajay.app/api/skipSegments?videoID=";
Koen's avatar
Koen committed

const URL_YOUTUBE_RSS = "https://www.youtube.com/feeds/videos.xml?channel_id=";

Koen's avatar
Koen committed
//Newest to oldest
Kelvin's avatar
Kelvin committed
const CIPHER_TEST_HASHES = ["3400486c", "b22ef6e7", "a960a0cb", "178de1f2", "4eae42b1", "f98908d1", "0e6aaa83", "d0936ad4", "8e83803a", "30857836", "4cc5d082", "f2f137c6", "1dda5629", "23604418", "71547d26", "b7910ca8"];
Koen's avatar
Koen committed
const CIPHER_TEST_PREFIX = "/s/player/";
const CIPHER_TEST_SUFFIX = "/player_ias.vflset/en_US/base.js";

const PLATFORM = "YouTube";
Kelvin's avatar
Kelvin committed
const PLATFORM_CLAIMTYPE = 2;
Koen's avatar
Koen committed

const BROWSE_TRENDING = "FEtrending";
const BROWSE_WHAT_TO_WATCH = "FEwhat_to_watch";
const BROWSE_SUBSCRIPTIONS = "FEsubscriptions";

const SEARCH_CHANNELS_PARAM = "EgIQAg%3D%3D";
const SEARCH_PLAYLISTS_PARAM = "EgIQAw%3D%3D";

const REGEX_VIDEO_URL_DESKTOP = new RegExp("https://(.*\\.)?youtube\\.com/watch.*?v=(.*)");
const REGEX_VIDEO_URL_SHARE = new RegExp("https://youtu\\.be/(.*)");
const REGEX_VIDEO_URL_SHARE_LIVE = new RegExp("https://(.*\\.)?youtube\\.com/live/(.*)");
const REGEX_VIDEO_URL_SHORT = new RegExp("https://(.*\\.)?youtube\\.com/shorts/(.*)");
Kelvin's avatar
Kelvin committed
const REGEX_VIDEO_URL_CLIP = new RegExp("https://(.*\\.)?youtube\\.com/clip/(.*)[?]?");
const REGEX_VIDEO_URL_EMBED = new RegExp("https://(.*\\.)?youtube\\.com/embed/([^?]+)");
Koen's avatar
Koen committed

const REGEX_VIDEO_CHANNEL_URL = new RegExp("https://(.*\\.)?youtube\\.com/channel/(.*)");
Koen's avatar
Koen committed
const REGEX_VIDEO_CHANNEL_URL2 = new RegExp("https://(.*\\.)?youtube\\.com/user/.*");
const REGEX_VIDEO_CHANNEL_URL3 =  new RegExp("https://(.*\\.)?youtube\\.com/@.*");
const REGEX_VIDEO_CHANNEL_URL4 =  new RegExp("https://(.*\\.)?youtube\\.com/c/*");
Koen's avatar
Koen committed

const REGEX_VIDEO_PLAYLIST_URL = new RegExp("https://(.*\\.)?youtube\\.com/playlist\\?list=.*");

const REGEX_INITIAL_DATA = new RegExp("<script.*?var ytInitialData = (.*?);<\/script>");
const REGEX_INITIAL_PLAYER_DATA = new RegExp("<script.*?var ytInitialPlayerResponse = (.*?});");

//TODO: Make this one more flexible/reliable. For now used as fallback if initial fails.
const REGEX_INITIAL_PLAYER_DATA_FALLBACK = new RegExp("<script.*?var ytInitialPlayerResponse = (.*});var meta = document\.createElement");

Koen's avatar
Koen committed
const REGEX_HUMAN_NUMBER = new RegExp("([0-9\\.,]*)([a-zA-Z]*)");
const REGEX_HUMAN_AGO = new RegExp("([0-9]*) ([a-zA-Z]*) ago");

const REGEX_DATE_HUMAN = new RegExp("([A-Za-z]*) ([0-9]*), ([1-9][0-9][0-9][0-9])");
const REGEX_DATE_ISO = new RegExp("([1-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])");
const REGEX_DATE_EU = new RegExp("([0-9][0-9])-([0-9][0-9])-([1-9][0-9][0-9][0-9])");
const REGEX_DATE_US = new RegExp("([0-9][0-9])/([0-9][0-9])/([1-9][0-9][0-9][0-9])");

const REGEX_CONTINUATION = new RegExp("\"continuation\":\"(.*?)\"");

const REGEX_INNERTUBE_KEY = new RegExp("\"INNERTUBE_API_KEY\":\"(.*?)\"");

const REGEX_YTCFG = new RegExp(/ytcfg\.set\((.*?)\);/g);

const REGEX_URL_KIND = new RegExp(/.*?\?kind=([^&]*)/g);
const REGEX_ASR = new RegExp(/<text .*?start="(.*?)" .*?dur="(.*?)".*?>(.*?)<\/text>/gms);

const USER_AGENT_WINDOWS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
const USER_AGENT_PHONE = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.153 Mobile Safari/537.36";
const USER_AGENT_TABLET = "Mozilla/5.0 (iPad; CPU OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1";
Kelvin's avatar
Kelvin committed

const IOS_APP_VERSION = "19.14.3"
const IOS_DEVICE_VERSION = "iPhone15,4"
const IOS_OS_VERSION = "17_4_1"
const IOS_OS_VERSION_DETAILED = "17.4.1.21E237"
const USER_AGENT_IOS = "com.google.ios.youtube/" + IOS_APP_VERSION + "(" + IOS_DEVICE_VERSION + "; U; CPU iOS " + IOS_OS_VERSION + " like Mac OS X; US)";

Koen's avatar
Koen committed
const USER_AGENT_ANDROID = "com.google.android.youtube/17.31.35 (Linux; U; Android 12; US) gzip";
const USER_AGENT_TVHTML5_EMBED = "Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36";

const USE_MOBILE_PAGES = true;
const USE_ANDROID_FALLBACK = false;
const USE_IOS_FALLBACK = true;
Kelvin's avatar
Kelvin committed
const USE_IOS_VIDEOS_FALLBACK = true;

let USE_ABR_VIDEOS = false;
Koen's avatar
Koen committed

const SORT_VIEWS_STRING = "Views";
const SORT_RATING_STRING = "Rating";

var config = {};
var _settings = {};
var _clientContext = {};
var _clientContextAuth = {};
var visitorId = "";
var langDisplayRegion = "en-US";
var langDisplay = "en";
var langRegion = "US";

var _prefetchHome = null;
var _prefetchHomeAuth = null;
var _prefetchHomeUsed = false;

Koen's avatar
Koen committed
function getClientContext(isAuth = false) {
	return (isAuth) ? _clientContextAuth : _clientContext;
}

//#region Source Methods
source.setSettings = function(settings) {
	_settings = settings;
}
source.enable = (conf, settings, saveStateStr) => {
	config = conf ?? {};
	_settings = settings ?? {};
Kelvin's avatar
Kelvin committed
	
	USE_ABR_VIDEOS = !!_settings.useUMP && (bridge.buildSpecVersion ?? 1) > 1;
	log("ABR Enabled: " + USE_ABR_VIDEOS);
Koen's avatar
Koen committed

	//log("Settings:\n" + JSON.stringify(settings, null, "   "));

	let didSaveState = false;
	try {
	    if(saveStateStr) {
	        const saveState = JSON.parse(saveStateStr);
	        if(saveState &&
	            saveState.clientContext &&
	            saveState.clientContextAuth) {
                _clientContext = saveState.clientContext;
                _clientContextAuth = saveState.clientContextAuth;
                _prefetchHomeUsed = true;
                _prefetchHome = undefined;
                _prefetchHomeAuth = undefined;
                didSaveState = true;
                log("Using save state");
	        }
	    }
	}
    catch(ex) {
        log("Failed to parse saveState:" + ex);
        didSaveState = false;
    }
    if(!didSaveState) {
	    log(config);
		log("Settings:\n" + JSON.stringify(_settings, null, "   "));
Koen's avatar
Koen committed

        const isLoggedIn = bridge.isLoggedIn();
        let batchReq = http.batch()
            .GET(URL_CONTEXT, {"Accept-Language": "en-US" }, false);
        if(isLoggedIn)
            batchReq = batchReq.GET(URL_CONTEXT_M, { "User-Agent": USER_AGENT_TABLET, "Accept-Language": "en-US" }, true);
        const batchResp = batchReq.execute();

Koen's avatar
Koen committed
		console.log("batchResp", batchResp);
Kelvin's avatar
Kelvin committed
		throwIfCaptcha(batchResp[0])
		if (!batchResp[0].isOk)
Koen's avatar
Koen committed
			throw new ScriptException("Failed to request context enable !batchResp[0].isOk");
Kelvin's avatar
Kelvin committed

Koen's avatar
Koen committed
        if(isLoggedIn && !batchResp[1].isOk) throw new ScriptException("Failed to request context enable isLoggedIn && !batchResp[1].isOk");
Koen's avatar
Koen committed

        _clientContext = getClientConfig(batchResp[0].body)//requestClientConfig(false);
        if(isLoggedIn) {
            log("Logged in, fetching auth context");
            _clientContextAuth = getClientConfig(batchResp[1].body)//requestClientConfig(USE_MOBILE_PAGES, true);

            _prefetchHomeAuth = getInitialData(batchResp[1].body, true);
        }
        else {
            _clientContextAuth = _clientContext;
            _prefetchHomeAuth = null;
        }
        _prefetchHome = getInitialData(batchResp[0].body, false);
        _prefetchHomeAuth = _prefetchHomeAuth ?? _prefetchHome;
        _prefetchHomeUsed = false;
    }


	let innerContext = _clientContext.INNERTUBE_CONTEXT;
	let innerContextAuth = _clientContextAuth.INNERTUBE_CONTEXT;

	if(IS_TESTING)
		console.log("Context", innerContext);

	if(innerContext && innerContext.client) {
		innerContext.client.hl = langDisplay;
		innerContext.client.gl = langRegion;
		innerContext.client.visitorData = undefined;
	}
	if(innerContextAuth && innerContextAuth.client) {
		innerContextAuth.client.hl = langDisplay;
		innerContextAuth.client.gl = langRegion;
		innerContextAuth.client.visitorData = undefined;
	}

	return _clientContextAuth;
};

source.saveState = () => {
    return JSON.stringify({
        clientContext: _clientContext,
        clientContextAuth: _clientContextAuth
    });
};


//Home
source.getHome = () => {
    let initialData = null;
    if(!_prefetchHomeUsed && _prefetchHomeAuth != null) {
        log("Using pre-fetched Home Page")
        initialData = _prefetchHomeAuth;
        _prefetchHomeUsed = true;
    }
    else if(bridge.isLoggedIn())
        initialData = requestInitialData(URL_CONTEXT_M, USE_MOBILE_PAGES, true);
	else
		initialData = requestInitialData(URL_HOME, USE_MOBILE_PAGES, true);
Koen's avatar
Koen committed
	const tabs = extractPage_Tabs(initialData);
	if(tabs.length == 0) {
        if(bridge.devSubmit) bridge.devSubmit("getHome - No tabs found..", JSON.stringify(initialData));
Koen's avatar
Koen committed
		throw new ScriptException("No tabs found..");
Kelvin's avatar
Kelvin committed
    if(tabs[0].videos.length > 0)
	    return new RichGridPager(tabs[0], {}, USE_MOBILE_PAGES, true);
    else
        return source.getTrending();
};

source.getTrending = () => {
    let initialData = requestInitialData(URL_TRENDING, USE_MOBILE_PAGES, false);
    if(IS_TESTING)
        console.log("getTrending initialData", initialData);
	const tabs = extractPage_Tabs(initialData);
	if(tabs.length == 0) {
        if(bridge.devSubmit) bridge.devSubmit("getTrending - No tabs found..", JSON.stringify(initialData));
Kelvin's avatar
Kelvin committed
		throw new ScriptException("No tabs found..");
Kelvin's avatar
Kelvin committed
	let tab = tabs[0];
	if (tab.videos.length === 0) {
		if (tab.shelves.length > 0) {
			tab = tab.shelves[0];
		}
	}
Kelvin's avatar
Kelvin committed
	return new RichGridPager(tab, {}, USE_MOBILE_PAGES, false);
Koen's avatar
Koen committed
};

Koen's avatar
Koen committed
//Search
source.searchSuggestions = (query) => {
	const suggestionsResp = http.GET(URL_SEARCH_SUGGESTIONS + query + "&hl=" + langDisplay.toLowerCase() + "&gl=" + langRegion.toLowerCase(), {});
	if(!suggestionsResp.isOk)
		throw new ScriptException("Failed to get suggestions");
	const suggestionsRaw = suggestionsResp.body;

	const startIndex = suggestionsRaw.indexOf("(") + 1;
	if(startIndex < 0) throw new ScriptException("Failed to filter suggestions (startIndex)");
	const endIndex = suggestionsRaw.lastIndexOf(")");
	if(endIndex < 0) throw new ScriptException("Failed to filter suggestions (endIndex)");
	const suggestions = JSON.parse(suggestionsRaw.substring(startIndex, endIndex));
	if(suggestions && suggestions.length >= 2) {
		if(suggestions[1] && suggestions[1].length > 0)
			return suggestions[1].filter(x=>x.length == 3).map(x=>x[0]);
	}

	return [];
};
source.getSearchCapabilities = () => {
	return {
		types: [Type.Feed.Videos, Type.Feed.Live],
		sorts: [Type.Order.Chronological, SORT_VIEWS_STRING, SORT_RATING_STRING],
		filters: FILTERS
	};
}
source.searchQuery = function(type, order, filters) {
	return searchQueryToSP(order, type, filters);
};
source.search = function(query, type, order, filters) {
	const param = (order || (filters && Object.keys(filters).length > 0 )) ?
		searchQueryToSP(order, type, filters) :
		null;
	if(IS_TESTING && param)
		console.log("Search Param:", param);

	const data = requestSearch(query, false, param);
	const searchResults = extractSearch_SearchResults(data);

	if(searchResults.videos)
		return new SearchItemSectionVideoPager(searchResults);
	return [];
};
source.searchChannels = function(query) {
    const data = requestSearch(query, false, SEARCH_CHANNELS_PARAM);
    const searchResults = extractSearch_SearchResults(data);

    if(searchResults.channels)
        return new SearchItemSectionChannelPager(searchResults);
    return [];
};

source.getSearchChannelContentsCapabilities = function(){ return { types: [Type.Feed.Mixed], sorts: [] }; }
source.searchChannelContents = function(channelUrl, query, type, order, filters) {
	const initialData = requestInitialData(channelUrl + "/search?query=" + query, USE_MOBILE_PAGES, true);
	const tabs = extractPage_Tabs(initialData, {});

	if(IS_TESTING) {
		console.log("Initial Data", initialData);
		console.log("Tabs", tabs);
	}

	const tab = tabs.find(x=>x.title == "Search");

	if(tab)
		return new RichGridPager(tab, {}, USE_MOBILE_PAGES, true);
	else {
        if(bridge.devSubmit) bridge.devSubmit("searchChannelContents - No search tab found", JSON.stringify(initialData));
Koen's avatar
Koen committed
		throw new ScriptException("No search tab found");
Koen's avatar
Koen committed
}

Kelvin's avatar
Kelvin committed
source.getChannelUrlByClaim = (claimType, claimValues) => {
Koen's avatar
Koen committed
	const values = claimValues.values();
	if (values.length == 0)
		return null;
	const atName = values.find(x => x.startsWith("@"));
	if (atName)
		return URL_BASE + "/" + atName;
	else
		return URL_BASE + "/channel/" + values[0];
};
source.getChannelTemplateByClaimMap = () => {
Koen's avatar
Koen committed
	return {
		//Youtube
		2: {
			0: URL_BASE + "/{{CLAIMVALUE}}",
			1: URL_BASE + "/channel/{{CLAIMVALUE}}",
		}
	};
Koen's avatar
Koen committed
//Video
source.isContentDetailsUrl = (url) => {
Kelvin's avatar
Kelvin committed
	return REGEX_VIDEO_URL_DESKTOP.test(url) || REGEX_VIDEO_URL_SHARE.test(url) || REGEX_VIDEO_URL_SHARE_LIVE.test(url) || REGEX_VIDEO_URL_SHORT.test(url) || REGEX_VIDEO_URL_CLIP.test(url) || REGEX_VIDEO_URL_EMBED.test(url);
Koen's avatar
Koen committed
};


Kelvin's avatar
Kelvin committed
if(false && (bridge.buildSpecVersion ?? 1) > 1) {
	//TODO: Implement more compact version using new api batch spec
}
else {
	source.getContentDetails = (url, useAuth, simplify) => {
		useAuth = !!_settings?.authDetails || !!useAuth;
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		log("ABR Enabled: " + USE_ABR_VIDEOS);
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		url = convertIfOtherUrl(url);
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		const clientContext = getClientContext(false);
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		const videoId = extractVideoIDFromUrl(url);
		if(IS_TESTING)
			console.log("VideoID:", videoId);
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		const useLogin = useAuth && bridge.isLoggedIn();

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

		let batchCounter = 1;
		const batch = http.batch()
			.GET(url, headersUsed, useLogin);
Koen's avatar
Koen committed
		
Kelvin's avatar
Kelvin committed
		let batchYoutubeDislikesIndex = -1;
		if(videoId && _settings["youtubeDislikes"] && !simplify) {
			batch.GET(URL_YOUTUBE_DISLIKES + videoId, {}, false);
			batchYoutubeDislikesIndex = batchCounter++;
		}

		let batchIOS = -1;
		if(USE_IOS_VIDEOS_FALLBACK) {
			requestIOSStreamingData(videoId, batch);
			batchIOS = batchCounter++;
		}

		const resps = batch.execute();

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

		let html = resps[0].body;//requestPage(url);
		let initialData = getInitialData(html);
		let initialPlayerData = getInitialPlayerData(html);
		let clientConfig = getClientConfig(html);
		
		let ageRestricted = initialPlayerData.playabilityStatus?.reason?.indexOf("your age") > 0 ?? false;
		if (initialPlayerData.playabilityStatus?.status == "LOGIN_REQUIRED" && (bridge.isLoggedIn() || !ageRestricted)) {
Kelvin's avatar
Kelvin committed
			if(!!_settings?.allowLoginFallback && !useLogin) {
				bridge.toast("Using login fallback to resolve:\n" + initialPlayerData?.playabilityStatus?.reason);
				resps[0] = http.GET(url, headersUsed, true);

				html = resps[0].body;//requestPage(url);
				initialData = getInitialData(html);
				initialPlayerData = getInitialPlayerData(html);
				clientConfig = getClientConfig(html);
Kelvin's avatar
Kelvin committed
				if (initialPlayerData.playabilityStatus?.status == "LOGIN_REQUIRED")
					throw new ScriptLoginRequiredException("Login required\nReason: " + initialPlayerData?.playabilityStatus?.reason);
Kelvin's avatar
Kelvin committed
			else
				throw new ScriptLoginRequiredException("Login required\nReason: " + initialPlayerData?.playabilityStatus?.reason);
Kelvin's avatar
Kelvin committed
		const invalidExperiments = [51217102, 51217476];
		var invalidExperimentIndexes = invalidExperiments.map(x=>clientConfig.FEXP_EXPERIMENTS.indexOf(x));
		const isExperiment = clientConfig.FEXP_EXPERIMENTS && invalidExperimentIndexes.filter(x=>x >= 0).length > 0;
Koen's avatar
Koen committed


		if(initialPlayerData?.playabilityStatus?.status == "UNPLAYABLE")
Kelvin's avatar
Kelvin committed
			throw new UnavailableException("Video unplayable");
		
		const jsUrlMatch = html.match("PLAYER_JS_URL\"\\s?:\\s?\"(.*?)\"");
		const jsUrl = (jsUrlMatch) ? jsUrlMatch[1] : clientContext.PLAYER_JS_URL;
		const isNewCipher = prepareCipher(jsUrl);

		ageRestricted = initialPlayerData.playabilityStatus?.reason?.indexOf("your age") > 0 ?? false;
Kelvin's avatar
Kelvin committed
		if (ageRestricted) {
			if (_settings["allowAgeRestricted"]) {
				const sts = _sts[jsUrl];
				if (!initialPlayerData.streamingData && sts) {
					initialPlayerData = requestTvHtml5EmbedStreamingData(initialPlayerData.videoDetails.videoId, sts);
					console.log("Filled missing streaming data using TvHtml5Embed.");
					if(initialPlayerData.playabilityStatus?.reason?.indexOf("sign in") >= 0){
						throw new ScriptLoginRequiredException("Age restricted video requires login to retrieve"); 
					}
Kelvin's avatar
Kelvin committed
				}
			} else {
				throw new AgeException("Age restricted videos can be allowed using the plugin settings");
Koen's avatar
Koen committed
			}
		}
Kelvin's avatar
Kelvin committed
		const controversial = initialPlayerData.playabilityStatus?.errorScreen?.playerErrorMessageRenderer?.reason?.simpleText?.indexOf("following content may") > 0 ?? false;
		if(controversial) {
			if (_settings["allowControversialRestricted"]) {
				bridge.toast("Controversial video, bypassing..");
Kelvin's avatar
Kelvin committed
				const sts = _sts[jsUrl];
				if (!initialPlayerData.streamingData && sts) {
					initialPlayerData = requestTvHtml5EmbedStreamingData(initialPlayerData.videoDetails.videoId, sts);
					console.log("Filled missing streaming data using TvHtml5Embed.");
					if(initialPlayerData.playabilityStatus?.status == "UNPLAYABLE") {
						if(bridge.isLoggedIn() && !!_settings?.allowLoginFallback) {
							bridge.toast("Bypass failed, trying login fallback");
							initialPlayerData = verifyAgePlayerData(videoId, sts, true);
						}
						else if(initialPlayerData.playabilityStatus?.reason?.indexOf("sign in") >= 0){
							throw new ScriptLoginRequiredException("Controversial video requires login to retrieve"); 
						}

						if(initialPlayerData.playabilityStatus?.status == "UNPLAYABLE") {
							throw new ScriptException("Bypass failed due to:\n" + initialPlayerData.playabilityStatus?.reason);
						}
					}
Kelvin's avatar
Kelvin committed
				}
			} else {
				throw new UnavailableException("Controversial restricted videos can be allowed using the plugin settings");
Koen's avatar
Koen committed
		
Kelvin's avatar
Kelvin committed
		if(IS_TESTING) {
			console.log("Initial Data", initialData);
			console.log("Initial Player Data", initialPlayerData);
		}
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		let creationData = {
			url: url,
			initialData: initialData,
			initialPlayerData: initialPlayerData,
			jsUrl: jsUrl
		};
Kelvin's avatar
Kelvin committed
		const videoDetails = extractVideoPage_VideoDetails(initialData, initialPlayerData, {
			url: url
		}, jsUrl, useLogin);
		if(videoDetails == null)
			throw new UnavailableException("No video found");

		if(!videoDetails.live && 
			(videoDetails.video?.videoSources == null || videoDetails.video.videoSources.length == 0) &&
			(!videoDetails.datetime || videoDetails.datetime < (((new Date()).getTime() / 1000) - 60 * 60))) {
			if(isNewCipher) {
				log("Unavailable video found with new cipher, clearing cipher");
				clearCipher(jsUrl);
			}
			if(!simplify)
				throw new UnavailableException("No sources found");
Kelvin's avatar
Kelvin committed
		}
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		//Substitute Dash manifest from Android
		if(USE_ANDROID_FALLBACK && videoDetails.dash && videoDetails.dash.url) {
			const androidData = requestAndroidStreamingData(videoDetails.id.value);
			if(IS_TESTING)
				console.log("Android Streaming Data", androidData);
			if(androidData?.streamingData?.dashManifestUrl) {
				log("Using Android dash substitute");
				const existingUrl = videoDetails.dash.url;
				videoDetails.dash.url = androidData.streamingData.dashManifestUrl;
				if(existingUrl == videoDetails.live?.url)
					videoDetails.live.url = androidData.streamingData.dashManifestUrl;
			}
Koen's avatar
Koen committed
		}
Kelvin's avatar
Kelvin committed
		//Substitute HLS manifest from iOS
		if(USE_IOS_FALLBACK && videoDetails.hls && videoDetails.hls.url && !simplify) {
			const iosDataResp = (batchIOS > 0) ?
				resps[batchIOS] : 
				requestIOSStreamingData(videoDetails.id.value);
				
			if(iosDataResp.isOk) {
				const iosData = JSON.parse(iosDataResp.body);
				if(IS_TESTING)
					console.log("IOS Streaming Data", iosData);
				if(iosData?.streamingData?.hlsManifestUrl) {
					log("Using iOS HLS substitute");
					const existingUrl = videoDetails.hls.url;
					videoDetails.hls.name = "HLS (IOS)";
					videoDetails.hls.url = iosData.streamingData.hlsManifestUrl;
					if(existingUrl == videoDetails.live?.url) {
						videoDetails.live.name = "HLS (IOS)";
						videoDetails.live.url = iosData.streamingData.hlsManifestUrl;
					}
				}
Kelvin's avatar
Kelvin committed
			else
				bridge.toast("Failed to get iOS stream data");
Kelvin's avatar
Kelvin committed
		else if(USE_IOS_VIDEOS_FALLBACK && !USE_ABR_VIDEOS && !simplify) {
			const iosDataResp = (batchIOS > 0) ?
				resps[batchIOS] : 
				requestIOSStreamingData(videoDetails.id.value);
			if(iosDataResp.isOk) {
				const iosData = JSON.parse(iosDataResp.body);
				if(IS_TESTING)
					console.log("IOS Streaming Data", iosData);
Kelvin's avatar
Kelvin committed
				if(iosData?.streamingData?.adaptiveFormats) {
					if(!!_settings["showVerboseToasts"])
					bridge.toast("Using iOS sources fallback (" + (batchIOS > 0 ? "cached" : "lazily") + ")");
Kelvin's avatar
Kelvin committed
					let newDescriptor = extractAdaptiveFormats_VideoDescriptor(iosData.streamingData.adaptiveFormats, jsUrl, creationData, "IOS ");
					videoDetails.video = newDescriptor;
				}
				else {
					log("Failed to get iOS stream data, fallback to UMP")
					bridge.toast("Failed to get iOS stream data, fallback to UMP");
					videoDetails.video = extractABR_VideoDescriptor(initialPlayerData, jsUrl) ?? new VideoSourceDescriptor([]);
				}
			}
			else {
				log("Failed to get iOS stream data, fallback to UMP (" + iosDataResp?.code + ")")
				bridge.toast("Failed to get iOS stream data, fallback to UMP");
				videoDetails.video = extractABR_VideoDescriptor(initialPlayerData, jsUrl) ?? new VideoSourceDescriptor([]);
Kelvin's avatar
Kelvin committed
			}
Koen's avatar
Koen committed
		}

Kelvin's avatar
Kelvin committed
		if(batchYoutubeDislikesIndex > 0) {
			try {
				const youtubeDislikeInfoResponse = resps[batchYoutubeDislikesIndex]
				if(youtubeDislikeInfoResponse.isOk) {
					const youtubeDislikeInfo = JSON.parse(youtubeDislikeInfoResponse.body);
					if(IS_TESTING)
						console.log("Youtube Dislike Info", youtubeDislikeInfo);
					videoDetails.rating = new RatingLikesDislikes(videoDetails.rating.likes, youtubeDislikeInfo.dislikes);
				}
			}
			catch(ex) {
				console.log("Failed to fetch Youtube Dislikes", ex);
			}
		}
Koen's avatar
Koen committed

Kelvin's avatar
Kelvin committed
		const finalResult = videoDetails;
		finalResult.__initialData = initialData;
		if(!!_settings["youtubeActivity"] && useLogin) {
			finalResult.__playerData = initialPlayerData;
			finalResult.getPlaybackTracker = function(url) {
				return source.getPlaybackTracker(url, initialPlayerData)
			};
		}
		finalResult.getContentChapters = function() {
			return source.getContentChapters(url, finalResult.__initialData);
Koen's avatar
Koen committed
		};

Kelvin's avatar
Kelvin committed
		finalResult.getContentRecommendations = function() {
			const initialData = finalResult.__initialData;
			if(!initialData)
				return new VideoPager([], false);
			return source.getContentRecommendations(url, initialData);
		}
Kelvin's avatar
Kelvin committed
		return finalResult;
	};
}
function isVerifyAge(initialPlayerData){
	return (initialPlayerData.playabilityStatus.status == "CONTENT_CHECK_REQUIRED")
}
function verifyAgePlayerData(videoId, sts, useLogin = true) {
	/*
	const context = getClientContext(useLogin);
	const authHeaders = useLogin ? getAuthContextHeaders(false) : {};
	authHeaders["Accept-Language"] = "en-US";
	authHeaders["Cookie"] = "PREF=hl=en&gl=US"
	const body = {
		"context": context.INNERTUBE_CONTEXT,
		"nextEndpoint": {
		  "watchEndpoint": {
			"videoId": videoId,
			"playerParams": "QAA%3D",
			"racyCheckOk": true,
			"contentCheckOk": true
		  }
		},
		"setControvercy": true
	  };
	const resp = http.POST(URL_VERIFY_AGE, JSON.stringify(body), authHeaders, useLogin);
	if(!resp.isOk)
		throw new ScriptException("Failed to verify age");
	*/
	return getPlayerData(videoId, sts, useLogin);
}
function getPlayerData(videoId, sts, useLogin = true) {
	const context = getClientContext(useLogin);
	const authHeaders = useLogin ? getAuthContextHeaders(false) : {};
	authHeaders["Accept-Language"] = "en-US";
	authHeaders["Cookie"] = "PREF=hl=en&gl=US"
	const body = {
		contentCheckOk: true,
		context: context.INNERTUBE_CONTEXT,
		params: "QAA%3D",
		playbackContext: {
			contentPlaybackContext: {
				autoCaptionsDefaultOne: false,
				autonavState: "STATE_OFF",
				currentUrl: "/watch?v=" + videoId + "&pp=QAA%3D&rco=1",
				html5Preference: "HTML5_PREF_WANTS",
				lactMilliseconds: "-1",
				referer: "https://www.youtube.com/watch?v=" + videoId,
				signatureTimestamp: parseInt(sts),
				splay: false,
				vis: 0,
				watchAmbientModeContext: {hasShownAmbientMode: true, watchAmbientModeEnabled: true}
			}
		},
		racyCheckOk: true,
		videoId: videoId
	}
	const newResp = http.POST("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", JSON.stringify(body), authHeaders, useLogin);
	if(!newResp.isOk) {
		console.log(newResp.body);
		throw new ScriptException("Failed to fetch player data");
	}
	const json = JSON.parse(newResp.body);
	return json;
}


function getSkipTypeSetting(setting){
	//Overkill comparisons to catch any edgecases from old value types.
Kelvin's avatar
Kelvin committed
	const val = _settings[setting];
	if(val === "true" || val === "True" || val === true)
		return Type.Chapter.SKIPPABLE;
	if(val === "false" || val === "False" || val === false || isNaN(val))
		return Type.Chapter.NORMAL;
	const valInt = parseInt(val);
	if(isNaN(valInt)) //Cuz IsNaN(false) == false && isNaN(parseInt(false)) == true
		return Type.Chapter.NORMAL;
	//Very explicit to prevent edge cases from skipping.
	if(valInt < 1)
		return Type.Chapter.NORMAL;
	if(valInt === 1)
		return Type.Chapter.SKIPPABLE;
	if(valInt === 2)
		return Type.Chapter.SKIP;
	else
		return Type.Chapter.NORMAL;
Kelvin's avatar
Kelvin committed
source.getContentChapters = function(url, initialData) {
Kelvin's avatar
Kelvin committed
    //return [];
Kelvin's avatar
Kelvin committed
    if(REGEX_VIDEO_URL_CLIP.test(url)) {
		const videoPage = http.GET(url, getRequestHeaders({}), false);

		if(videoPage.isOk && throwIfCaptcha(videoPage)) {
		    const initialData = getInitialData(videoPage.body);
		    const playerData = getInitialPlayerData(videoPage.body);

		    console.log("Clip data", playerData?.clipConfig);
		    const clipConfig = playerData?.clipConfig;
		    if(clipConfig?.endTimeMs && clipConfig?.startTimeMs) {
		        const startTime = parseInt(clipConfig.startTimeMs) / 1000;
		        const endTime = parseInt(clipConfig.endTimeMs) / 1000;
		        return [
		            {
                        name: "Non-Clip",
                        timeStart: 0,
                        timeEnd: startTime,
                        type: Type.Chapter.SKIPPABLE
                    },
		            {
                        name: "Clip",
                        timeStart: startTime,
                        timeEnd: endTime,
                        type: Type.Chapter.NORMAL
                    },
		            {
                        name: "Non-Clip",
                        timeStart: endTime,
                        timeEnd: 99999999,
                        type: Type.Chapter.SKIPPABLE
                    },
		        ];
		    }
		    else
		        return [];
		}
		else return [];
    }
Kelvin's avatar
Kelvin committed

    const videoId = extractVideoIDFromUrl(url);

    let sbResp = null;
    const sbChapters = [];

	const skipCategoryTypes = {
		"sponsor": getSkipTypeSetting("sponsorBlockCat_Sponsor"),
		"intro": getSkipTypeSetting("sponsorBlockCat_Intro"),
		"outro": getSkipTypeSetting("sponsorBlockCat_Outro"),
		"selfpromo": getSkipTypeSetting("sponsorBlockCat_Self"),
		"music_offtopic": getSkipTypeSetting("sponsorBlockCat_Offtopic"),
		"preview": getSkipTypeSetting("sponsorBlockCat_Preview"),
		"filler": getSkipTypeSetting("sponsorBlockCat_Filler"),
		other: Type.Chapter.SKIPPABLE
	}

Kelvin's avatar
Kelvin committed
	if(initialData == null) {
Kelvin's avatar
Kelvin committed
		const reqs = http.batch()
		    .GET(url, getRequestHeaders({}), false);

        if(_settings["sponsorBlock"] && videoId) {
			const cats = [
Kelvin's avatar
Kelvin committed
				(skipCategoryTypes.sponsor && skipCategoryTypes.sponsor >= 1) ? "sponsor" : null,
				(skipCategoryTypes.intro && skipCategoryTypes.intro >= 1) ? "intro" : null,
				(skipCategoryTypes.outro && skipCategoryTypes.outro >= 1) ? "outro" : null,
				(skipCategoryTypes.selfpromo && skipCategoryTypes.selfpromo >= 1) ? "selfpromo" : null,
				(skipCategoryTypes.music_offtopic && skipCategoryTypes.music_offtopic >= 1) ? "music_offtopic" : null,
				(skipCategoryTypes.preview && skipCategoryTypes.preview >= 1) ? "preview" : null,
				(skipCategoryTypes.filler && skipCategoryTypes.filler >= 1) ? "filler" : null
			].filter(x=>!!x);
			const catsArg = "&categories=[" + cats.map(x=>"\"" + x + "\"").join(",") + "]";
            reqs.GET(URL_YOUTUBE_SPONSORBLOCK + videoId + catsArg, {}, false);
Kelvin's avatar
Kelvin committed
			log("Sponsorblock Config:\n" + JSON.stringify(skipCategoryTypes, undefined, "   "));
Kelvin's avatar
Kelvin committed

        const resps = reqs.execute();

		if(resps[0].isOk && throwIfCaptcha(resps[0]))
		    initialData = getInitialData(resps[0].body);
		else
		    throw ScriptException("Failed to get chapters (" + resps[0].code + ")");

        if(_settings["sponsorBlock"] && videoId)
            sbResp = resps[1];
Kelvin's avatar
Kelvin committed
	else if(_settings["sponsorBlock"] && videoId)
	    sbResp = http.GET(URL_YOUTUBE_SPONSORBLOCK + videoId, {}, false);

	if(sbResp && sbResp.isOk) {
	    try {
Kelvin's avatar
Kelvin committed
	        const allowNoVoteSkip = !!(_settings["sponsorBlockNoVotes"]);
	        const skipType = (_settings["sponsorBlockType"]) ? Type.Chapter.SKIP : Type.Chapter.SKIPPABLE;
	        const sbData = JSON.parse(sbResp.body);
Kelvin's avatar
Kelvin committed
	        for(let block of sbData) {
				let sponsorConfiguredType = skipType;
Kelvin's avatar
Kelvin committed
				if(isNaN(skipCategoryTypes[block.category])) {
					sponsorConfiguredType = Type.Chapter.SKIPPABLE;
Kelvin's avatar
Kelvin committed
				}
				else
					sponsorConfiguredType = skipCategoryTypes[block.category];

Kelvin's avatar
Kelvin committed
	            if(block.actionType == "skip" &&
	                block.segment && block.segment.length == 2 &&
	                (allowNoVoteSkip || block.votes >= 1)) {
	                sbChapters.push({
	                    name: block.category,
Kelvin's avatar
Kelvin committed
	                    timeStart: parseFloat(block.segment[0]),
	                    timeEnd: parseFloat(block.segment[1]),
Kelvin's avatar
Kelvin committed
	                    type: sponsorConfiguredType
			log("Sponsorblock Chapters:\n" + JSON.stringify(sbChapters, null, "   "));
Kelvin's avatar
Kelvin committed
	    }
	    catch(ex) {
	        //SB Failed
	        log("SB Failed (" + sbResp.code + "): " + ex);
	    }
	}

	let videoChapters = [];
Kelvin's avatar
Kelvin committed

    const queryParams = parseQueryString(url);
    if(Type.Chapter.SKIPONCE && queryParams["t"]) {
        const initialSkip = parseInt(queryParams["t"]);
        if(!isNaN(initialSkip)) {
            videoChapters.push({
                name: "InitialSkip",
                timeStart: parseFloat(-1),
                timeEnd: parseFloat(initialSkip),
                type: Type.Chapter.SKIPONCE
            });
        }
    }

Kelvin's avatar
Kelvin committed
	try {
Kelvin's avatar
Kelvin committed
	    videoChapters = videoChapters.concat(extractVideoChapters(initialData) ?? []);
Kelvin's avatar
Kelvin committed
	}
	catch(ex) {
	    //Chapters failed
	}

    //Merge chapters
	if(videoChapters.length > 0 && sbChapters.length > 0)
	    return mergeSBChapters(videoChapters, sbChapters);
	else if(videoChapters.length > 0)
	    return videoChapters;
	else if(sbChapters.length > 0)
	    return sbChapters;
	else
	    return [];
}
function mergeSBChapters(videoChapters, sbChapters) {
    let newChapters = [];
	for(let videoChapter of videoChapters) {
	    const sponsors = sbChapters.filter(x=>
	        x.timeStart >= videoChapter.timeStart &&
Kelvin's avatar
Kelvin committed
	        x.timeStart <= videoChapter.timeEnd);
Kelvin's avatar
Kelvin committed
	    if(sponsors.length > 0) {
	        let startTime = videoChapter.timeStart;
	        let skip = false;
	        for(let sponsorI = 0; sponsorI < sponsors.length && !skip; sponsorI++) {
	            const sponsor = sponsors[sponsorI];
	            const nextSponsor = (sponsorI + 1 < sponsors.length) ? sponsors[sponsorI + 1] : null;

                const videoChapterBefore = {
                    name: videoChapter.name,
                    timeStart: startTime,
                    timeEnd: sponsor.timeStart,
                    type: videoChapter.type
                };
                const videoChapterAfter = {
                    name: videoChapter.name,
                    timeStart: sponsor.timeEnd,
                    timeEnd: (nextSponsor != null) ? nextSponsor.timeStart :  videoChapter.timeEnd,
                    type: videoChapter.type
                };

                if(sponsor.timeStart <= startTime && sponsor.timeEnd <= videoChapter.timeEnd) {
                    newChapters.push(sponsor);
                    skip = true;
                }
                else if(sponsor.timeStart <= startTime) {
                    newChapters.push(sponsor);
                    newChapters.push(videoChapterAfter);
                    startTime = videoChapterAfter.timeEnd;
                }
                else {
                    newChapters.push(videoChapterBefore);
                    newChapters.push(sponsor);
Kelvin's avatar
Kelvin committed
                    if(videoChapterAfter.timeStart < videoChapterAfter.timeEnd) {
                        newChapters.push(videoChapterAfter);
                        startTime = videoChapterAfter.timeEnd;
                    }
                    else startTime = videoChapterAfter.timeStart;
Kelvin's avatar
Kelvin committed
                }
	        }
	    }
	    else
	        newChapters.push(videoChapter);
    }
    return newChapters;
}
function extractVideoChapters(initialData) {
Kelvin's avatar
Kelvin committed
	let rawObjects = initialData?.playerOverlays?.playerOverlayRenderer?.decoratedPlayerBarRenderer;
	if(rawObjects?.decoratedPlayerBarRenderer)
Kelvin's avatar
Kelvin committed
	    rawObjects = rawObjects.decoratedPlayerBarRenderer?.playerBar?.multiMarkersPlayerBarRenderer?.markersMap;
	else
	    rawObjects = rawObjects.playerBar?.multiMarkersPlayerBarRenderer?.markersMap;

Kelvin's avatar
Kelvin committed
	if(!rawObjects || rawObjects.length == 0)
Kelvin's avatar
Kelvin committed
		return [];

    const chapters = rawObjects.find(x=>x.key == "DESCRIPTION_CHAPTERS") ?? rawObjects.find(x=>x.key == "AUTO_CHAPTERS");
Kelvin's avatar
Kelvin committed
    if(chapters?.value?.chapters == null)
        return [];

    let result = [];
    const validChapters = chapters.value.chapters.filter(x=> x.chapterRenderer &&  x.chapterRenderer.title && (x.chapterRenderer.timeRangeStartMillis || x.chapterRenderer.timeRangeStartMillis === 0))
    for(let i = 0; i < validChapters.length; i++) {
        const chapter = validChapters[i]?.chapterRenderer;
        const chapterNext = (i + 1 < validChapters.length) ? validChapters[i + 1]?.chapterRenderer : null;

        const resultChapter = {
            name: extractText_String(chapter.title),
            timeStart: parseInt(chapter.timeRangeStartMillis / 1000),
            timeEnd: (chapterNext?.timeRangeStartMillis) ? parseInt(chapterNext.timeRangeStartMillis / 1000) : 999999, //Easier than re-parsing video end,
            type: Type.Chapter.NORMAL
        };
        result.push(resultChapter);
    }

    return result;
function getVideoDetailsHtml(url, useLogin) {
	const shouldUseLogin = useLogin && bridge.isLoggedIn();
	const headersUsed = (shouldUseLogin) ? getAuthContextHeaders(false) : {};
	headersUsed["Accept-Language"] = "en-US";

	const result = http.GET(url, headersUsed, shouldUseLogin);
	if(result.isOk)
	    return result.body;
	else
	    throw new ScriptException("Failed to get video details [" + url + "] (" + result.code + ")");
}
Koen's avatar
Koen committed
source.getLiveChatWindow = function(url) {
	const id = extractVideoIDFromUrl(url);
	if(!id)
		throw new ScriptException("No valid id found");

	let chatUrl = URL_LIVE_CHAT_HTML + "?v=" + id;
    if(bridge.isLoggedIn()) {
        try {
            //Try live version
            const html = getVideoDetailsHtml(url, true);
            const initialData = getInitialData(html)

            const continuations = initialData?.contents?.twoColumnWatchNextResults?.conversationBar?.liveChatRenderer?.continuations;
            if(continuations) {
                const continuation = continuations.find(x=>x.reloadContinuationData?.continuation);
                if(continuation) {
                    chatUrl = URL_LIVE_CHAT_HTML + "?continuation=" + continuation.reloadContinuationData?.continuation;

                }
            }
        }
        catch(ex) {
            log("Failed to get live chat window continuation, fallback to standard\n" + ex)
        }
    }

Koen's avatar
Koen committed

Koen's avatar
Koen committed
	const chatHtmlResp = http.GET(chatUrl, {}, false);
	if(!chatHtmlResp.isOk)
		return null;
	else {
		return {
			url: chatUrl,
			removeElements: [ "yt-live-chat-header-renderer", "#ticker" ]
		};
	}
}
Koen's avatar
Koen committed
source.getLiveEvents = function(url) {
	const id = extractVideoIDFromUrl(url);
	if(!id)
		throw new ScriptException("No valid id found");

	const chatHtmlResp = http.GET(URL_LIVE_CHAT_HTML + "?v=" + id, {}, false);