Skip to content
Snippets Groups Projects
YoutubeScript.js 458 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";

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);

        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;

Koen's avatar
Koen committed
	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

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.");
				}
			} 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"]) {
				const sts = _sts[jsUrl];
				if (!initialPlayerData.streamingData && sts) {
					initialPlayerData = requestTvHtml5EmbedStreamingData(initialPlayerData.videoDetails.videoId, sts);
					console.log("Filled missing streaming data using TvHtml5Embed.");
				}
			} 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 getSkipTypeSetting(setting){
	const val = _settings["setting"];
	if(val == "true" || val == "True")
		return Type.Chapter.SKIPPABLE;
	if(val == "false" || val == "False" || isNaN(val))
		return Type.Chapter.NORMAL;
	const valInt = parseInt(val);
	if(valInt < 1)
		return Type.Chapter.NORMAL;
	if(valInt == 1)
		return Type.Chapter.SKIPPABLE;
	return Type.Chapter.SKIP;
}

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 = [
				(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

        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
	}
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;
				if(isNaN(skipCategoryTypes[block.category]))
					sponsorConfiguredType = Type.Chapter.SKIPPABLE;
				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: skipType
	                });
	            }
	        }
	    }
	    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;
Kelvin's avatar
Kelvin committed
}
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);
Koen's avatar
Koen committed
	if(!chatHtmlResp.isOk)
		throw new ScriptException("Failed to get chat html");
	const chatHtml = chatHtmlResp.body;


	const matchKey = chatHtml.match(REGEX_INNERTUBE_KEY);
	if(!matchKey || matchKey.length < 1)
		throw new ScriptException("Live chat key not found");

	const matchContinuation = chatHtml.match(REGEX_CONTINUATION);
	if(!matchContinuation || matchContinuation.length < 1)
		throw new ScriptException("Live chat continuation not found");

	return new YTLiveEventPager(matchKey[1], matchContinuation[1]);
}

source.getPlaybackTracker = function(url, initialPlayerData) {
	if(!_settings["youtubeActivity"] || !bridge.isLoggedIn())
		return null;
	if(!initialPlayerData) {
		const video = source.getContentDetails(url, true, true);
Koen's avatar
Koen committed
		initialPlayerData = video.__playerData;
		if(!initialPlayerData)
			throw new ScriptException("No playerData for playback tracker");
	}
	return new YoutubePlaybackTracker(initialPlayerData);
}
class YoutubePlaybackTracker extends PlaybackTracker {
	constructor(playerData) {
		super(10 * 1000);
		this.playerData = playerData;

		this.cpn = randomString(16);
		this.idpj = -Math.floor(10 * Math.random());
		this.ldpj = -Math.floor(40 * Math.random());
		this.lastPosition = 0;
		
		this.watchUrl = playerData.playbackTracking?.videostatsWatchtimeUrl?.baseUrl;
		this.playbackUrl = playerData.playbackTracking?.videostatsPlaybackUrl?.baseUrl;
Koen's avatar
Koen committed
		if(!this.playbackUrl || !this.watchUrl)
			throw new ScriptException("Playback tracking unavailable");

Koen's avatar
Koen committed
		this.playbackUrlBase = this.playbackUrl.substring(0, this.playbackUrl.indexOf("?"));
		this.watchUrlBase = this.watchUrl.substring(0, this.watchUrl.indexOf("?"));
		this.watchParams = parseQueryString(this.watchUrl);
		this.playbackParams = parseQueryString(this.playbackUrl);
		
		delete this.playbackParams["fexp"];
	    delete this.watchParams["fexp"];

		if(this.playbackParams["plid"])// && !this.watchParams["plid"])
			this.watchParams["plid"] = this.playbackParams["plid"];
		if(this.playbackParams["of"])// && !this.watchParams["of"])
			this.watchParams["of"] = this.playbackParams["of"];
		if(this.playbackParams["vm"])// && !this.watchParams["vm"])
			this.watchParams["vm"] = this.playbackParams["vm"];
		if(this.playbackParams["ei"])// && !this.watchParams["ei"])
			this.watchParams["ei"] = this.playbackParams["ei"];
		if(this.playbackParams["cl"])// && !this.watchParams["cl"])
			this.watchParams["cl"] = this.playbackParams["cl"];

		const missing = ["plid", "of", "vm", "ei", "cl"].filter(x=>!this.watchParams[x]);
		if(missing && missing.length > 0)
			throw new ScriptException("Missing playback tracking parameter: " + missing.join(","));
	}
	onInit(seconds) {
		//Initial playback
		const resp = http.GET(constructUrl(this.playbackUrlBase, this.getProgressParameters(this.playbackParams, seconds)),
			getAuthContextHeaders(false), true);
		if(!resp.isOk)
			throw new ScriptException("Failed to initial playback:", resp.body);
		else if(resp.body && resp.body.indexOf("ERROR"))
			throw new ScriptException("Failed to initial playback:", resp.body);
	}
	onProgress(seconds, isPlaying) {
		const resp = http.GET(constructUrl(this.watchUrlBase, this.getProgressParameters(this.watchParams, seconds, !isPlaying)),
			getAuthContextHeaders(false), true);
		if(!resp.isOk)
			throw new ScriptException("Failed to update watchtime:", resp.body);
		else if(resp.body && resp.body.indexOf("ERROR"))
			throw new ScriptException("Failed to update watchtime:", resp.body);
	}

	getProgressParameters(baseParameters, progress, paused) {
		const rt = parseFloat(progress);
		const position = parseFloat(rt);

		let q = {};
		for(let key in baseParameters) {
			q[key] = baseParameters[key];
		}
		//q["cl"] = "547360702"; //NS
		q["ns"] = "yt"; //NS
		q["cmt"] = position; //Progress, Seconds
		q["cpn"] = this.cpn; //16 character string
		q["state"] = (paused) ? "paused" : "playing"; //State
		q["volume"] = "100"; //Volume
		q["lact"] = parseInt(progress * 1000); //Miliseconds
		q["fmt"] = "136"; //Format (itag)
		q["afmt"] = "251"; //Format Audio (itag)
		q["euri"] = ""; //Empty
		q["subscribed"] = "1"; //Subscribed
		q["cbr"] = "Chrome"; //Browser
		q["cbrver"] = "114.0.0.0"; //Browser version
		q["c"] = "WEB"; //Client Type
		q["cver"] = "2.20230427.04.00"; //Client version
		q["cplayer"] = "UNIPLAYER"; //Player Name
		q["cos"] = "Windows"; //OS
		q["cosver"] = "10"; //OS
		q["cplatform"] = "DESKTOP"; //Platform
		q["hl"] = "en_US"; //Language/Region
		q["cr"] = "US"; //Region
		q["idpj"] = this.idpj; //Random -0..-10
		q["ldpj"] = this.ldpj; //Random -0..-40
		if(!paused)
			q["rtn"] = (position + 10); //Next RT
		else
			q["final"] = 1;
		q["rt"] = position; //Current time spend on page
		q["rti"] = parseInt(position); //Current time spend on page (integer)
		q["st"] = this.lastPosition; //Last time
		q["et"] = position; //Current time
		//q["sourceid"] = "y"; //Always y
		q["ver"] = "2"; //Always y
		q["muted"] = "0"; //If muted
		//q["sdetails"] = "p:/feed/subscriptions" //Source page

		this.lastPosition = position;
		return q;
	}
}

function constructUrl(base, queryParams) {
	let hasQ = (base.indexOf("?") > 0);
	let url = base;
	for(let key in queryParams) {
		if(queryParams[key] === undefined)
			url += "";
		else if(!hasQ) {
			url += "?" + key + "=" + queryParams[key];
			hasQ = true;
		}
		else {
			if(queryParams[key] === "")
				url += "&" + key;
			else
				url += "&" + key + "=" + queryParams[key];
		}
	}
	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

	log("GetChannelContents - " + type);
Koen's avatar
Koen committed
	switch(type) {
		case undefined:
		case null:
		case "":
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);
	log("Channel Result Count: " + tab?.videos?.length)
	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
		}));
    }

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);

Kelvin's avatar
Kelvin committed
    const playlistHeaderRenderer1 = initialData?.header?.playlistHeaderRenderer;
	const playlistHeaderRenderer2 = initialData?.header?.pageHeaderRenderer;
	let author = undefined;
	let title = undefined;
	let videoCount = undefined;
	let playlistId = undefined;
    if(playlistHeaderRenderer1) {
		title = extractText_String(playlistHeaderRenderer1.title);
		author = extractRuns_AuthorLink(playlistHeaderRenderer1?.ownerText?.runs);
		videoCount = extractFirstNumber_Integer(extractText_String(playlistHeaderRenderer1?.numVideosText));
		playlistId = playlistHeaderRenderer1?.playlistId;
Koen's avatar
Koen committed
    }
Kelvin's avatar
Kelvin committed
	else if(playlistHeaderRenderer2) {
		title = playlistHeaderRenderer2.pageTitle

		const actions = playlistHeaderRenderer2?.pageHeaderViewModel?.actions?.flexibleActionsViewModel?.actionsRows;
		if(actions) {
			for(let action of actions){
				for(let subAction of action.actions) {
					if(subAction.buttonViewModel?.onTap?.innertubeCommand?.watchEndpoint?.playlistId) {
						playlistId = subAction.buttonViewModel?.onTap?.innertubeCommand?.watchEndpoint?.playlistId;
					}
					if(playlistId)
						break;
				}
				if(playlistId)
					break;
			}
		}

		const metaDataRows = playlistHeaderRenderer2?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows;
		if(!metaDataRows)
			throw new ScriptException("No playlist header found");
			
		for(let row of metaDataRows) {
			if(row.metadataParts) {
				for(let part of row.metadataParts) {
					if(part.avatarStack?.avatarStackViewModel) {
						let model = part.avatarStack?.avatarStackViewModel
						let authorName = model?.text?.content?.trim();
						let authorThumbnail = 
							(model.avatars && model.avatars.length > 0) ?
								model.avatars[0].avatarViewModel?.image?.sources[0].url :
								undefined;
						let authorId = 
							(model.text.commandRuns && model.text.commandRuns.length > 0) ?
								model.text.commandRuns[0].onTap?.innertubeCommand?.browseEndpoint?.browseId :
								undefined;
						let authorUrl = authorId ? URL_CHANNEL_BASE + authorId : undefined;
						
						author = new PlatformAuthorLink(new PlatformID(PLATFORM, null, config?.id, PLATFORM_CLAIMTYPE), authorName, authorUrl, authorThumbnail);
							
						if(author)
							break;
					}
					else if(part.text) {
						const partText = part.text.content;
						if(partText && !videoCount && /[0-9]+ videos?/.test(partText)) {
							videoCount = extractFirstNumber_Integer(partText);
						}
					}
				}
			}
			if(author && videoCount)
				break;
		}
	}
	else 
		throw new ScriptException("No playlist header found");

Koen's avatar
Koen committed

	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 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;
		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,
Kelvin's avatar
Kelvin committed
			id: new PlatformID(PLATFORM, playlistId, config.id),
Koen's avatar
Koen committed
            name: title,
            thumbnail: thumbnail,
Kelvin's avatar
Kelvin committed
            videoCount: videoCount,
Kelvin's avatar
Kelvin committed
            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
    }
}

Kelvin's avatar
Kelvin committed
class YTABRVideoSource extends DashManifestRawSource {
    constructor(obj, url, sourceObj, ustreamerConfig) {
		super(obj);
		this.url = url;
		this.abrUrl = url;
		this.ustreamerConfig = ustreamerConfig;
		this.sourceObj = sourceObj;
		this.manifest = "";
		this.lastHeaderData = {};
		this.width = obj.width;
		this.height = obj.height;
    }

	generate() {
		if(this.lastDash)
			return this.lastDash;
		log("Generating ABR Video Dash");
		getMediaReusableVideoBuffers()?.freeAll();
Kelvin's avatar
Kelvin committed
		let [dash, umpResp, fileHeader] = generateDash(this.sourceObj, this.ustreamerConfig, this.abrUrl, this.sourceObj.itag);
Kelvin's avatar
Kelvin committed
		this.initialHeader = fileHeader;
		this.initialUMP = umpResp;
		this.lastDash = dash;

		this.initStart = 0;
		this.initEnd = fileHeader.indexRangeStart - 1;
		this.indexStart = fileHeader.indexRangeStart;
		this.indexEnd = fileHeader.indexRangeEnd;

Kelvin's avatar
Kelvin committed
		return dash;
	}
	getRequestExecutor() {
		return new YTABRExecutor(this.abrUrl, this.sourceObj, this.ustreamerConfig, 
			this.initialHeader,
			this.initialUMP);
	}
}
class YTABRAudioSource extends DashManifestRawAudioSource {
    constructor(obj, url, sourceObj, ustreamerConfig) {
		super(obj);
		this.url = url;
		this.abrUrl = url;
		this.ustreamerConfig = ustreamerConfig;
		this.sourceObj = sourceObj;
		this.manifest = "";
		this.initialHeader = {};
    }

	generate() {
		if(this.lastDash)
			return this.lastDash;
		log("Generating ABR Audio Dash");
		getMediaReusableAudioBuffers()?.freeAll();
Kelvin's avatar
Kelvin committed
		let [dash, umpResp, fileHeader] = generateDash(this.sourceObj, this.ustreamerConfig, this.abrUrl, this.sourceObj.itag);
Kelvin's avatar
Kelvin committed
		this.initialHeader = fileHeader;
		this.initialUMP = umpResp;
		this.lastDash = dash;

		this.initStart = 0;
		this.initEnd = fileHeader.indexRangeStart - 1;
		this.indexStart = fileHeader.indexRangeStart;
		this.indexEnd = fileHeader.indexRangeEnd;
Kelvin's avatar
Kelvin committed
		
		return dash;
	}
	getRequestExecutor() {
		return new YTABRExecutor(this.abrUrl, this.sourceObj, this.ustreamerConfig,
			this.initialHeader,
			this.initialUMP);
	}
}
Kelvin's avatar
Kelvin committed
function generateDash(sourceObj, ustreamerConfig, abrUrl, itag) {
Kelvin's avatar
Kelvin committed
	const now = (new Date()).getTime();
	const lastAction = (new Date()).getTime() - (Math.random() * 5000);
	const initialReq = getVideoPlaybackRequest(sourceObj, ustreamerConfig, 0, 0, 0, lastAction, now);
	const postData = initialReq.serializeBinary();
	let initialResp = http.POST(abrUrl, postData, {
Kelvin's avatar
Kelvin committed
		"Origin": "https://www.youtube.com",
		"Accept": "*/*",
		"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"
	}, false, true);
	if(!initialResp.isOk)
		throw new ScriptException("Failed initial stream request [ " + initialResp.code + "]");

	const data = initialResp.body;
	let byteArray = undefined;
	if(data instanceof ArrayBuffer)
		byteArray = new Uint8Array(data);
	else
		byteArray = Uint8Array.from(data);

	const isVideo = sourceObj.mimeType.startsWith("video/");
	const reusableBuffer = (useReusableBuffers) ?
		((isVideo) ? getMediaReusableVideoBuffers() : getMediaReusableAudioBuffers()) :
		undefined;

	let umpResp = undefined;

	const maxRedirect = 3;
	for(let i = 0; i < maxRedirect; i++) {
		umpResp = new UMPResponse(byteArray, reusableBuffer);
		if(!umpResp)
			throw new ScriptException("Invalid UMP response found");
		if(!umpResp.streams[0]?.data) {
			if(umpResp.redirectUrl && i < maxRedirect - 1) {
				bridge.toast("UMP Redirect..");
				abrUrl = umpResp.redirectUrl;
				log("UMP Redirecting to:\n" + umpResp.redirectUrl);
				initialResp = http.POST(abrUrl, postData, {
					"Origin": "https://www.youtube.com",
					"Accept": "*/*",
					"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"
				}, false, true);
				if(!initialResp.isOk)
					throw new ScriptException("Failed initial stream request [ " + initialResp.code + "]");
			
				const data = initialResp.body;
				let byteArray = undefined;
				if(data instanceof ArrayBuffer)
					byteArray = new Uint8Array(data);
				else
					byteArray = Uint8Array.from(data);
			}
			else {
				log("No stream data in initial UMP Response:\n" + JSON.stringify(umpResp));
				log("Failed to load UMP, try restarting plugin/app.\n" + umpResp.opcodes.map(x=>x.opcode + ":" + x.length).join(", "));
				bridge.toast("Failed to load UMP, try restarting plugin/app.\n" + umpResp.opcodes.map(x=>x.opcode + ":" + x.length).join(", "));
				throw new ScriptException("No stream data in initial UMP response");
			}
		}
	}
Kelvin's avatar
Kelvin committed

	let streams = [];
	for(let key in umpResp.streams) {
		const stream = umpResp.streams[key];
		if(!itag || stream.itag == itag)
			streams.push(stream);
	}

	const webmHeaderData = streams[0].data;
Kelvin's avatar
Kelvin committed
	const webmHeader = new WEBMHeader(webmHeaderData, 
		sourceObj.mimeType.split(";")[0],
		/codecs=\"(.+)\"/.exec(sourceObj.mimeType)[1],
		sourceObj.width, 
		sourceObj.height);
	const urlPrefix = (isVideo) ?
		"https://grayjay.internal/video" :
		"https://grayjay.internal/audio";
	const dash = generateWEBMDash(webmHeader, 
		urlPrefix + "/internal/segment.webm?segIndex=$Number$", 
		urlPrefix + "/internal/init.webm");
	
	return [dash, umpResp, webmHeader];
}

function generateWEBMDash(webm, templateUrl, initUrl) {
	const duration = splitMS(webm.duration);
	const durationFormatted = `PT${duration.hours}H${duration.minutes}M${duration.seconds}.${((duration.miliseconds + "").padStart(3, '0'))}S`;

	let repCounter = 1;
	let mpd = `<?xml version="1.0" encoding="UTF-8"?>\n`;
	mpd += xmlTag("MPD", {
		"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
		"xmlns": "urn:mpeg:dash:schema:mpd:2011",
		"xsi:schemaLocation": "urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd",
		"profiles": "urn:mpeg:dash:profile:isoff-live:2011",
		"minBufferTime": "PT1.5S",
		"type": "static",
		"mediaPresentationDuration": durationFormatted
	}, (indent)=>
		xmlTag("Period", {id: "0", duration: durationFormatted}, (indent) =>
			xmlTag("AdaptationSet", {segmentAlignment: "true"}, (indent)=>
				xmlTag("Representation", 
					(webm.mimeType.startsWith("video/")) ? 
						{id: "1", mimeType: webm.mimeType, codecs: webm.codec, startWithSAP: "1", bandwidth: "800000", width: webm.width, height: webm.height}:
						{id: "2", mimeType: webm.mimeType, codecs: webm.codec, startWithSAP: "1", bandwidth: "800000", audioSamplingRate: webm.samplingFrequency},(indent)=>
						xmlTag("SegmentTemplate", {timescale: webm.timescale / 1000, startNumber: "1", 
								media: templateUrl, 
								duration: webm.duration,
Kelvin's avatar
Kelvin committed
								initialization: initUrl} , (indent)=>
Kelvin's avatar
Kelvin committed
							xmlTag("SegmentTimeline", {}, (indent)=>
								webm.cues.map((cue, i)=>
									xmlTag("S", {t: cue, d: (webm.cues.length > i + 1) ? webm.cues[i + 1] - cue : webm.durationCueTimescale - cue}, undefined, indent + " ")
								).join("")
							,indent + " ")
Kelvin's avatar
Kelvin committed
						,indent + " ")
Kelvin's avatar
Kelvin committed
				,indent + " ")
			, indent + " ")
		, indent + " ")
	, "");

	return mpd;
}
function splitMS(ms) {
	const hours = Math.floor(ms / (60 * 60 * 1000));
	ms -= hours * (60 * 60 * 1000);
	const minutes = Math.floor(ms / (60 * 1000));
	ms -= minutes * (60 * 1000);
	const seconds = Math.floor(ms / 1000);
	ms -= seconds * 1000;
	return {
		hours: hours,
		minutes: minutes,
		seconds: seconds,
		miliseconds: ms
	};
}
function xmlTag(tag, attributes, nested, indent) {
	indent = indent ?? "";
	let prefix = indent + "<" + tag;
	const attrKeys = (attributes) ? Object.keys(attributes) : [];
	if(attrKeys && attrKeys.length > 0) {
		prefix += " " + attrKeys.map(x=>x + "=\"" + attributes[x] + "\"").join(" ");
	}
	if(!!nested) {
		return prefix + ">\n" + 
			nested(indent + " ") + 
			indent + "</" + tag + ">\n";
	}
	else
		return prefix + "/>\n";
}

class TestYTABRVideoSource extends DashManifestRawSource {
    constructor(obj, url, sourceObj) {
		super(obj);
		this.url = url;
		this.url = "https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd";
		this.abrUrl = url;
		this.sourceObj = sourceObj;
    }

	generate() {
		const dash = http.GET("https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd", {});
		return dash.body;
	}
	getRequestExecutor() {
		return new YTABRExecutor(this.abrUrl, this.sourceObj);
	}

}

const KB_SIZE = 1000;
const MB_SIZE = 1000 * KB_SIZE;
const GB_SIZE = 1000 * MB_SIZE;
class ReusableBuffers {
	constructor(size, count) {
		this.maxSize = size;
		this.maxCount = count;
		this.buffers = [];
	}

	freeAll() {
		for(let buffer of this.buffers) {
			buffer.data = undefined;
			buffer.tag = undefined;
		}
	}
	freeTag(tag) {
		const buffers = this.buffers.filter(x=>x.tag == tag);
		for(let buffer of buffers) {
			buffer.data = undefined;
			buffer.tag = undefined;
		}
	}
	free(toFree) {
		const buffer = this.buffers.find(x=>x.data == toFree);
		if(buffer) {
			buffer.data = undefined;
			buffer.tag = undefined;
		}
	}

	getBuffer(size, tag) {
		log("Reusable Buffer [" + size + "]");
		if(size > this.maxSize)
			throw new ScriptException("Requested reusable buffer above the max buffer size (" + size + " > " + this.maxSize + ")");
		
		for(let buffer of this.buffers) {
			if(!buffer.data) {
				buffer.data = new Uint8Array(buffer.buffer, 0, size);
				buffer.tag = tag;
				return buffer.data;
			}
		}
		if(this.buffers.length < this.maxCount) {
			log("Allocated new resuable buffer (total: " + ((this.buffers.length + 1) * this.maxSize)/MB_SIZE + "MB)");
			const newBuffer = new ArrayBuffer(this.maxSize);
			const newData = new Uint8Array(newBuffer, 0, size);
			this.buffers.push({
				buffer: newBuffer,
				data: newData,
				tag: tag
			});
			return newData;
		}
		throw new ScriptException("Ran out of reusable memory (" + this.maxCount + ")");
	}
}

let _reusableBufferVideo = undefined;
let _reusableBufferAudio = undefined;
function getMediaReusableVideoBuffers() {
	if(!_reusableBufferVideo)
		_reusableBufferVideo = new ReusableBuffers(20 * MB_SIZE, 10);
	return _reusableBufferVideo;
}
function getMediaReusableAudioBuffers() {
	if(!_reusableBufferAudio)
		_reusableBufferAudio = new ReusableBuffers(2 * MB_SIZE, 10);
	return _reusableBufferAudio;
}
const useReusableBuffers = false;

let executorCounter = 0;
let _executorsVideo = [];
let _executorsAudio = [];
class YTABRExecutor {
	constructor(url, source, ustreamerConfig, header, initialUmp) {
		this.executorId = executorCounter++;
		this.source = source;
Kelvin's avatar
Kelvin committed
		this.header = header;
		this.initialUmp = initialUmp;
		this.abrUrl = url;
		this.ustreamerConfig = ustreamerConfig;
		this.lastRequest = 0;
		this.requestStarted = (new Date()).getTime();
		this.lastAction = (new Date()).getTime() - (Math.random() * 1000 * 5);
Kelvin's avatar
Kelvin committed
		this.segmentOffsets = undefined;
Kelvin's avatar
Kelvin committed
		log("UMP New executor: " + source.name + " - " + source.mimeType + " (segments: " + header?.cues?.length + ")");
		log("UMP Cues: " + header?.cues?.join(", "));
Kelvin's avatar
Kelvin committed
		this.isVideo = source.mimeType.startsWith("video/");
Kelvin's avatar
Kelvin committed
		if(source.mimeType.startsWith("video/")) {
			this.urlPrefix = "https://grayjay.internal/video";
			this.reusableBuffer = (useReusableBuffers) ? 
				getMediaReusableVideoBuffers() : undefined;
			this.type = "video";
			_executorsVideo.push(this);
			if(_executorsVideo.length > 2) {
				log("LEAKED EXECUTOR DETECTED?");
			}
		}
		else {
			this.urlPrefix = "https://grayjay.internal/audio";
			this.reusableBuffer = (useReusableBuffers) ? 
				getMediaReusableAudioBuffers() : undefined;
			this.type = "audio";
			_executorsAudio.push(this);
		}
		this.segments = {};
		if(initialUmp)
		{
			for(let segment of Object.keys(initialUmp.streams)) {
Kelvin's avatar
Kelvin committed
				const stream = initialUmp.streams[segment];
				if(stream.itag == this.itag) {
					log(`Caching initial Segment: itag:${stream.itag}, segmentIndex: ${stream.segmentIndex}, segmentLength: ${stream.segmentSize}, completed: ${stream.completed}`)
					this.cacheSegment(initialUmp.streams[segment]);
				}
Kelvin's avatar
Kelvin committed
	getOffset(index) {
		if(this.segmentOffset && this.segmentOffset.actual <= index)
			return this.segmentOffset.offset;
		return 0;
	}
	registerOffset(index, found) {
		this.segmentOffset = {index: index, actual: found, offset: found - index};
	}
Kelvin's avatar
Kelvin committed
	findSegmentTime(index) {
		if(this.header && this.header.cues) {
			if(this.header.cues.length > index) {
				const time = this.header.cues[index];
				if(index > 0 && time == 0) {
					log("UMP Cues: " + this.header.cues.join(", "));
					throw new ScriptException("Zero time for non-zero segment?");
				}
				return time;
			}
			else
				throw new ScriptException("UMP: Segment index out of bound? " + this.header.cues.length + " > " + index)
		}
		throw new ScriptException("Missing initialHeader?");
	}

	cacheSegment(segment) {
Kelvin's avatar
Kelvin committed
		this.segments[segment.segmentIndex - this.getOffset(segment.segmentIndex)] = segment;
Kelvin's avatar
Kelvin committed
	}
	getCachedSegmentCount() {
		return Object.keys(this.segments).length;
	}
	getCachedSegment(index) {
		return this.segments[index];
	}
	freeOldSegments(index) {
Kelvin's avatar
Kelvin committed
		index = parseInt(index);
Kelvin's avatar
Kelvin committed
		const reusable = this.reusableBuffer;
		for(let key of Object.keys(this.segments)) {
Kelvin's avatar
Kelvin committed
			key = parseInt(key);

Kelvin's avatar
Kelvin committed
			if(key < index || key > index + 7) {
Kelvin's avatar
Kelvin committed
				log("UMP [" + this.type + "]: disposing segment " + key + " (<" + index + " || >" + (index + 6) + ")");
Kelvin's avatar
Kelvin committed
				reusable?.free(this.segments[key].data);
				const segment = this.segments[key];
				if(segment) {
					delete segment.data;
				}
				delete this.segments[key];
			}
		}
	}
	freeAllSegments() {
		const reusable = this.reusableBuffer;
		for(let key of Object.keys(this.segments)) {
			reusable?.free(this.segments[key].data);
			delete this.segments[key];
		}
	}

	cleanup() {
		log("UMP: Cleaning up!");
		this.initialUmp = undefined;
		this.header = undefined;
		if(this.type == "video") {
			const index = _executorsVideo.indexOf(this);
			const removed = _executorsVideo.splice(index, 1);
			if(removed)
				log("Remaining video executors: " + _executorsVideo.length);
		}
		else {
			const index = _executorsAudio.indexOf(this);
			_executorsVideo.splice(index, 1);
			log("Remaining audio executors: " + _executorsAudio.length);
		}
		this.freeAllSegments();
	}

Kelvin's avatar
Kelvin committed
	executeRequest(url, headers, retryCount, overrideSegment) {
		if(!retryCount)
			retryCount = 0;
Kelvin's avatar
Kelvin committed
		log("UMP: " + url + "");
Loading
Loading full blame...