const URL_BASE = "https://www.youtube.com"; const URL_BASE_M = "https://m.youtube.com"; const URL_HOME = "https://www.youtube.com"; const URL_TRENDING = "https://www.youtube.com/feed/trending"; 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"; const URL_CHANNEL_PLAYLISTS = "/playlists"; 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"; 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_LIVE_CHAT_HTML = "https://www.youtube.com/live_chat"; 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="; const URL_YOUTUBE_SPONSORBLOCK = "https://sponsor.ajay.app/api/skipSegments?videoID="; const URL_YOUTUBE_RSS = "https://www.youtube.com/feeds/videos.xml?channel_id="; //Newest to oldest const CIPHER_TEST_HASHES = ["3400486c", "b22ef6e7", "a960a0cb", "178de1f2", "4eae42b1", "f98908d1", "0e6aaa83", "d0936ad4", "8e83803a", "30857836", "4cc5d082", "f2f137c6", "1dda5629", "23604418", "71547d26", "b7910ca8"]; const CIPHER_TEST_PREFIX = "/s/player/"; const CIPHER_TEST_SUFFIX = "/player_ias.vflset/en_US/base.js"; const PLATFORM = "YouTube"; const PLATFORM_CLAIMTYPE = 2; 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/(.*)"); const REGEX_VIDEO_URL_CLIP = new RegExp("https://(.*\\.)?youtube\\.com/clip/(.*)[?]?"); const REGEX_VIDEO_URL_EMBED = new RegExp("https://(.*\\.)?youtube\\.com/embed/([^?]+)"); const REGEX_VIDEO_CHANNEL_URL = new RegExp("https://(.*\\.)?youtube\\.com/channel/(.*)"); 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/*"); 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"); 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"; 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)"; 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; const USE_IOS_VIDEOS_FALLBACK = true; 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; 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 ?? {}; //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(); console.log("batchResp", batchResp); throwIfCaptcha(batchResp[0]) if (!batchResp[0].isOk) throw new ScriptException("Failed to request context enable !batchResp[0].isOk"); if(isLoggedIn && !batchResp[1].isOk) throw new ScriptException("Failed to request context enable isLoggedIn && !batchResp[1].isOk"); _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); const tabs = extractPage_Tabs(initialData); if(tabs.length == 0) { if(bridge.devSubmit) bridge.devSubmit("getHome - No tabs found..", JSON.stringify(initialData)); throw new ScriptException("No tabs found.."); } 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)); throw new ScriptException("No tabs found.."); } let tab = tabs[0]; if (tab.videos.length === 0) { if (tab.shelves.length > 0) { tab = tab.shelves[0]; } } return new RichGridPager(tab, {}, USE_MOBILE_PAGES, false); }; //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)); throw new ScriptException("No search tab found"); } } source.getChannelUrlByClaim = (claimType, claimValues) => { 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 = () => { return { //Youtube 2: { 0: URL_BASE + "/{{CLAIMVALUE}}", 1: URL_BASE + "/channel/{{CLAIMVALUE}}", } }; }; //Video source.isContentDetailsUrl = (url) => { 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); }; source.getContentDetails = (url, useAuth, simplify) => { useAuth = !!_settings?.authDetails || !!useAuth; url = convertIfOtherUrl(url); const clientContext = getClientContext(false); 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" let batchCounter = 1; const batch = http.batch() .GET(url, headersUsed, useLogin); 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); const ageRestricted = initialPlayerData.playabilityStatus?.reason?.indexOf("your age") > 0 ?? false; if (initialPlayerData.playabilityStatus?.status == "LOGIN_REQUIRED" && !ageRestricted) { 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); if (initialPlayerData.playabilityStatus?.status == "LOGIN_REQUIRED") throw new ScriptException("Login required\nReason: " + initialPlayerData?.playabilityStatus?.reason); else log("Login fallback resolved?"); } else throw new ScriptException("Login required\nReason: " + initialPlayerData?.playabilityStatus?.reason); } 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; if(initialPlayerData?.playabilityStatus?.status == "UNPLAYABLE") 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); 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"); } } 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"); } } if(IS_TESTING) { console.log("Initial Data", initialData); console.log("Initial Player Data", initialPlayerData); } let creationData = { url: url, initialData: initialData, initialPlayerData: initialPlayerData, jsUrl: jsUrl }; 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); } throw new UnavailableException("No sources found"); } //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; } } //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; } } } else if(!!_settings["showVerboseToasts"]) bridge.toast("Failed to get iOS stream data"); } else if(USE_IOS_VIDEOS_FALLBACK && !simplify) { const iosDataResp = (batchIOS > 0) ? resps[batchIOS] : requestIOSStreamingData(videoDetails.id.value); if(iosDataResp.isOk) { if(!!_settings["showVerboseToasts"]) bridge.toast("Using iOS sources fallback (" + (batchIOS > 0 ? "cached" : "lazily") + ")"); const iosData = JSON.parse(iosDataResp.body); if(IS_TESTING) console.log("IOS Streaming Data", iosData); if(iosData?.streamingData?.adaptiveFormats) { let newDescriptor = extractAdaptiveFormats_VideoDescriptor(iosData.streamingData.adaptiveFormats, jsUrl, creationData, "IOS "); videoDetails.video = newDescriptor; } else if(!!_settings["showVerboseToasts"]) bridge.toast("Invalid iOS source response.."); } else bridge.toast("Failed to get iOS stream data"); } 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); } } 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); }; finalResult.getContentRecommendations = function() { const initialData = finalResult.__initialData; if(!initialData) return new VideoPager([], false); return source.getContentRecommendations(url, initialData); } return finalResult; }; source.getContentChapters = function(url, initialData) { //return []; 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 []; } const videoId = extractVideoIDFromUrl(url); let sbResp = null; const sbChapters = []; if(initialData == null) { const reqs = http.batch() .GET(url, getRequestHeaders({}), false); if(_settings["sponsorBlock"] && videoId) { const cats = [ (!!_settings["sponsorBlockCat_Sponsor"]) ? "sponsor" : null, (!!_settings["sponsorBlockCat_Intro"]) ? "intro" : null, (!!_settings["sponsorBlockCat_Outro"]) ? "outro" : null, (!!_settings["sponsorBlockCat_Self"]) ? "selfpromo" : null, (!!_settings["sponsorBlockCat_Offtopic"]) ? "music_offtopic" : null, (!!_settings["sponsorBlockCat_Preview"]) ? "preview" : null, (!!_settings["sponsorBlockCat_Filler"]) ? "filler" : null ].filter(x=>!!x); const catsArg = "&categories=[" + cats.map(x=>"\"" + x + "\"").join(",") + "]"; reqs.GET(URL_YOUTUBE_SPONSORBLOCK + videoId + catsArg, {}, false); } 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]; } else if(_settings["sponsorBlock"] && videoId) sbResp = http.GET(URL_YOUTUBE_SPONSORBLOCK + videoId, {}, false); if(sbResp && sbResp.isOk) { try { const allowNoVoteSkip = !!(_settings["sponsorBlockNoVotes"]); const skipType = (_settings["sponsorBlockType"]) ? Type.Chapter.SKIP : Type.Chapter.SKIPPABLE; const sbData = JSON.parse(sbResp.body); for(let block of sbData) { if(block.actionType == "skip" && block.segment && block.segment.length == 2 && (allowNoVoteSkip || block.votes >= 1)) { sbChapters.push({ name: block.category, timeStart: parseFloat(block.segment[0]), timeEnd: parseFloat(block.segment[1]), type: skipType }); } } } catch(ex) { //SB Failed log("SB Failed (" + sbResp.code + "): " + ex); } } let videoChapters = []; 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 }); } } try { videoChapters = videoChapters.concat(extractVideoChapters(initialData) ?? []); } 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 && x.timeStart <= videoChapter.timeEnd); 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); if(videoChapterAfter.timeStart < videoChapterAfter.timeEnd) { newChapters.push(videoChapterAfter); startTime = videoChapterAfter.timeEnd; } else startTime = videoChapterAfter.timeStart; } } } else newChapters.push(videoChapter); } return newChapters; } function extractVideoChapters(initialData) { let rawObjects = initialData?.playerOverlays?.playerOverlayRenderer?.decoratedPlayerBarRenderer; if(rawObjects?.decoratedPlayerBarRenderer) rawObjects = rawObjects.decoratedPlayerBarRenderer?.playerBar?.multiMarkersPlayerBarRenderer?.markersMap; else rawObjects = rawObjects.playerBar?.multiMarkersPlayerBarRenderer?.markersMap; if(!rawObjects || rawObjects.length == 0) return []; const chapters = rawObjects.find(x=>x.key == "DESCRIPTION_CHAPTERS") ?? rawObjects.find(x=>x.key == "AUTO_CHAPTERS"); 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 + ")"); } 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) } } const chatHtmlResp = http.GET(chatUrl, {}, false); if(!chatHtmlResp.isOk) return null; else { return { url: chatUrl, removeElements: [ "yt-live-chat-header-renderer", "#ticker" ] }; } } 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); 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); 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; if(!this.playbackUrl || !this.watchUrl) throw new ScriptException("Playback tracking unavailable"); 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) => { 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"); //const html = requestPage(url, {}, useLogin); const initialData = requestInitialData(url, useLogin, useLogin); const contents = initialData.contents; const result = contents.twoColumnWatchNextResults?.results?.results?.contents ?? contents.singleColumnWatchNextResults?.results?.results?.contents ?? null; //Add any alternative containers if(!result) return new CommentPager([], false); const engagementPanels = initialData.engagementPanels ?? []; return extractTwoColumnWatchNextResultContents_CommentsPager(url, result, useLogin, engagementPanels); }; 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'); } else return new CommentPager([], false); }; source.getContentRecommendations = (url, initialData) => { const useAuth = !!_settings?.authDetails; 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" 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); if(!watchNextFeed) return new VideoPager([], false); //log("Recommendations watchNextFeed: " + !!watchNextFeed + "\n" + JSON.stringify(watchNextFeed)); const originalItems = watchNextFeed.results; 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")); return new VideoPager([], false); } if(watchNextFeed.itemSectionRenderer?.targetId == 'watch-next-feed') { log("Recommendations in sub-section renderer"); watchNextFeed = watchNextFeed.itemSectionRenderer; } const itemSectionRenderer = extractItemSectionRenderer_Shelves(watchNextFeed); //TODO: pages return new VideoPager(itemSectionRenderer?.videos ?? [], false); }; //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); }; source.getChannel = (url) => { const initialData = requestInitialData(url); if(!initialData) throw new ScriptException("No channel data found for: " + url); 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; } source.getChannelContents = (url, type, order, filters) => { let targetTab = null; url = filterChannelUrl(url); switch(type) { case undefined: case null: case "": 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); 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 VideoPager([], false); if(IS_TESTING) console.log("Tab", tab); if(type == Type.Feed.Live) { if(tab.shelves) { const featured = tab.shelves.find(x=>x?.name == "Featured"); if(featured && featured.videos && featured.videos.length > 0) { const live = featured.videos.find(x=>x.isLive); if(live) return new VideoPager([live], false); } } return new VideoPager([], false); } //throw new ScriptException("Could not find tab: " + targetTab); return new RichGridPager(tab, contextData, useAuth, useAuth); }; source.getChannelPlaylists = (url) => { const targetTab = "Playlists"; const useAuth = bridge.isLoggedIn() && !!_settings?.authChannels; if(useAuth) log("USING AUTH FOR CHANNEL"); const initialData = requestInitialData(url + URL_CHANNEL_PLAYLISTS, useAuth, useAuth); if(!initialData) throw new ScriptException("No channel data found for: " + url); const channel = extractChannel_PlatformChannel(initialData, url); const contextData = { authorLink: new PlatformAuthorLink(new PlatformID(PLATFORM, channel.id.value, config.id, PLATFORM_CLAIMTYPE), channel.name, channel.url, channel.thumbnail) }; const tabs = extractPage_Tabs(initialData, contextData); const tab = tabs.find(x=>x.title == targetTab); if(!tab) return new PlaylistPager([], false); return new RichGridPlaylistPager(tab, contextData, useAuth, useAuth); } source.getPeekChannelTypes = () => { return [Type.Feed.Videos, Type.Feed.Mixed]; } source.peekChannelContents = function(url, type) { if(type != Type.Feed.Mixed && type != Type.Feed.Videos) return []; const match = url.match(REGEX_VIDEO_CHANNEL_URL); if(!match || match.length != 3) return {}; const id = removeQuery(match[2]); if(!id) return {}; const rssUrl = URL_YOUTUBE_RSS + id; const xmlResp = http.GET(rssUrl, {}); if(!xmlResp.isOk) return null; const xml = domParser.parseFromString(xmlResp.body).querySelector("feed"); const xmlTree = JSON.parse(xml.toNodeTreeJson())?.children; console.log(xmlTree); if(!xmlTree || xmlTree.length <= 0) return {}; const authorNode = xmlTree?.find(x=>x.name == "author"); const entryNodes = xmlTree?.filter(x=>x.name == "entry") ?? []; const videos = []; const author = new PlatformAuthorLink( new PlatformID(PLATFORM, null, id, PLATFORM_CLAIMTYPE), authorNode.children.find(x=>x.name == "name").value, authorNode.children.find(x=>x.name == "uri").value, "" ) for(let entry of entryNodes) { const group = entry.children.find(x=>x.name == 'media:group'); const community = group.children.find(x=>x.name == "media:community"); videos.push(new PlatformVideo({ id: new PlatformID(PLATFORM, entry.children.find(x=>x.name == 'yt:videoid').value, config.id), name: escapeUnicode(group.children.find(x=>x.name == 'media:title').value), thumbnails: new Thumbnails([ new Thumbnail(group.children.find(x=>x.name == 'media:thumbnail')?.attributes["url"], 1) ]), author: author, uploadDate: parseInt(new Date(entry.children.find(x=>x.name == "updated").value).getTime() / 1000), duration: 0, viewCount: parseInt(community.children.find(x=>x.name == "media:statistics").attributes["views"]) ?? 0, url: entry.children.find(x=>x.name == "link").attributes["href"], isLive: false })); } return videos; }; source.searchPlaylists = function(query, type, order, filters) { const data = requestSearch(query, false, SEARCH_PLAYLISTS_PARAM); if(IS_TESTING) console.log("Search data:", data); const searchResults = extractSearch_SearchResults(data); if(searchResults.playlists) return new SearchItemSectionPlaylistPager(searchResults); return new PlaylistPager([]); }; source.isPlaylistUrl = function(url) { return REGEX_VIDEO_PLAYLIST_URL.test(url); }; source.getPlaylist = function (url) { log(`Getting playlist: ${url}`); const initialData = requestInitialData(url, USE_MOBILE_PAGES, true); const contents = initialData.contents; if(IS_TESTING) console.log("Initial data", initialData); const playlistHeaderRenderer = initialData?.header?.playlistHeaderRenderer; if(!playlistHeaderRenderer) { throw new ScriptException("No playlist header found"); return null; } if(IS_TESTING) console.log("initialData", initialData); const renderer = initialData?.contents?.singleColumnBrowseResultsRenderer ?? initialData?.contents?.twoColumnBrowseResultsRenderer; if(renderer) { if(!renderer.tabs) { throw new ScriptException("No tabs found"); 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"); return null; } const id = playlistHeaderRenderer.playlistId; const title = extractText_String(playlistHeaderRenderer.title); const videos = []; let continuationToken = null; for(let playlistRenderer of playlistList.contents) { switchKey(playlistRenderer, { playlistVideoRenderer(renderer) { const video = extractPlaylistVideoRenderer_Video(renderer); if(video) videos.push(video); }, continuationItemRenderer(continueRenderer) { continuationToken = continueRenderer?.continuationEndpoint?.continuationCommand?.token; } }); } //Fallback for old apps 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; } 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; } }); } } } let thumbnail = null; if(videos && videos.length > 0 && videos[0].thumbnails?.sources && videos[0].thumbnails.sources.length > 0) thumbnail = videos[0].thumbnails.sources[videos[0].thumbnails.sources.length - 1].url; let author = extractRuns_AuthorLink(playlistHeaderRenderer?.ownerText?.runs); if(!author && videos && videos.length > 0 && videos.filter(x=>x.author.url != videos[0].author.url).length == 0) { //Assume author = video owner if all videos by same & author null author = videos[0].author; } return new PlatformPlaylistDetails({ url: url, id: new PlatformID(PLATFORM, playlistHeaderRenderer?.playlistId, config.id), author: author, name: title, thumbnail: thumbnail, videoCount: extractFirstNumber_Integer(extractText_String(playlistHeaderRenderer?.numVideosText)), contents: new PlaylistVideoPager(videos, continuationToken) }); } else throw new ScriptException("No playlist renderer found?"); return null; }; 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; } } 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"); } 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"); } 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 function throwIfCaptcha(resp) { if (resp != null && resp.code === 429 && resp.body != null && resp.body.includes("captcha")) { throw new CaptchaRequiredException(resp.url, resp.body); } return true; } 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 { constructor(obj, originalUrl) { super(obj); this.originalUrl = originalUrl; } getRequestModifier() { return new YTRequestModifier(this.originalUrl); } } class YTAudioSource extends AudioUrlRangeSource { constructor(obj, originalUrl) { super(obj); this.originalUrl = originalUrl; } getRequestModifier() { return new YTRequestModifier(this.originalUrl); } } class YTRequestModifier extends RequestModifier { constructor(originalUrl) { super({ allowByteSkip: false }); this.requestNumber = 0; this.originalUrl = originalUrl; this.newUrl = null; this.newUrlCount = 0; } /** * Modifies the request * @param {string} url The URL string used * @param {{[key: string]: string}} headers The headers used * @returns {Request} */ modifyRequest(url, headers) { const u = new URL(url); const actualUrl = (this.newUrl) ? new URL(this.newUrl) : u; const isVideoPlaybackUrl = u.pathname.startsWith('/videoplayback'); if (isVideoPlaybackUrl && !u.searchParams.has("rn")) { actualUrl.searchParams.set("rn", this.requestNumber.toString()); } this.requestNumber++; if(this.newUrl) { log("BYPASS: Using NewURL For sources"); log("BYPASS: OldUrl: " + u.toString()); log("BYPASS: NewUrl: " + actualUrl.toString()); log("BYPASS: Headers: " + JSON.stringify(headers)); } let removedRangeHeader = undefined; if (headers["Range"] && !u.searchParams.has("range")) { let range = headers["Range"]; if (range.startsWith("bytes=")) { range = range.substring("bytes=".length); } removedRangeHeader = headers["Range"]; delete headers["Range"]; actualUrl.searchParams.set("range", range); } const c = u.searchParams.get("c"); if (c === "WEB" || c === "TVHTML5_SIMPLY_EMBEDDED_PLAYER") { headers["Origin"] = URL_BASE; headers["Referer"] = URL_BASE; headers["Sec-Fetch-Dest"] = "empty"; headers["Sec-Fetch-Mode"] = "cors"; headers["Sec-Fetch-Site"] = "cross-site"; } headers['TE'] = "trailers"; //I hate this //Workaround for seemingly active blocking /* const isValid = refetchClient.request("HEAD", actualUrl.toString(), headers); if(isValid.code == 403 && this.newUrlCount < 3) { const itag = actualUrl.searchParams.get("itag"); bridge.toast("Youtube block detected (" + (this.newUrlCount + 1) + "), bypassing.."); log("Detected 403, attempting bypass"); try { const newDetailsResp = source.getContentDetails(this.originalUrl, false, true); if(newDetailsResp) { let source = newDetailsResp.video.videoSources.find(x=>x.itagId == itag); if(!source) source = newDetailsResp.video.audioSources.find(x=>x.itagId == itag); if(source) { this.newUrl = source.url; this.newUrlCount++; this.requestNumber = 0; log("Injecting new source url[" + source.name + "]: " + source.url); bridge.toast("Injecting new source url"); if(removedRangeHeader) headers["Range"] = removedRangeHeader; return this.modifyRequest(url, headers); } } else bridge.toast("Bypass failed, couldn't reload [" + newDetailsResp.code + "]"); } catch(ex) { bridge.toast("Bypass failed\n" + ex); } } */ if (c) { switch (c) { case "ANDROID": headers["User-Agent"] = USER_AGENT_ANDROID; break; case "IOS": headers["User-Agent"] = USER_AGENT_IOS; break; default: headers["User-Agent"] = USER_AGENT_WINDOWS; break; } } return { url: actualUrl.toString(), headers: headers } } } class YTLiveEventPager extends LiveEventPager { constructor(key, continuation) { super([], continuation != null); this.key = key; this.continuation = continuation; this.hasMore = true; this.knownEmojis = {}; this.nextPage(); } nextPage() { const newResult = http.POST(URL_LIVE_CHAT + "?key=" + this.key + "&prettyPrint=false", JSON.stringify({ context: { client: { clientName: "WEB", clientVersion: "2.20220901.00.00", clientFormFactor: "UNKNOWN_FORM_FACTOR", utcOffsetMinutes: 0, memoryTotalKbytes: 100000, timeZone: "ETC/UTC" }, user: { lockedSafetyMode: false } }, continuation: this.continuation, webClientInfo: { isDocumentHidden: false } }), { "Content-Type": "application/json", "User-Agent": USER_AGENT_WINDOWS }, false); if(!newResult.isOk) throw new ScriptException("Failed chat: " + newResult.body); const json = JSON.parse(newResult.body); //if(IS_TESTING) // console.log("Live Chat Json:", json); const continuationArr = json?.continuationContents?.liveChatContinuation?.continuations; if(!continuationArr || continuationArr.length == 0) { this.hasMore = false; throw new ScriptException("No chat continuation found"); } const continuation = continuationArr[0]?.timedContinuationData?.continuation ?? continuationArr[0]?.invalidationContinuationData?.continuation if(!continuation) { this.hasMore = false; throw new ScriptException("No chat continuation found"); } this.continuation = continuation; const actions = json.continuationContents?.liveChatContinuation?.actions; if(IS_TESTING) console.log("Live Chat Actions:", actions); let events = []; if(actions && actions.length > 0) { const actionResults = handleYoutubeLiveEvents(actions); const emojiMap = actionResults.emojis; events = actionResults.events; let newEmojiCount = 0; for(let kv in emojiMap) { if(this.knownEmojis[kv]) delete emojiMap[kv]; else { this.knownEmojis[kv] = emojiMap[kv]; newEmojiCount++; } } if(newEmojiCount > 0) { console.log("New Emojis:", emojiMap); events.unshift(new LiveEventEmojis(emojiMap)); } } this.results = events; //if(IS_TESTING) // console.log("LiveEvents:", this.results); return this; } } function handleYoutubeLiveEvents(actions) { let emojiMap = {}; let events = []; for(let action of actions) { try { if(action.addChatItemAction) { const obj = action.addChatItemAction; const isPaid = !!obj.item?.liveChatPaidMessageRenderer const renderer = (isPaid) ? obj.item?.liveChatPaidMessageRenderer : obj.item?.liveChatTextMessageRenderer; const msgObj = extractLiveMessage_Obj(renderer); if(!msgObj) continue; if(msgObj.emojis) for(let emojiKey in msgObj.emojis) emojiMap[emojiKey] = msgObj.emojis[emojiKey]; if(msgObj && msgObj.name && (msgObj.message || isPaid)) { if(!isPaid) events.push(new LiveEventComment(msgObj.name, msgObj.message, msgObj.thumbnail, msgObj.colorName, msgObj.badges)); else { const amount = extractText_String(renderer.amount ?? renderer.purchaseAmountText ?? paidMessageRenderer?.amount ?? paidMessageRenderer?.purchaseAmountText); events.push(new LiveEventDonation(amount, msgObj.name, msgObj.message ?? "", msgObj.thumbnail, 0, renderer.bodyBackgroundColor ? "#" + Number(renderer.bodyBackgroundColor).toString(16) : null)); } } } else if(action.ReplaceChatItemAction) {} else if(action.RemoveChatItemAction) {} else if(action.addLiveChatTickerItemAction) { const obj = action.addLiveChatTickerItemAction; if(obj.item?.liveChatTickerSponsorItemRenderer) { const renderer = obj.item?.liveChatTickerSponsorItemRenderer; const membershipRenderer = renderer.showItemEndpoint?.showLiveChatItemEndpoint?.renderer?.liveChatMembershipItemRenderer; const msgObj = extractLiveMessage_Obj(membershipRenderer); if(msgObj && msgObj.name) events.push(new LiveEventDonation("Member", msgObj.name, msgObj.message, msgObj.thumbnail, (renderer.durationSec ?? 10) * 1000, membershipRenderer.bodyBackgroundColor ? "#" + Number(membershipRenderer.bodyBackgroundColor).toString(16) : null)); } else if(obj.item?.liveChatTickerPaidMessageItemRenderer) { const renderer = obj.item?.liveChatTickerPaidMessageItemRenderer const paidMessageRenderer = renderer.showItemEndpoint?.showLiveChatItemEndpoint?.renderer?.liveChatPaidMessageRenderer; const msgObj = extractLiveMessage_Obj(paidMessageRenderer); const amount = extractText_String(renderer.amount ?? renderer.purchaseAmountText ?? paidMessageRenderer?.amount ?? paidMessageRenderer?.purchaseAmountText); if(msgObj && msgObj.name) events.push(new LiveEventDonation(amount, msgObj.name, msgObj.message, msgObj.thumbnail, (renderer.durationSec ?? 10) * 1000, paidMessageRenderer.bodyBackgroundColor ? "#" + Number(paidMessageRenderer.bodyBackgroundColor).toString(16) : null)); } } else if(action.addBannerToLiveChatCommand) { const bannerRenderer = action.addBannerToLiveChatCommand?.bannerRenderer?.liveChatBannerRenderer; const redirectRenderer = bannerRenderer?.contents?.liveChatBannerRedirectRenderer; if(bannerRenderer && redirectRenderer && bannerRenderer.bannerType == "LIVE_CHAT_BANNER_TYPE_CROSS_CHANNEL_REDIRECT") { const url = redirectRenderer.inlineActionButton?.buttonRenderer?.command?.commandMetadata?.webCommandMetadata?.url; const name = redirectRenderer.bannerMessage?.runs?.find(x=>x.bold)?.text; const thumbnails = redirectRenderer.authorPhoto?.thumbnails; if(url && name && thumbnails && thumbnails.length && thumbnails.length > 0) events.push(new LiveEventRaid(URL_BASE + url, name, thumbnails[thumbnails.length - 1]?.url)); } } else { const keys = Object.keys(action); log("Unknown Event: " + keys.join(",") + JSON.stringify(action, null, " ")); } } catch(ex) { log("Failed Youtube live action parse due to [" + ex + "]: " + JSON.stringify(action, null, " ")); } } return { events: events, emojis: emojiMap }; } source.handleYoutubeLiveEvents = handleYoutubeLiveEvents; function extractLiveMessage_Obj(obj) { if(!obj) return null; const name = extractText_String(obj.authorName); const thumbnails = obj?.authorPhoto?.thumbnails; let thumbnail = null; for(let thumb of thumbnails){ if(thumb?.url) { thumbnail = thumb.url; break; } } let message = extractText_String(obj.message); const headerMessage = extractText_String(obj.headerPrimaryText); const emojiMap = {}; let isMember = false; const badges = []; if(obj.authorBadges) { for(let badge of obj.authorBadges) { const badgeImages = badge.liveChatAuthorBadgeRenderer?.customThumbnail?.thumbnails; const badgeName = badge.liveChatAuthorBadgeRenderer?.tooltip; if(badgeImages && badgeImages.length > 0 && badgeName) { emojiMap[badgeName] = badgeImages[badgeImages.length - 1].url; badges.push(badgeName); if(badgeName.toLowerCase().indexOf("member") >= 0) isMember = true; } } } if(obj?.message?.runs) { for(let part of obj?.message?.runs) { if(part.emoji?.image?.accessibility?.accessibilityData?.label && part.emoji?.image?.thumbnails) { const label = part.emoji?.image?.accessibility?.accessibilityData?.label; if(label && !emojiMap[label]) { emojiMap[label] = part.emoji?.image?.thumbnails[0]?.url; } } } } return { name: name, thumbnail: thumbnail, message: message, headerMessage: headerMessage, emojis: emojiMap, colorName: isMember ? "#2ba640" : null, badges: badges }; } class YTCommentPager extends CommentPager { constructor(comments, continuation, contextUrl, useLogin, useMobile) { super(comments, continuation != null, contextUrl); this.useLogin = !!useLogin; this.useMobile = !!useMobile; this.continuation = continuation; } nextPage() { if(!this.continuation) return new CommentPager([], false); return requestCommentPager(this.context, this.continuation, this.useLogin, this.useMobile) ?? new CommentPager([], false); } } class YTComment extends Comment { constructor(obj) { super(obj); } } class RichGridPager extends VideoPager { constructor(tab, context, useMobile = false, useAuth = false) { super(tab.videos, tab.videos.length > 0 && !!tab.continuation, context); this.continuation = tab.continuation; this.useMobile = useMobile; this.useAuth = useAuth; } nextPage() { this.context.page = this.context.page + 1; if(this.continuation) { const newData = validateContinuation(()=>requestBrowse({ continuation: this.continuation.token }, !!this.useMobile, !!this.useAuth)); if(newData && newData.length > 0) { const fakeRichGrid = { contents: newData }; const newItemSection = extractRichGridRenderer_Shelves(fakeRichGrid, this.context); if(newItemSection.videos && newItemSection.videos.length == 0 && newItemSection.shelves && newItemSection.shelves.length > 0) { if(IS_TESTING) console.log("No videos in root found, checking shelves", newItemSection); let vids = []; for(let i = 0; i < newItemSection.shelves.length; i++) { const shelf = newItemSection.shelves[i]; vids = vids.concat(shelf.videos); } newItemSection.videos = vids; } if(newItemSection.videos) return new RichGridPager(newItemSection, this.context, this.useMobile, this.useAuth); } else log("Call [RichGridPager.nextPage] continuation gave no appended items, setting empty page with hasMore to false"); } this.hasMore = false; this.results = []; return this; } } class RichGridPlaylistPager extends PlaylistPager { constructor(tab, context, useMobile = false, useAuth = false) { super(tab.playlists, tab.videos.length > 0 && !!tab.continuation, context); this.continuation = tab.continuation; this.useMobile = useMobile; this.useAuth = useAuth; } nextPage() { this.context.page = this.context.page + 1; if(this.continuation) { const newData = validateContinuation(()=>requestBrowse({ continuation: this.continuation.token }, !!this.useMobile, !!this.useAuth)); if(newData && newData.length > 0) { const fakeRichGrid = { contents: newData }; const newItemSection = extractRichGridRenderer_Shelves(fakeRichGrid, this.context); if(newItemSection.playlists && newItemSection.playlists.length == 0 && newItemSection.shelves && newItemSection.shelves.length > 0) { if(IS_TESTING) console.log("No playlists in root found, checking shelves", newItemSection); let vids = []; for(let i = 0; i < newItemSection.shelves.length; i++) { const shelf = newItemSection.shelves[i]; vids = vids.concat(shelf.playlists); } newItemSection.playlists = vids; } if(newItemSection.playlists) return new RichGridPager(newItemSection, this.context, this.useMobile, this.useAuth); } else log("Call [RichGridPager.nextPage] continuation gave no appended items, setting empty page with hasMore to false"); } this.hasMore = false; this.results = []; return this; } } class SearchItemSectionVideoPager extends VideoPager { constructor(itemSection) { super(itemSection.videos, itemSection.videos.length > 0 && !!itemSection.continuation); this.continuation = itemSection.continuation; } nextPage() { this.context.page = this.context.page + 1; if(this.continuation) { const continueItems = validateContinuation(()=> requestSearchContinuation(this.continuation.token)); if(continueItems.length > 0) { const fakeSectionList = { contents: continueItems }; const newItemSection = extractSectionListRenderer_Sections(fakeSectionList, this.context); if(newItemSection.videos) return new SearchItemSectionVideoPager(newItemSection); } } this.hasMore = false; this.results = []; return this; } } class SearchItemSectionChannelPager extends ChannelPager { constructor(itemSection) { super(itemSection.channels, itemSection.channels.length > 0 && !!itemSection.continuation); this.continuation = itemSection.continuation; } nextPage() { this.context.page = this.context.page + 1; if(this.continuation) { const continueItems = validateContinuation(()=> requestSearchContinuation(this.continuation.token)); if(continueItems.length > 0) { const fakeSectionList = { contents: continueItems }; const newItemSection = extractSectionListRenderer_Sections(fakeSectionList, this.context); if(newItemSection.channels) return new SearchItemSectionChannelPager(newItemSection); } } this.hasMore = false; this.results = []; return this; } } class SearchItemSectionPlaylistPager extends ChannelPager { constructor(itemSection) { super(itemSection.playlists, itemSection.playlists.length > 0 && !!itemSection.continuation); this.continuation = itemSection.continuation; } nextPage() { this.context.page = this.context.page + 1; if(this.continuation) { const continueItems = validateContinuation(()=> requestSearchContinuation(this.continuation.token)); if(continueItems.length > 0) { const fakeSectionList = { contents: continueItems }; const newItemSection = extractSectionListRenderer_Sections(fakeSectionList, this.context); if(newItemSection.channels) return new SearchItemSectionPlaylistPager(newItemSection); } } this.hasMore = false; this.results = []; return this; } } //#endregion //#region Requests function getAuthContextHeaders(useMobile = false, contentType = null) { const clientContext = getClientContext(true); const result = { "Accept-Language": "en-US", "x-goog-authuser": "0", "x-goog-pageid": clientContext.DELEGATED_SESSION_ID, "x-origin": useMobile ? URL_BASE_M : URL_BASE, "x-youtube-client-name": useMobile ? "2" : "1", "User-Agent": useMobile ? USER_AGENT_TABLET : USER_AGENT_WINDOWS }; if(contentType) result["Content-Type"] = contentType; return result; } function requestGuide(pageId) { if(!pageId) throw new ScriptException("No page id found, invalid authentication?"); const clientContext = getClientContext(true); const body = { context: clientContextAuth.INNERTUBE_CONTEXT }; const url = URL_GUIDE + "?key=" + clientContext.INNERTUBE_API_KEY + "&prettyPrint=false" const res = http.POST(url, JSON.stringify(body), getAuthContextHeaders(false, "application/json"), true); if (res.code != 200) { bridge.log("Failed to retrieve subscriptions page."); return []; } const data = JSON.parse(res.body); return data; } function requestNext(body, useAuth = false, useMobile = false) { const clientContext = getClientContext(useAuth); if(!clientContext || !clientContext.INNERTUBE_CONTEXT || !clientContext.INNERTUBE_API_KEY) throw new ScriptException("Missing client context"); body.context = clientContext.INNERTUBE_CONTEXT; const baseUrl = (useMobile) ? URL_NEXT_MOBILE : URL_NEXT; const url = baseUrl + "?key=" + clientContext.INNERTUBE_API_KEY + "&prettyPrint=false"; let headers = (!bridge.isLoggedIn() && useAuth) ? {} : getAuthContextHeaders(useMobile); headers["Content-Type"] = "application/json"; if(useMobile) { headers["User-Agent"] = USER_AGENT_TABLET; } if(useAuth) { headers["x-goog-authuser"] = clientContext.SESSION_INDEX ?? "0"; } const resp = http.POST(url, JSON.stringify(body), headers, useAuth); if(!resp.isOk) { log("Fail Url: " + url + "\nFail Body:\n" + JSON.stringify(body)); throw new ScriptException("Failed to next [" + resp.code + "]"); } return JSON.parse(resp.body); } function requestBrowse(body, useMobile = false, useAuth = false, attempt = 0) { const clientContext = getClientContext(useAuth); if(!clientContext || !clientContext.INNERTUBE_CONTEXT || !clientContext.INNERTUBE_API_KEY) throw new ScriptException("Missing client context"); body.context = clientContext.INNERTUBE_CONTEXT; let headers = !bridge.isLoggedIn() ? {} : getAuthContextHeaders(useMobile); if(useMobile) headers["User-Agent"] = USER_AGENT_TABLET; headers["Content-Type"] = "application/json"; const baseUrl = !useMobile ? URL_BROWSE : URL_BROWSE_MOBILE; const url = baseUrl + "?key=" + clientContext.INNERTUBE_API_KEY + "&prettyPrint=false"; const resp = http.POST(url, JSON.stringify(body), headers, useAuth); if(!resp.isOk) { if((resp.code == 408 || resp.code == 500) && attempt < 1) { return requestBrowse(body, useMobile, useAuth, attempt + 1); } log("Fail Url: " + url + "\nFail Body:\n" + JSON.stringify(body)); if(resp.code != 500 || !bridge.isLoggedIn()) throw new ScriptException("Failed to browse [" + resp.code + "]"); else { throw new ScriptLoginRequiredException("Failed to browse [" + resp.code + "]\nLogin might have expired, try logging in again"); } } return JSON.parse(resp.body); } function requestSearch(query, useAuth = false, params = null) { const clientContext = getClientContext(useAuth); if(!clientContext || !clientContext.INNERTUBE_CONTEXT || !clientContext.INNERTUBE_API_KEY) throw new ScriptException("Missing client context"); const body = { context: clientContext.INNERTUBE_CONTEXT, query: query }; if(params) body.params = params; const resp = http.POST(URL_SEARCH + "?key=" + clientContext.INNERTUBE_API_KEY + "&prettyPrint=false", JSON.stringify(body), { "User-Agent": USER_AGENT_WINDOWS, "Content-Type": "application/json" }, useAuth); if(!resp.isOk) throw new ScriptException("Failed to search [" + resp.code + "]"); return JSON.parse(resp.body); } function requestSearchContinuation(continuation, useAuth = false) { const clientContext = getClientContext(useAuth); if(!clientContext || !clientContext.INNERTUBE_CONTEXT || !clientContext.INNERTUBE_API_KEY) throw new ScriptException("Missing client context"); const body = { context: clientContext.INNERTUBE_CONTEXT, continuation: continuation }; const resp = http.POST(URL_SEARCH + "?key=" + clientContext.INNERTUBE_API_KEY + "&prettyPrint=false", JSON.stringify(body), { "Content-Type": "application/json" }, useAuth); if(!resp.isOk) throw new ScriptException("Failed to search [" + resp.code + "]"); return JSON.parse(resp.body); } function getRequestHeaders(additionalHeaders) { const headers = additionalHeaders ?? {}; return Object.assign(headers, {"Accept-Language": "en-US"}); } function requestPage(url, headers, useAuth = false) { const resp = http.GET(url, getRequestHeaders(headers), useAuth); throwIfCaptcha(resp); if(resp.isOk) return resp.body; else throw new ScriptException("Failed to request page [" + resp.code + "]"); } function requestInitialData(url, useMobile = false, useAuth = false) { let headers = {"Accept-Language": "en-US", "Cookie": "PREF=hl=en&gl=US" }; if(useMobile) headers["User-Agent"] = USER_AGENT_TABLET; const resp = http.GET(url, headers, useAuth); throwIfCaptcha(resp); if(resp.isOk) { let html = resp.body; if(html.indexOf("<form action=\"https://consent.youtube.com/save\"") > 0) { log("Consent form required"); const consentData = "gl=US&m=0&app=0&pc=yt&continue=" + encodeURIComponent(url) + "&x=6&bl=boq_identityfrontenduiserver_20231017.04_p0&hl=en&src=1&cm=2&set_eom=true"; const respConsent = http.POST("https://consent.youtube.com/save", consentData, { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", "Accept-Language": "en-US", "Content-Type": "application/x-www-form-urlencoded" }, useAuth); throwIfCaptcha(respConsent); if(respConsent.isOk) { const body = respConsent.body; if(respConsent.body.indexOf("<form action=\"https://consent.youtube.com/save\"") > 0) throw new CriticalException("Failed to refuse Google consent [" + respConsent.code + "]") else html = respConsent.body; } else throw new CriticalException("Failed to refuse Google consent [" + resp.code + "]"); } const initialData = getInitialData(html); return initialData; } else throw new ScriptException("Failed to request page [" + resp.code + "]\n" + url + "\n"); } function requestClientConfig(useMobile = false, useAuth = false) { let headers = { } if(useMobile) headers["User-Agent"] = USER_AGENT_TABLET; const resp = http.GET(!useMobile ? URL_CONTEXT : URL_CONTEXT_M, headers, useAuth); if(!resp.isOk) throw new ScriptException("Failed to request context requestClientConfig"); return getClientConfig(resp.body); } function requestIOSStreamingData(videoId, batch) { const body = { videoId: videoId, cpn: "" + randomString(16), contentCheckOk: "true", racyCheckOn: "true", context: { client: { "clientName": "IOS", "clientVersion": IOS_APP_VERSION,//"17.31.4",^M "deviceMake": "Apple", "deviceModel": IOS_DEVICE_VERSION,//"iPhone14,5",^M "platform": "MOBILE", "osName": "iOS", "osVersion": IOS_OS_VERSION_DETAILED,//"15.6.0.19G71",^M "hl": langDisplay, "gl": langRegion, }, user: { "lockedSafetyMode": false } } }; const headers = { "Content-Type": "application/json", "User-Agent": USER_AGENT_IOS, "X-Goog-Api-Format-Version": "2" }; const token = randomString(12); const clientContext = getClientContext(false); const url = URL_PLAYER + "?key=" + clientContext.INNERTUBE_API_KEY + "&prettyPrint=false" + "&t=" + token + "&id=" + videoId if(batch) { batch.POST(url, JSON.stringify(body), headers, false); return null; } else { const resp = http.POST(url, JSON.stringify(body), headers, false); return resp; } } function requestAndroidStreamingData(videoId) { const body = { videoId: videoId, cpn: "" + randomString(16), contentCheckOk: "true", racyCheckOn: "true", context: { client: { "clientName": "ANDROID", "clientVersion": "17.31.35", "platform": "MOBILE", "osName": "Android", "osVersion": "12", "androidSdkVersion": 31, "hl": langDisplay, "gl": langRegion, "params": "8AEB" }, user: { "lockedSafetyMode": false } } }; const headers = { "Content-Type": "application/json", "User-Agent": USER_AGENT_ANDROID, "X-Goog-Api-Format-Version": "2" }; const token = randomString(12); const clientContext = getClientContext(false); const url = URL_PLAYER + "?key=" + clientContext.INNERTUBE_API_KEY + "&prettyPrint=false" + "&t=" + token + "&id=" + videoId const resp = http.POST(url, JSON.stringify(body), headers, false); if(resp.isOk) return JSON.parse(resp.body); else return null; } function requestTvHtml5EmbedStreamingData(videoId, sts) { const body = { videoId: videoId, cpn: "" + randomString(16), contentCheckOk: "true", racyCheckOn: "true", playbackContext: { contentPlaybackContext: { signatureTimestamp: sts, referer: "https://www.youtube.com/watch?v=" + videoId } }, context: { client: { "clientName": "TVHTML5_SIMPLY_EMBEDDED_PLAYER", "clientVersion": "2.0", "clientScreen": "EMBED", "platform": "TV", "hl": langDisplay, "gl": langRegion }, thirdParty: { "embedUrl": "https://www.youtube.com/watch?v=" + videoId, }, user: { "lockedSafetyMode": false } } }; const headers = { "Content-Type": "application/json", "User-Agent": USER_AGENT_TVHTML5_EMBED, "X-Goog-Api-Format-Version": "2" }; const token = randomString(12); const clientContext = getClientContext(false); const url = URL_PLAYER + "?key=" + clientContext.INNERTUBE_API_KEY + "&prettyPrint=false" + "&t=" + token + "&id=" + videoId const resp = http.POST(url, JSON.stringify(body), headers, false); if(resp.isOk) return JSON.parse(resp.body); else return null; } //#endregion //#region Page Extraction function getInitialData(html, useAuth = false) { const clientContext = getClientContext(useAuth); //TODO: Fix regex instead of this temporary workaround. /* const startIndex = html.indexOf("var ytInitialData = "); const endIndex = html.indexOf(";</script>", startIndex); if(startIndex > 0 && endIndex > 0) { const raw = html.substring(startIndex + 20, endIndex); const initialDataRaw = raw.startsWith("'") && raw.endsWith("'") ? decodeHexEncodedString(raw.substring(1, raw.length - 1)) //TODO: Find proper decoding strat .replaceAll("\\\\\"", "\\\"") : raw; let initialData = null; try{ initialData = JSON.parse(initialDataRaw); } catch(ex) { console.log("Failed to parse initial data: ", initialDataRaw); throw ex; } if(clientContext?.INNERTUBE_CONTEXT && !clientContext.INNERTUBE_CONTEXT.client.visitorData && initialData.responseContext?.webResponseContextExtensionData?.ytConfigData?.visitorData) { clientContext.INNERTUBE_CONTEXT.client.visitorData = initialData.responseContext?.webResponseContextExtensionData?.ytConfigData?.visitorData log("Found new visitor (auth) data: " + clientContext.INNERTUBE_CONTEXT.client.visitorData); } return initialData; }*/ const match = html.match(REGEX_INITIAL_DATA); if(match) { const initialDataRaw = match[1].startsWith("'") && match[1].endsWith("'") ? decodeHexEncodedString(match[1].substring(1, match[1].length - 1)) //TODO: Find proper decoding strat .replaceAll("\\\\\"", "\\\"") : match[1]; let initialData = null; try{ initialData = JSON.parse(initialDataRaw); } catch(ex) { console.log("Failed to parse initial data: ", initialDataRaw); throw ex; } if(clientContext?.INNERTUBE_CONTEXT && !clientContext.INNERTUBE_CONTEXT.client.visitorData && initialData.responseContext?.webResponseContextExtensionData?.ytConfigData?.visitorData) { clientContext.INNERTUBE_CONTEXT.client.visitorData = initialData.responseContext?.webResponseContextExtensionData?.ytConfigData?.visitorData log("Found new visitor (auth) data: " + clientContext.INNERTUBE_CONTEXT.client.visitorData); } return initialData; } //if(initialData == null) // log(html); return null; } function getInitialPlayerData(html) { let match = html.match(REGEX_INITIAL_PLAYER_DATA); if(match) { let initialDataRaw = match[1]; try { return JSON.parse(initialDataRaw); } catch(ex) { //Fallback approach match = html.match(REGEX_INITIAL_PLAYER_DATA_FALLBACK); if(match) { initialDataRaw = match[1]; return JSON.parse(initialDataRaw); } } } return null; } function getClientConfig(html) { const matches = html.matchAll(REGEX_YTCFG); let match = null; for(let m of matches) { if(m && m.length >= 2 && m[1].indexOf("INNERTUBE_CONTEXT") > 0) { match = m; } } if(!match) throw new ScriptException("Context structure not found"); return JSON.parse(match[1]); } //#endregion //#region Top-Level Extraction /** * Extract Subscription channels from a submenu obtained from subscriptionsPage * @returns {PlatformAuthorLink[]} Channels */ function extractChannelListSubMenuAvatarRenderer_AuthorLink(renderer) { const thumbnail = renderer?.thumbnail?.thumbnails && renderer.thumbnail.thumbnails.length > 0 ? renderer.thumbnail.thumbnails[renderer.thumbnail.thumbnails.length - 1] : null; const name = renderer?.accessibility?.accessibilityData?.label ? renderer.accessibility.accessibilityData.label.trim() : ""; const url = renderer?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl ? URL_BASE + renderer.navigationEndpoint.browseEndpoint.canonicalBaseUrl : null; if(!url || !name) return null; else return new PlatformAuthorLink(new PlatformID(PLATFORM, null, config?.id, PLATFORM_CLAIMTYPE), name, url, thumbnail); } /** * Extract Subscription channels from a submenu obtained from subscriptionsPage * @returns {String[]} Urls */ function extractChannelListSubMenuAvatarRenderer_URL(renderer) { const canonicalUrl = renderer?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl ? URL_BASE + renderer.navigationEndpoint.browseEndpoint.canonicalBaseUrl : null; const idUrl = renderer?.navigationEndpoint?.browseEndpoint?.browseId ? URL_BASE + "/channel/" + renderer.navigationEndpoint.browseEndpoint.browseId : null; const url = idUrl ?? canonicalUrl; if(!url) return null; else return url; } /** * Extract Subscription channels from a sections[] obtained from guide() * @returns {PlatformAuthorLink[]} Channels */ function extractGuide_Channels(data) { let sections = data.items ?? []; let channels = []; for(let section of sections) { switchKey(section, { guideSubscriptionsSectionRenderer(renderer) { for(let item of renderer.items) { switchKey(item, { guideEntryRenderer(guideEntryRenderer) { channels.push(extractGuideEntry_AuthorLink(guideEntryRenderer)); }, guideCollapsibleEntryRenderer(collapseRenderer) { if(collapseRenderer.expandableItems?.length > 0) { for(let item of collapseRenderer.expandableItems) { switchKey(item, { guideEntryRenderer(guideEntryRenderer) { channels.push(extractGuideEntry_AuthorLink(guideEntryRenderer)); } }) } } } }); } } }); } return channels; } function extractGuideEntry_AuthorLink(guideEntryRenderer) { const thumbnail = guideEntryRenderer.thumbnail?.thumbnails?.length > 0 ? guideEntryRenderer.thumbnail.thumbnails[0].url : null; const name = guideEntryRenderer.formattedTitle?.simpleText ?? guideEntryRenderer.accessibility?.accessibilityData?.label; const url = guideEntryRenderer.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl ? URL_BASE + guideEntryRenderer.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl : null; return new PlatformAuthorLink(new PlatformID(PLATFORM, null, config.id, PLATFORM_CLAIMTYPE), name, url, thumbnail); } /** * Extract all video results and shelves from a search page's initial data * @param data Root-data from search() * @param contextData Any context values used to fill out data for resulting objects * @returns Object containing videos and shelves */ function extractSearch_SearchResults(data, contextData) { let searchContents = data.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer ?? data.contents?.sectionListRenderer; if(searchContents) { const results = extractSectionListRenderer_Sections(searchContents, contextData); return results; } return {}; } /** * Extracts a PlatformChannel from a channel page's initial data * @param initialData Initial data from a ChannelPage * @returns {PlatformChannel} */ function extractChannel_PlatformChannel(initialData, sourceUrl = null) { if(initialData?.header?.c4TabbedHeaderRenderer) { const headerRenderer = initialData?.header?.c4TabbedHeaderRenderer; if(IS_TESTING) console.log("Initial Data", initialData); const thumbnailTargetWidth = 200; const thumbnails = headerRenderer.avatar?.thumbnails; const thumbnail = (thumbnails && thumbnails.length > 0) ? thumbnails.sort((a,b)=>Math.abs(a.width - thumbnailTargetWidth) - Math.abs(b.width - thumbnailTargetWidth))[0] : { url: "" }; const banners = headerRenderer.banner?.thumbnails; const bannerTargetWidth = 1080; const banner = (banners && banners.length > 0) ? banners.sort((a,b)=>Math.abs(a.width - bannerTargetWidth) - Math.abs(b.width - bannerTargetWidth))[0] : { url: "" }; const idUrl = headerRenderer?.navigationEndpoint?.browseEndpoint?.browseId ? URL_BASE + "/channel/" + headerRenderer.navigationEndpoint.browseEndpoint.browseId : null; const canonicalUrl = headerRenderer?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl ? URL_BASE + headerRenderer.navigationEndpoint.browseEndpoint.canonicalBaseUrl : null; return new PlatformChannel({ id: new PlatformID(PLATFORM, headerRenderer.channelId, config.id, PLATFORM_CLAIMTYPE), name: headerRenderer.title ?? "", thumbnail: thumbnail.url, banner: banner.url, subscribers: Math.max(0, extractHumanNumber_Integer(extractText_String(headerRenderer.subscriberCountText))), description: "", url: idUrl, urlAlternatives: [idUrl, canonicalUrl], links: {} }); } else if(initialData?.header?.pageHeaderRenderer) { log("New channel model"); const headerRenderer = initialData?.header?.pageHeaderRenderer; if(IS_TESTING) console.log("Initial Data (New Model)", initialData); const thumbnailTargetWidth = 200; const thumbnails = headerRenderer?.content?.pageHeaderViewModel?.image?.decoratedAvatarViewModel?.avatar?.avatarViewModel?.image?.sources; const thumbnail = (thumbnails && thumbnails.length > 0) ? thumbnails.sort((a,b)=>Math.abs(a.width - thumbnailTargetWidth) - Math.abs(b.width - thumbnailTargetWidth))[0] : { url: "" }; const banners = headerRenderer?.content?.pageHeaderViewModel?.banner?.imageBannerViewModel?.image?.sources; const bannerTargetWidth = 1080; const banner = (banners && banners.length > 0) ? banners.sort((a,b)=>Math.abs(a.width - bannerTargetWidth) - Math.abs(b.width - bannerTargetWidth))[0] : { url: "" }; const id = initialData?.metadata?.channelMetadataRenderer?.externalId; if(!id) { log("ID not found in new channel viewmodel:" + JSON.stringify(id, null, " ")); if(bridge.devSubmit) bridge.devSubmit("extractChannel_PlatformChannel - ID Not found in new channel view model", JSON.stringify(initialData)); throw new ScriptException("ID Not found in new channel view model"); } const idUrl = id ? URL_BASE + "/channel/" + id: null; const canonicalUrl = initialData?.metadata?.channelMetadataRenderer?.vanityChannelUrl ? initialData?.metadata?.channelMetadataRenderer?.vanityChannelUrl : null; let subCount = 0; const metadataRows = headerRenderer?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows; for(let row of metadataRows) { const subsStr = row.metadataParts.find(x=>x.text?.content?.indexOf("subscribers") > 0)?.text?.content; if(!subsStr) continue; const subsNum = extractHumanNumber_Integer(extractText_String(subsStr)); if(!isNaN(subsNum) && subsNum > 0) { subCount = subsNum; break; } } return new PlatformChannel({ id: new PlatformID(PLATFORM, id, config.id, PLATFORM_CLAIMTYPE), name: initialData?.metadata?.channelMetadataRenderer?.title ?? "", thumbnail: thumbnail.url, banner: banner.url, subscribers: Math.max(0, subCount), description: initialData?.metadata?.channelMetadataRenderer?.description, url: idUrl, urlAlternatives: [idUrl, canonicalUrl].filter(x=>x != null), links: {} }); } else { log("Missing header: (" + sourceUrl + ")\n" + JSON.stringify(initialData, null, " ")); if(bridge.devSubmit) bridge.devSubmit("extractChannel_PlatformChannel - No header for " + sourceUrl, JSON.stringify(initialData)); throw new ScriptException("No header for " + sourceUrl); } } /** * Extracts multiple tabs from a page that contains a tab rendering * @param initialData Initial data from a page with a TwoColumnBrowseResultsRenderer * @param contextData Any context values used to fill out data for resulting objects * @returns */ function extractPage_Tabs(initialData, contextData) { const content = initialData.contents; if(!content) { if(bridge.devSubmit) bridge.devSubmit("extractPage_Tabs - Missing contents", JSON.stringify(initialData)); throw new ScriptException("Missing contents"); } return switchKey(content, { twoColumnBrowseResultsRenderer(renderer) { return extractTwoColumnBrowseResultsRenderer_Tabs(renderer, contextData); }, singleColumnBrowseResultsRenderer(renderer) { return extractSingleColumnBrowseResultsRenderer_Tabs(renderer, contextData); }, default(name) { if(bridge.devSubmit) bridge.devSubmit("extractPage_Tabs - Unknown renderer type: " + name, JSON.stringify(content)); throw new ScriptException("Unknown renderer type: " + name); } }); } //#endregion //#region Layout Extractors function extractVideoPage_VideoDetails(initialData, initialPlayerData, contextData, jsUrl, useLogin) { const contents = initialData.contents; const contentsContainer = contents.twoColumnWatchNextResults?.results?.results ?? null; if(!contentsContainer || !contentsContainer.contents || !initialPlayerData.videoDetails) return null; if (IS_TESTING) { console.log("initialData: ", initialData); console.log("playerData:", initialPlayerData); console.log("streamingData:", initialPlayerData?.streamingData); } const videoDetails = initialPlayerData.videoDetails; const nonce = randomString(16); const hlsSource = (initialPlayerData?.streamingData?.hlsManifestUrl) ? new HLSSource({ url: initialPlayerData?.streamingData?.hlsManifestUrl }) : null; const dashSource = (initialPlayerData?.streamingData?.dashManifestUrl) ? new DashSource({ url: initialPlayerData?.streamingData?.dashManifestUrl }) : null; const video = { id: new PlatformID(PLATFORM, videoDetails.videoId, config.id), name: videoDetails.title, thumbnails: new Thumbnails(videoDetails.thumbnail?.thumbnails.map(x=>new Thumbnail(escapeUnicode(x.url), x.height)) ?? []), author: new PlatformAuthorLink(new PlatformID(PLATFORM, videoDetails.channelId, config.id, PLATFORM_CLAIMTYPE), videoDetails.author, URL_BASE + "/channel/" + videoDetails.channelId, null, null), duration: parseInt(videoDetails.lengthSeconds), viewCount: parseInt(videoDetails.viewCount), url: contextData.url, isLive: videoDetails?.isLive ?? false, description: videoDetails.shortDescription, hls: (videoDetails?.isLive ?? false) ? hlsSource : null, dash: (videoDetails?.isLive ?? false) ? dashSource : null, live: (videoDetails?.isLive ?? false) ? (hlsSource ?? dashSource) : null, video: initialPlayerData?.streamingData?.adaptiveFormats ? new UnMuxVideoSourceDescriptor( initialPlayerData.streamingData.adaptiveFormats.filter(x=>x.mimeType.startsWith("video/")).map(y=>{ const codecs = y.mimeType.substring(y.mimeType.indexOf('codecs=\"') + 8).slice(0, -1); const container = y.mimeType.substring(0, y.mimeType.indexOf(';')); if(codecs.startsWith("av01")) return null; //AV01 is unsupported. const logItag = y.itag == 134; if(logItag) { log(videoDetails.title + " || Format " + container + " - " + y.itag + " - " + y.width); log("Source Parameters:\n" + JSON.stringify({ url: y.url, cipher: y.cipher, signatureCipher: y.signatureCipher }, null, " ")); } let url = decryptUrlN(y.url, jsUrl, logItag) ?? decryptUrl(y.cipher, jsUrl, logItag) ?? decryptUrl(y.signatureCipher, jsUrl, logItag); if(url.indexOf("&cpn=") < 0) url = url + "&cpn=" + nonce; const duration = parseInt(parseInt(y.approxDurationMs) / 1000) ?? 0; if(isNaN(duration)) return null; if(!y.initRange?.end || !y.indexRange?.end) return null; return new YTVideoSource({ name: y.height + "p" + (y.fps ? y.fps : "") + " " + container, url: url, width: y.width, height: y.height, duration: (!isNaN(duration)) ? duration : 0, container: y.mimeType.substring(0, y.mimeType.indexOf(';')), codec: codecs, bitrate: y.bitrate, itagId: y.itag, initStart: parseInt(y.initRange?.start), initEnd: parseInt(y.initRange?.end), indexStart: parseInt(y.indexRange?.start), indexEnd: parseInt(y.indexRange?.end) }, contextData.url); }).filter(x=>x != null), initialPlayerData.streamingData.adaptiveFormats.filter(x=>x.mimeType.startsWith("audio/")).map(y=>{ const codecs = y.mimeType.substring(y.mimeType.indexOf('codecs=\"') + 8).slice(0, -1); const container = y.mimeType.substring(0, y.mimeType.indexOf(';')); let url = decryptUrlN(y.url, jsUrl) ?? decryptUrl(y.cipher, jsUrl) ?? decryptUrl(y.signatureCipher, jsUrl); if(url.indexOf("&cpn=") < 0) url = url + "&cpn=" + nonce; const duration = parseInt(parseInt(y.approxDurationMs) / 1000); if(isNaN(duration)) return null; if(!y.initRange?.end || !y.indexRange?.end) return null; return new YTAudioSource({ name: y.audioTrack?.displayName ? y.audioTrack.displayName : codecs, container: container, bitrate: y.bitrate, url: url, duration: (!isNaN(duration)) ? duration : 0, container: y.mimeType.substring(0, y.mimeType.indexOf(';')), codec: codecs, language: ytLangIdToLanguage(y.audioTrack?.id), itagId: y.itag, initStart: parseInt(y.initRange?.start), initEnd: parseInt(y.initRange?.end), indexStart: parseInt(y.indexRange?.start), indexEnd: parseInt(y.indexRange?.end), audioChannels: y.audioChannels }, contextData.url); }).filter(x=>x!=null), ) : new VideoSourceDescriptor([]), subtitles: initialPlayerData .captions ?.playerCaptionsTracklistRenderer ?.captionTracks ?.map(x=>{ let kind = x.baseUrl.match(REGEX_URL_KIND); if(kind) kind = kind[1]; if(!kind || kind == "asr") { return { name: extractText_String(x.name), url: x.baseUrl, format: "text/vtt", getSubtitles() { const subResp = http.GET(x.baseUrl, {}); if(!subResp.isOk) return ""; const asr = subResp.body; let lines = asr.match(REGEX_ASR); const newSubs = []; let skipped = 0; for(let i = 0; i < lines.length; i++) { const line = lines[i]; const lineParsed = /<text .*?start="(.*?)" .*?dur="(.*?)".*?>(.*?)<\/text>/gms.exec(line); const start = parseFloat(lineParsed[1]); const dur = parseFloat(lineParsed[2]); let end = start + dur; const text = decodeHtml(lineParsed[3]); const nextLine = (i + 1 < lines.length) ? lines[i + 1] : null; if(nextLine) { const lineParsedNext = /<text .*?start="(.*?)" .*?dur="(.*?)".*?>(.*?)<\/text>/gms.exec(nextLine); const startNext = parseFloat(lineParsedNext[1]); const durNext = parseFloat(lineParsedNext[2]); const endNext = startNext + durNext; if(startNext && startNext < end) end = startNext; } newSubs.push((i - skipped + 1) + "\n" + toSRTTime(start, true) + " --> " + toSRTTime(end, true) + "\n" + text + "\n"); } console.log(newSubs); return "WEBVTT\n\n" + newSubs.join('\n'); } }; } else if(kind == "vtt") { return { name: extractText_String(x.name), url: x.baseUrl, format: "text/vtt", }; } else return null; })?.filter(x=>x != null) ?? [] }; //Adds HLS stream if any other format is not yet available, mostly relevant for recently ended livestreams. if(video.video.videoSources !== null && video.video.videoSources.length == 0 && initialPlayerData?.streamingData?.hlsManifestUrl) video.video.videoSources.push(new HLSSource({url: initialPlayerData.streamingData.hlsManifestUrl})); //Add additional/better details for(let i = 0; i < contentsContainer.contents.length; i++) { const content = contentsContainer.contents[i]; switchKey(content, { videoPrimaryInfoRenderer(renderer) { //if(renderer.title?.runs) // video.name = extractString_Runs(renderer.title.runs); if(renderer.viewCount?.videoViewCountRenderer?.viewCount?.simpleText) video.viewCount = extractFirstNumber_Integer(renderer.viewCount?.videoViewCountRenderer?.viewCount.simpleText) else if(renderer.viewCount?.videoViewCountRenderer?.viewCount?.runs) { video.viewCount = parseInt(extractFirstNumber_Integer(extractRuns_String(renderer.viewCount?.videoViewCountRenderer?.viewCount?.runs))); } if(renderer.viewCount?.videoViewCountRenderer?.isLive || renderer.viewCount?.videoViewCountRenderer?.viewCount?.isLive) video.isLive = true; else video.isLive = false; if(renderer.videoActions?.menuRenderer?.topLevelButtons) renderer.videoActions.menuRenderer.topLevelButtons.forEach((button)=>{ switchKey(button, { segmentedLikeDislikeButtonRenderer(renderer) { const likeButtonRenderer = renderer?.likeButton?.toggleButtonRenderer; if(likeButtonRenderer) { const likeTextData = likeButtonRenderer.defaultText; if(likeTextData){ if(likeTextData.accessibility?.accessibilityData?.label) video.rating = new RatingLikes(extractFirstNumber_Integer(likeTextData.accessibility.accessibilityData.label)); else if(likeTextData.simpleText) video.rating = new RatingLikes(extractHumanNumber_Integer(likeTextData.simpleText)); } } }, segmentedLikeDislikeButtonViewModel(renderer) { if(IS_TESTING) console.log("Found new likes model:", renderer); let likeButtonViewModel = renderer?.likeButtonViewModel; if(likeButtonViewModel.likeButtonViewModel) //Youtube double nested, not sure if a bug on their end which may be removed likeButtonViewModel = likeButtonViewModel.likeButtonViewModel; let toggleButtonViewModel = likeButtonViewModel?.toggleButtonViewModel; if(toggleButtonViewModel.toggleButtonViewModel) //Youtube double nested, not sure if a bug on their end which may be removed toggleButtonViewModel = toggleButtonViewModel.toggleButtonViewModel; const buttonViewModel = toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel; if(buttonViewModel?.title) { let num = parseInt(buttonViewModel.title); if(!isNaN(num)) video.rating = new RatingLikes(num); num = extractHumanNumber_Integer(buttonViewModel.title); if(!isNaN(num) && num >= 0) video.rating = new RatingLikes(num); else if(buttonViewModel.title?.toLowerCase() == "like") video.rating = new RatingLikes(0); else { if(bridge.devSubmit) bridge.devSubmit("extractVideoPage_VideoDetails - Found unknown likes model", JSON.stringify(buttonViewModel)); throw new ScriptException("Found unknown likes model, please report to dev:\n" + JSON.stringify(buttonViewModel.title)); } } else log("UNKNOWN LIKES MODEL:\n" + JSON.stringify(renderer, null, " ")); } }); }); if(!video.datetime || video.datetime <= 0) { let date = 0; if (date <= 0 && renderer.relativeDateText?.simpleText) date = extractAgoText_Timestamp(renderer.relativeDateText.simpleText); if(date <= 0 && renderer.dateText?.simpleText) date = extractDate_Timestamp(renderer.dateText.simpleText); video.datetime = date; } }, videoSecondaryInfoRenderer(renderer) { if(renderer.owner.videoOwnerRenderer) video.author = extractVideoOwnerRenderer_AuthorLink(renderer.owner.videoOwnerRenderer); if(renderer.description?.runs) video.description = extractRuns_Html(renderer.description.runs); }, itemSectionRenderer() { //Comments } }); } const scheduledTime = initialPlayerData?.playabilityStatus?.liveStreamability?.liveStreamabilityRenderer?.offlineSlate?.liveStreamOfflineSlateRenderer?.scheduledStartTime; if(scheduledTime && !isNaN(scheduledTime)) video.datetime = parseInt(scheduledTime); const result = new PlatformVideoDetails(video); if(!useLogin){ result.getComments = function() { return extractTwoColumnWatchNextResultContents_CommentsPager(contextData.url, contentsContainer?.contents, useLogin) }; } return result; } function extractAdaptiveFormats_VideoDescriptor(adaptiveSources, jsUrl, contextData, prefix) { const nonce = randomString(16); return adaptiveSources ? new UnMuxVideoSourceDescriptor( adaptiveSources.filter(x=>x.mimeType.startsWith("video/")).map(y=>{ const codecs = y.mimeType.substring(y.mimeType.indexOf('codecs=\"') + 8).slice(0, -1); const container = y.mimeType.substring(0, y.mimeType.indexOf(';')); if(codecs.startsWith("av01")) return null; //AV01 is unsupported. const logItag = y.itag == 134; if(logItag) { //log(videoDetails.title + " || Format " + container + " - " + y.itag + " - " + y.width); log("Source Parameters:\n" + JSON.stringify({ url: y.url, cipher: y.cipher, signatureCipher: y.signatureCipher }, null, " ")); } let url = decryptUrlN(y.url, jsUrl, logItag) ?? decryptUrl(y.cipher, jsUrl, logItag) ?? decryptUrl(y.signatureCipher, jsUrl, logItag); if(url.indexOf("&cpn=") < 0) url = url + "&cpn=" + nonce; const duration = parseInt(parseInt(y.approxDurationMs) / 1000) ?? 0; if(isNaN(duration)) return null; if(!y.initRange?.end || !y.indexRange?.end) return null; return new YTVideoSource({ name: prefix + y.height + "p" + (y.fps ? y.fps : "") + " " + container, url: url, width: y.width, height: y.height, duration: (!isNaN(duration)) ? duration : 0, container: y.mimeType.substring(0, y.mimeType.indexOf(';')), codec: codecs, bitrate: y.bitrate, itagId: y.itag, initStart: parseInt(y.initRange?.start), initEnd: parseInt(y.initRange?.end), indexStart: parseInt(y.indexRange?.start), indexEnd: parseInt(y.indexRange?.end) }, contextData); }).filter(x=>x != null), adaptiveSources.filter(x=>x.mimeType.startsWith("audio/")).map(y=>{ const codecs = y.mimeType.substring(y.mimeType.indexOf('codecs=\"') + 8).slice(0, -1); const container = y.mimeType.substring(0, y.mimeType.indexOf(';')); let url = decryptUrlN(y.url, jsUrl) ?? decryptUrl(y.cipher, jsUrl) ?? decryptUrl(y.signatureCipher, jsUrl); if(url.indexOf("&cpn=") < 0) url = url + "&cpn=" + nonce; const duration = parseInt(parseInt(y.approxDurationMs) / 1000); if(isNaN(duration)) return null; if(!y.initRange?.end || !y.indexRange?.end) return null; return new YTAudioSource({ name: prefix + (y.audioTrack?.displayName ? y.audioTrack.displayName : codecs), container: container, bitrate: y.bitrate, url: url, duration: (!isNaN(duration)) ? duration : 0, container: y.mimeType.substring(0, y.mimeType.indexOf(';')), codec: codecs, language: ytLangIdToLanguage(y.audioTrack?.id), itagId: y.itag, initStart: parseInt(y.initRange?.start), initEnd: parseInt(y.initRange?.end), indexStart: parseInt(y.indexRange?.start), indexEnd: parseInt(y.indexRange?.end), audioChannels: y.audioChannels }, contextData); }).filter(x=>x!=null), ) : new VideoSourceDescriptor([]) } function toSRTTime(sec, withDot) { let hours = 0; let minutes = 0; let seconds = sec; let remainder = 0; remainder = parseInt((seconds % 1) * 100); minutes = parseInt(seconds / 60); seconds = parseInt(seconds % 60); hours = parseInt(minutes / 60); minutes = minutes % 60; return ("" + hours).padStart(2, '0') + ":" + ("" + minutes).padStart(2, '0') + ":" + ("" + seconds).padStart(2, '0') + ((withDot) ? "." : ",") + ("" + remainder).padEnd(3, '0'); } function extractVideoOwnerRenderer_AuthorLink(renderer) { const id = renderer?.navigationEndpoint?.browseEndpoint?.browseId; const url = (!id) ? extractRuns_Url(renderer.title.runs) : URL_BASE + "/channel/" + id; const hasMembership = !!(renderer?.membershipButton?.buttonRenderer) let membershipUrl = (hasMembership) ? url + "/join" : null; let bestThumbnail = null; if(renderer.thumbnail?.thumbnails) bestThumbnail = renderer.thumbnail.thumbnails[renderer.thumbnail.thumbnails.length - 1].url; let subscribers = null; if(renderer.subscriberCountText) subscribers = extractHumanNumber_Integer(extractText_String(renderer.subscriberCountText)); return new PlatformAuthorLink(new PlatformID(PLATFORM, id, config.id, PLATFORM_CLAIMTYPE), extractRuns_String(renderer.title.runs), url, bestThumbnail, subscribers, membershipUrl); } function extractTwoColumnWatchNextResultContents_CommentsPager(contextUrl, contents, useLogin, engagementPanels) { //Add additional/better details let totalComments = 0; let commentsToken = null; for(let i = 0; i < contents.length; i++) { const content = contents[i]; switchKey(content, { videoPrimaryInfoRenderer(renderer) { }, videoSecondaryInfoRenderer(renderer) { }, itemSectionRenderer(itemSectionRenderer) { const contents = itemSectionRenderer.contents; const content = contents && contents.length > 0 ? contents[0] : null; if(content) switchKey(content, { commentsEntryPointHeaderRenderer(renderer) { const commentCount = extractText_String(renderer.commentCount); if(commentCount) { totalComments = parseInt(commentCount); } }, continuationItemRenderer(continueRenderer) { if(totalComments > 0 && itemSectionRenderer.targetId == 'comments-section' && continueRenderer?.continuationEndpoint?.continuationCommand) { commentsToken = continueRenderer.continuationEndpoint.continuationCommand.token; } } }); } }); } const commentSectionPanel = engagementPanels?.find(x=>x?.engagementPanelSectionListRenderer?.panelIdentifier == "engagement-panel-comments-section"); const altContinuation = commentSectionPanel?.engagementPanelSectionListRenderer?.content?.sectionListRenderer?.contents ?.find(y=>true)?.itemSectionRenderer; if(altContinuation != null && !commentsToken && altContinuation.sectionIdentifier == "comment-item-section") { const continuationRenderer = altContinuation?.contents?.find(y=>true)?.continuationItemRenderer; const altToken = continuationRenderer?.continuationEndpoint?.continuationCommand?.token; if(altToken) commentsToken = altToken; } if(!commentsToken) return new CommentPager([], false); return requestCommentPager(contextUrl, commentsToken, useLogin, useLogin) ?? new CommentPager([], false); } function requestCommentPager(contextUrl, continuationToken, useLogin, useMobile) { const data = requestNext({ continuation: continuationToken }, useLogin, useMobile); if(IS_TESTING) console.log("data", data); const endpoints = data?.onResponseReceivedCommands ?? data?.onResponseReceivedActions ?? data?.onResponseReceivedEndpoints; if(!endpoints) { log("Comment object:\n" + JSON.stringify(data, null, " ")); if(bridge.devSubmit) bridge.devSubmit("requestCommentPager - No comment endpoints", JSON.stringify(data)); throw new ScriptException("No comment endpoints provided by Youtube"); } let commentsContinuation = null; for(let i = 0; i < endpoints.length; i++) { const endpoint = endpoints[i]; const continuationItems = endpoint.reloadContinuationItemsCommand?.continuationItems ?? endpoint.appendContinuationItemsAction?.continuationItems; if(continuationItems && continuationItems.length > 0) { let comments = []; if(continuationItems && continuationItems.length > 0) { for(let continuationItem of continuationItems) { switchKey(continuationItem, { commentThreadRenderer(renderer) { const commentRenderer = renderer?.comment?.commentRenderer; if(!commentRenderer) return; const replyCount = (commentRenderer.replyCount ? commentRenderer?.replyCount : 0); let replyContinuation = renderer.replies?.commentRepliesRenderer?.contents?.length == 1 ? (renderer.replies.commentRepliesRenderer.contents[0]?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token ?? renderer.replies.commentRepliesRenderer.contents[0]?.continuationItemRenderer?.button?.buttonRenderer?.command?.continuationCommand?.token) : null; comments.push(extractCommentRenderer_Comment(contextUrl, commentRenderer, replyCount, replyContinuation, useLogin, useMobile)); }, commentRenderer(renderer) { comments.push(extractCommentRenderer_Comment(contextUrl, renderer, 0, null, useLogin, useMobile)); }, continuationItemRenderer(renderer) { if(renderer?.continuationEndpoint?.continuationCommand?.token) commentsContinuation = renderer?.continuationEndpoint?.continuationCommand?.token; else if(renderer?.button?.buttonRenderer?.command?.continuationCommand?.token) commentsContinuation = renderer.button.buttonRenderer.command.continuationCommand.token } }); } if(comments.length > 0) { return new YTCommentPager(comments, commentsContinuation, contextUrl, useLogin, useMobile); } } } } if(data?.frameworkUpdates?.entityBatchUpdate?.mutations) { log("New comments model"); const mutations = data.frameworkUpdates.entityBatchUpdate.mutations; if(mutations.length > 0) { const comments = []; let parentItems = []; for(let i = 0; i < endpoints.length; i++) parentItems.push(...(endpoints[i].reloadContinuationItemsCommand?.continuationItems ?? endpoints[i].appendContinuationItemsAction?.continuationItems ?? [])); parentItems = parentItems.filter(x=>x.commentThreadRenderer); const commentObjects = mutations.filter(x=>x?.payload?.commentEntityPayload); for(let commentObject of commentObjects) { const cobj = commentObject?.payload?.commentEntityPayload ?? {}; const parent = parentItems.find(x=>x.commentThreadRenderer?.commentViewModel?.commentViewModel?.commentKey == commentObject.entityKey); const replyContents = parent?.commentThreadRenderer?.replies?.commentRepliesRenderer?.contents; const replyContinuation = ((replyContents?.length ?? 0) > 0) ? (replyContents[0].continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token ?? replyContents[0].continuationItemRenderer?.button?.buttonRenderer?.command?.continuationCommand?.token) : null; const authorEndpoint = cobj.author?.channelCommand?.innertubeCommand?.commandMetadata?.webCommandMetadata?.url; comments.push(new YTComment({ contextUrl: contextUrl, author: new PlatformAuthorLink(new PlatformID(PLATFORM, cobj?.author?.displayName, config.id, PLATFORM_CLAIMTYPE), cobj.author.displayName, (authorEndpoint) ? URL_BASE + authorEndpoint : "", cobj.author.avatarThumbnailUrl), message: cobj.properties?.content?.content ?? "", rating: new RatingLikes(extractHumanNumber_Integer(cobj.toolbar?.likeCountLiked) ?? 0), date: (extractAgoTextRuns_Timestamp(cobj?.properties?.publishedTime) ?? 0), replyCount: extractFirstNumber_Integer(cobj?.toolbar?.replyCount) ?? 0, context: { replyContinuation: replyContinuation, useLogin: useLogin + "", useMobile: useMobile + "" } })); } if(comments.length > 0) { return new YTCommentPager(comments, commentsContinuation, contextUrl, useLogin, useMobile); } } } log("Comment object:\n" + JSON.stringify(data, null, " ")); if(bridge.devSubmit) bridge.devSubmit("requestCommentPager - No comment endpoints", JSON.stringify(data)); throw new ScriptException("No valid comment endpoint provided by Youtube"); } function extractSingleColumnBrowseResultsRenderer_Tabs(renderer, contextData) { const tabs = []; if(!renderer.tabs) { if(bridge.devSubmit) bridge.devSubmit("extractSingleColumnBrowseResultsRenderer_Tabs - No tabs found", JSON.stringify(renderer)); throw new ScriptException("No tabs found"); } for(let i = 0; i < renderer.tabs.length; i++) { const tab = renderer.tabs[i]; if(!tab.tabRenderer) continue; const tabRenderer = tab.tabRenderer; //TODO: Check if this is ever null? const isDefault = tabRenderer.selected; const title = tabRenderer.title; const content = tabRenderer.content; if(!content) continue; //.endpoint const tabContentRendererName = Object.keys(content)[0]; if(!tabContentRendererName) continue; let tabResult = undefined; switchKey(content, { richGridRenderer(renderer) { tabResult = extractRichGridRenderer_Shelves(content[tabContentRendererName], contextData); }, sectionListRenderer(renderer) { if(!renderer.contents) return; tabResult = extractSectionListRenderer_Sections(renderer, contextData); }, default() { if(bridge.devSubmit) bridge.devSubmit("extractSingleColumnBrowseResultsRenderer_Tabs - Unknown tab renderer: " + tabContentRendererName, JSON.stringify(content)); throw new ScriptException("Unknown tab renderer: " + tabContentRendererName); } }); if(tabResult) { tabResult.isDefault = !!isDefault; tabResult.title = title; tabs.push(tabResult); } } return tabs; } function extractTwoColumnBrowseResultsRenderer_Tabs(renderer, contextData) { const tabs = []; if(!renderer.tabs) { if(bridge.devSubmit) bridge.devSubmit("extractTwoColumnBrowseResultsRenderer_Tabs - No tabs found", JSON.stringify(renderer)); throw new ScriptException("No tabs found"); } for(let i = 0; i < renderer.tabs.length; i++) { const tab = renderer.tabs[i]; if(!tab.tabRenderer && !tab.expandableTabRenderer) continue; const tabRenderer = tab.tabRenderer ?? tab.expandableTabRenderer; const isDefault = tabRenderer.selected; const title = tabRenderer.title; const content = tabRenderer.content; if(!content) continue; //.endpoint const tabContentRendererName = Object.keys(content)[0]; if(!tabContentRendererName) continue; let tabResult = undefined; switchKey(content, { richGridRenderer(renderer) { tabResult = extractRichGridRenderer_Shelves(renderer, contextData); }, sectionListRenderer(renderer) { //Channel sectioned tabs.. tabResult = extractSectionListRenderer_Sections(renderer, contextData) }, default() { if(bridge.devSubmit) bridge.devSubmit("extractTwoColumnBrowseResultsRenderer_Tabs - Unknown tab renderer: " + tabContentRendererName, JSON.stringify(renderer)); throw new ScriptException("Unknown tab renderer: " + tabContentRendererName); } }); if(tabResult) { tabResult.isDefault = !!isDefault; tabResult.title = title; tabs.push(tabResult); } } return tabs; } function extractRichGridRenderer_Shelves(richGridRenderer, contextData) { const contents = richGridRenderer.contents; let shelves = []; let videos = []; let continuation = null; for(let ci = 0; ci < contents.length; ci++) { const content = contents[ci]; switchKey(content, { richSectionRenderer(renderer) { shelves.push(extractRichSectionRenderer_Shelf(renderer, contextData)); }, richItemRenderer(renderer) { videos.push(extractRichItemRenderer_Video(renderer, contextData)); }, continuationItemRenderer(renderer) { continuation = extractContinuationItemRenderer_Continuation(renderer, contextData); }, itemSectionRenderer(renderer) { const items = extractItemSectionRenderer_Shelves(renderer, contextData); if(items.shelves) shelves = shelves.concat(items.shelves); if(items.videos) videos = videos.concat(items.videos); }, default(name) { log("Unknown shelf/section renderer in extractRichGridRenderer_Shelves: " + name); } }); } return { shelves: shelves.filter(x=>x != null), videos: videos.filter(x=>x != null), continuation: continuation }; } function extractSectionListRenderer_Sections(sectionListRenderer, contextData) { const contents = sectionListRenderer.contents; let shelves = []; let videos = []; let channels = []; let playlists = []; let continuation = null; for(let i = 0; i < contents.length; i++) { const item = contents[i]; switchKey(item, { itemSectionRenderer(renderer) { const items = extractItemSectionRenderer_Shelves(renderer, contextData); if(items.videos.length > 0) videos.push(...items.videos); if(items.channels.length > 0) channels.push(...items.channels); if(items.playlists.length > 0) playlists.push(...items.playlists); if(items.shelves) shelves.push(...items.shelves); }, continuationItemRenderer(renderer) { continuation = extractContinuationItemRenderer_Continuation(renderer, contextData); } }); } return { shelves: shelves, videos: videos, channels: channels, playlists: playlists, continuation: continuation }; } function extractItemSectionRenderer_Shelves(itemSectionRenderer, contextData) { const contents = itemSectionRenderer.contents ?? itemSectionRenderer.results; let shelves = []; let videos = []; let channels = []; let playlists = []; let continuationToken = undefined; contents.forEach((item)=>{ switchKey(item, { channelFeaturedContentRenderer(renderer) { if(renderer.items) { let videos = switchKeyVideos(renderer.items, contextData); if(videos && videos.length > 0) { shelves.push({ name: "Featured", type: "Shelf", videos: videos }); } } }, channelRenderer(renderer) { const channel = extractChannelRenderer_AuthorLink(renderer); if(channel) channels.push(channel); }, playlistRenderer(renderer) { const playlist = extractPlaylistRenderer_Playlist(renderer); if(playlist) playlists.push(playlist); }, shelfRenderer(renderer) { const shelf = extractShelfRenderer_Shelf(renderer); if(shelf) shelves.push(shelf); }, gridRenderer(renderer) { const shelf = extractGridRenderer_Shelf(renderer, contextData); if(shelf.playlists.length > 0) playlists.push(...shelf.playlists); }, continuationItemRenderer(renderer) { const token = renderer?.continuationEndpoint?.continuationCommand?.token if(token) continuationToken = token; }, default() { const video = switchKeyVideo(item, contextData); if(video) videos.push(video); } }); }); return { shelves: shelves.filter(x=>x != null), videos: videos.filter(x=>x != null), channels: channels.filter(x=>x != null), playlists: playlists.filter(x=>x != null), continuation: continuationToken }; } function extractGridRenderer_Shelf(gridRenderer, contextData) { const contents = gridRenderer.items; let shelves = []; let videos = []; let channels = []; let playlists = []; contents.forEach((item)=>{ switchKey(item, { gridPlaylistRenderer(renderer) { const playlist = extractPlaylistRenderer_Playlist(renderer, contextData); if(playlist) playlists.push(playlist); }, default() { const video = switchKeyVideo(item, contextData); if(video) videos.push(video); } }); }); return { videos: videos.filter(x=>x != null), channels: channels.filter(x=>x != null), playlists: playlists.filter(x=>x != null) }; } function switchKeyVideos(contents, contextData) { let videos = []; for(let content of contents) { const video = switchKeyVideo(content, contextData); if(video) videos.push(video); } return videos; } function switchKeyVideo(content, contextData) { return switchKey(content, { channelFeaturedContentRenderer(renderer) { return extractVideoRenderer_Video(renderer, contextData); }, videoRenderer(renderer) { return extractVideoRenderer_Video(renderer, contextData); }, compactVideoRenderer(renderer) { return extractVideoWithContextRenderer_Video(renderer, contextData); }, videoWithContextRenderer(renderer) { return extractVideoWithContextRenderer_Video(renderer, contextData); }, reelItemRenderer(renderer) { return extractReelItemRenderer_Video(renderer, contextData); }, adSlotRenderer(adSlot) { return null; }, default(name) { return null; } }); } //#endregion //#region Element Extractors function extractShelfRenderer_Shelf(shelfRenderer, contextData) { let name = extractText_String(shelfRenderer.title); return switchKey(shelfRenderer.content, { expandedShelfContentsRenderer(renderer) { return { name: name, type: "Shelf", videos: switchKeyVideos(renderer.items) }; }, default() { return null; } }); } function extractContinuationItemRenderer_Continuation(continuationItemRenderer) { return { url: continuationItemRenderer.continuationEndpoint.commandMetadata.apiUrl, //TODO: See if this is useful at all token: continuationItemRenderer.continuationEndpoint.continuationCommand.token }; } function extractRichSectionRenderer_Shelf(sectionRenderer, contextData) { const content = sectionRenderer.content; return switchKey(content, { richShelfRenderer(renderer) { return extractRichShelfRenderer_Shelf(renderer, contextData); }, default(name) { log("Unknown shelf renderer in extractRichSectionRenderer_Shelf: " + name); return null; //throw new ScriptException("Unknown shelf renderer: " + name); } }); } function extractRichShelfRenderer_Shelf(shelfRenderer, contextData) { const shelf = { name: extractRuns_String(shelfRenderer.title?.runs), type: "Shelf", videos: [] }; for(let itemi = 0; itemi < shelfRenderer.contents.length; itemi++) { const item = shelfRenderer.contents[itemi]; switchKey(item, { richItemRenderer(renderer) { shelf.videos.push(extractRichItemRenderer_Video(renderer), contextData); }, default(name) { log("Unknown shelf renderer in extractRichShelfRenderer_Shelf: " + name); //throw new ScriptException("Unknown item renderer: " + name); } }); } shelf.videos = shelf.videos.filter(x=>x != null); return shelf; } //#endregion //#region Item Extractor function extractRichItemRenderer_Video(itemRenderer, contextData) { const content = itemRenderer.content; return switchKeyVideo(content, contextData); } function extractVideoWithContextRenderer_Video(videoRenderer, contextData) { const liveBadges = videoRenderer.thumbnailOverlays?.filter(x=> x.thumbnailOverlayTimeStatusRenderer?.style == "LIVE" || x.thumbnailOverlayTimeStatusRenderer?.accessibility?.accessibilityData?.label == "LIVE"); let isLive = liveBadges != null && liveBadges.length > 0; isLive = isLive || ((videoRenderer.badges?.filter(x=>x.metadataBadgeRenderer?.style == "BADGE_STYLE_TYPE_LIVE_NOW")?.length ?? 0) > 0) let plannedDate = null; if(videoRenderer.upcomingEventData?.startTime) plannedDate = parseInt(videoRenderer.upcomingEventData.startTime); //if(!isLive && !videoRenderer.publishedTimeText?.simpleText) // return null; //Not a normal video const author = (contextData && contextData.authorLink) ? contextData.authorLink : extractVideoWithContextRenderer_AuthorLink(videoRenderer); if(IS_TESTING) ;//console.log(videoRenderer); //if(!videoRenderer?.lengthText?.runs || !videoRenderer.publishedTimeText?.runs) // isLive = true; //If no length, live after all? let viewCount = 0; if(videoRenderer?.shortViewCountText) viewCount = extractHumanNumber_Integer(extractText_String(videoRenderer.shortViewCountText)); else log("No viewcount found on video " + videoRenderer.videoId); const title = (videoRenderer.headline) ? extractText_String(videoRenderer.headline) : extractText_String(videoRenderer.title); if (isLive) { return new PlatformVideo({ id: new PlatformID(PLATFORM, videoRenderer.videoId, config.id), name: escapeUnicode(title), thumbnails: extractThumbnail_Thumbnails(videoRenderer.thumbnail), author: author, uploadDate: plannedDate ?? parseInt(new Date().getTime() / 1000), duration: 0, viewCount: viewCount, url: URL_BASE + "/watch?v=" + videoRenderer.videoId, isLive: true, extractType: "VideoWithContext" }); } else { return new PlatformVideo({ id: new PlatformID(PLATFORM, videoRenderer.videoId, config.id), name: escapeUnicode(title), thumbnails: extractThumbnail_Thumbnails(videoRenderer.thumbnail), author: author, uploadDate: parseInt(extractAgoText_Timestamp(extractText_String(videoRenderer.publishedTimeText))), duration: extractHumanTime_Seconds(extractText_String(videoRenderer.lengthText)), viewCount: viewCount, url: URL_BASE + "/watch?v=" + videoRenderer.videoId, isLive: false, extractType: "VideoWithContext" }); } } function extractVideoRenderer_Video(videoRenderer, contextData) { const liveBadges = videoRenderer.badges?.filter(x=>x.metadataBadgeRenderer?.label == "LIVE"); const liveOverlays = videoRenderer.thumbnailOverlays?.filter(x=> x.thumbnailOverlayTimeStatusRenderer?.style == "LIVE" || x.thumbnailOverlayTimeStatusRenderer?.accessibility?.accessibilityData?.label == "LIVE"); let isLive = (liveBadges != null && liveBadges.length > 0) || (liveOverlays != null && liveOverlays.length > 0); let plannedDate = null; if(videoRenderer.upcomingEventData?.startTime) plannedDate = parseInt(videoRenderer.upcomingEventData.startTime); if(plannedDate) isLive = true; isLive = isLive || ((videoRenderer.badges?.filter(x=>x.metadataBadgeRenderer?.style == "BADGE_STYLE_TYPE_LIVE_NOW")?.length ?? 0) > 0) if(!isLive && !videoRenderer.publishedTimeText?.simpleText) return null; //Not a normal video const author = (contextData && contextData.authorLink) ? contextData.authorLink : extractVideoRenderer_AuthorLink(videoRenderer); if(IS_TESTING) console.log(videoRenderer); // if(!videoRenderer?.lengthText?.simpleText) // isLive = true; //If no length, live after all? if(isLive) return new PlatformVideo({ id: new PlatformID(PLATFORM, videoRenderer.videoId, config.id), name: escapeUnicode(extractRuns_String(videoRenderer.title.runs)), thumbnails: extractThumbnail_Thumbnails(videoRenderer.thumbnail), author: author, uploadDate: plannedDate ?? parseInt(new Date().getTime()/1000), duration: 0, viewCount: !videoRenderer.viewCountText ? 0 : extractRuns_ViewerCount(videoRenderer.viewCountText.runs), url: URL_BASE + "/watch?v=" + videoRenderer.videoId, isLive: true, extractType: "Video" }); else return new PlatformVideo({ id: new PlatformID(PLATFORM, videoRenderer.videoId, config.id), name: escapeUnicode(extractRuns_String(videoRenderer.title.runs)), thumbnails: extractThumbnail_Thumbnails(videoRenderer.thumbnail), author: author, uploadDate: parseInt(extractAgoText_Timestamp(videoRenderer.publishedTimeText.simpleText)), duration: extractHumanTime_Seconds(videoRenderer.lengthText.simpleText), viewCount: extractFirstNumber_Integer(extractText_String(videoRenderer.viewCountText)), url: URL_BASE + "/watch?v=" + videoRenderer.videoId, isLive: false, extractType: "Video" }); } function extractReelItemRenderer_Video(reelItemRenderer) { //We don't do shorts for now.. return null; } function extractPlaylistVideoRenderer_Video(videoRenderer, contextData) { if(!videoRenderer.lengthText) return null; const author = (contextData && contextData.authorLink) ? contextData.authorLink : extractRuns_AuthorLink(videoRenderer.shortBylineText?.runs); if(IS_TESTING) console.log(videoRenderer); let date = 0; if(videoRenderer?.publishedTimeText?.simpleText) date = parseInt(extractAgoText_Timestamp(videoRenderer.publishedTimeText.simpleText)); return new PlatformVideo({ id: new PlatformID(PLATFORM, videoRenderer.videoId, config.id), name: escapeUnicode(extractRuns_String(videoRenderer.title.runs)), thumbnails: extractThumbnail_Thumbnails(videoRenderer.thumbnail), author: author, uploadDate: date, duration: extractHumanTime_Seconds(extractText_String(videoRenderer.lengthText)), viewCount: 0,//extractFirstNumber_Integer(videoRenderer.viewCountText.simpleText), url: URL_BASE + "/watch?v=" + videoRenderer.videoId, isLive: false, extractType: "Video" }); } function extractPlaylistRenderer_Playlist(playlistRenderer, contextData) { const author = (contextData && contextData.authorLink) ? contextData.authorLink : extractRuns_AuthorLink(playlistRenderer.shortBylineText?.runs); let thumbnail = (playlistRenderer.thumbnails && playlistRenderer.thumbnails.length > 0) ? extractThumbnail_BestUrl(playlistRenderer.thumbnails[0]) : null; if(!thumbnail && playlistRenderer.thumbnail) thumbnail = extractThumbnail_BestUrl(playlistRenderer.thumbnail); return new PlatformPlaylist({ id: new PlatformID(PLATFORM, playlistRenderer.playlistId, config.id), author: author, name: extractText_String(playlistRenderer.title), thumbnail: thumbnail, url: URL_PLAYLIST + playlistRenderer.playlistId, videoCount: extractFirstNumber_Integer(extractText_String(playlistRenderer.videoCountText)), }); } function extractChannelRenderer_AuthorLink(channelRenderer) { const id = channelRenderer.channelId; const name = extractText_String(channelRenderer.title); const channelUrl = extractNavigationEndpoint_Url(channelRenderer.navigationEndpoint); let thumbUrl = extractThumbnail_BestUrl(channelRenderer.thumbnail); if(thumbUrl.startsWith("//")) thumbUrl = "https:" + thumbUrl; const subscribers = extractHumanNumber_Integer(extractText_String(channelRenderer.videoCountText)); return new PlatformAuthorLink(new PlatformID(PLATFORM, id, config.id, PLATFORM_CLAIMTYPE), name, channelUrl, thumbUrl, subscribers); } function extractRuns_AuthorLink(runs) { if(!runs || runs.length == 0) return null; const id = runs[0]?.navigationEndpoint?.browseEndpoint?.browseId; const name = extractRuns_String(runs); const channelUrl = extractNavigationEndpoint_Url(runs[0]?.navigationEndpoint); const thumbUrl = null; return new PlatformAuthorLink(new PlatformID(PLATFORM, id, config.id, PLATFORM_CLAIMTYPE), name, channelUrl, thumbUrl ?? ""); } function extractThumbnail_Thumbnails(thumbnail) { return new Thumbnails(thumbnail.thumbnails.map(x=>new Thumbnail(escapeUnicode(x.url), x.height))); } function extractThumbnail_BestUrl(thumbnail) { if(!thumbnail?.thumbnails || thumbnail.thumbnails.length <= 0) return null; let bestUrl = thumbnail.thumbnails[0].url; let bestHeight = thumbnail.thumbnails[0].height; for(let thumb of thumbnail.thumbnails) if(thumb.height > bestHeight) { bestUrl = thumb.url; bestHeight = thumb.height; } return bestUrl; } function extractVideoWithContextRenderer_AuthorLink(videoRenderer) { let id = videoRenderer.channelThumbnail?.channelThumbnailWithLinkRenderer?.navigationEndpoint?.browseEndpoint?.browseId; const name = extractRuns_String(videoRenderer.shortBylineText.runs); const channelThumbs = videoRenderer.channelThumbnail.channelThumbnailWithLinkRenderer?.thumbnail?.thumbnails; const thumbUrl = channelThumbs && channelThumbs.length > 0 ? channelThumbs[0].url : null; let channelUrl = videoRenderer.channelThumbnail?.channelThumbnailWithLinkRenderer?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl; if(channelUrl) channelUrl = URL_BASE + channelUrl; if (id) channelUrl = URL_BASE + "/channel/" + id; return new PlatformAuthorLink(new PlatformID(PLATFORM, id, config.id, PLATFORM_CLAIMTYPE), name, channelUrl, thumbUrl); } function extractVideoRenderer_AuthorLink(videoRenderer) { const id = videoRenderer.channelThumbnailSupportedRenderers.channelThumbnailWithLinkRenderer?.navigationEndpoint?.browseEndpoint?.browseId; const name = extractText_String(videoRenderer.ownerText)//extractRuns_String(videoRenderer.ownerText.runs); const channelIcon = videoRenderer.channelThumbnailSupportedRenderers.channelThumbnailWithLinkRenderer; const thumbUrl = channelIcon.thumbnail.thumbnails[0].url; const channelUrl = (!id) ? extractRuns_Url(videoRenderer.ownerText.runs) : URL_BASE + "/channel/" + id; return new PlatformAuthorLink(new PlatformID(PLATFORM, id, config.id, PLATFORM_CLAIMTYPE), name, channelUrl, thumbUrl); } function extractCommentRenderer_Comment(contextUrl, commentRenderer, replyCount, replyContinuation, useLogin, useMobile) { const authorName = extractText_String(commentRenderer.authorText) ?? ""; const authorEndpoint = commentRenderer.authorEndpoint?.commandMetadata?.webCommandMetadata?.url ?? ""; const authorThumbnail = (commentRenderer.authorThumbnail?.thumbnails ? commentRenderer.authorThumbnail.thumbnails[commentRenderer.authorThumbnail.thumbnails.length - 1].url : "" ); return new YTComment({ contextUrl: contextUrl, author: new PlatformAuthorLink(new PlatformID(PLATFORM, null, config.id, PLATFORM_CLAIMTYPE), authorName, URL_BASE + authorEndpoint, authorThumbnail), message: extractRuns_String(commentRenderer.contentText?.runs) ?? "", rating: new RatingLikes(commentRenderer?.voteCount?.simpleText ? extractHumanNumber_Integer(commentRenderer.voteCount.simpleText) : 0), date: (commentRenderer.publishedTimeText?.runs ? extractAgoTextRuns_Timestamp(commentRenderer.publishedTimeText.runs) : 0), replyCount: replyCount ?? 0, context: { replyContinuation: replyContinuation, useLogin: useLogin + "", useMobile: useMobile + "" } }) } //#endregion function convertIfOtherUrl(url) { url = convertIfShortUrl(url); url = convertIfEmbedUrl(url); url = convertIfMusicUrl(url); return url; } function convertIfMusicUrl(url) { const musicMatch = url.match(REGEX_VIDEO_URL_DESKTOP); if(musicMatch && musicMatch.length == 3 && musicMatch[1]?.toLowerCase() == "music") url = URL_BASE + "/watch?v=" + musicMatch[1]; return url; } function convertIfEmbedUrl(url) { const embedMatch = url.match(REGEX_VIDEO_URL_EMBED); if(embedMatch && embedMatch.length == 3) { let id = embedMatch[2]; if(id.indexOf("?") > 0) id = id.substring(0, id.indexOf("?")); url = URL_BASE + "/watch?v=" + id; } return url; } function convertIfShortUrl(url) { const shortMatch = url.match(REGEX_VIDEO_URL_SHORT); if(shortMatch && shortMatch.length == 3) { let id = shortMatch[2]; if(id.indexOf("?") > 0) id = id.substring(0, id.indexOf("?")); url = URL_BASE + "/watch?v=" + id; } return url; } //#region Basic Extractors function extractText_String(item) { if(typeof item === 'string') return item; if(item?.simpleText) return item.simpleText; if(item?.runs) return extractRuns_String(item.runs); if(item) log("Unknown string object: " + JSON.stringify(item, null, " ")); return null; } function extractRuns_String(runs) { if(!runs) return null; let str = ""; for(let runi = 0; runi < runs.length; runi++) { const run = runs[runi]; if(run.text) str += run.text; else if(run.emoji?.image?.accessibility?.accessibilityData?.label) str += "__" + run.emoji?.image?.accessibility?.accessibilityData?.label + "__" } return str; } function extractRuns_Html(runs) { if(!runs) return null; let str = ""; for(let runi = 0; runi < runs.length; runi++) { const run = runs[runi]; if(run.text) str += run.text; } return str; } function extractRuns_Url(runs) { for(let runi = 0; runi < runs.length; runi++) { const run = runs[runi]; if(run.navigationEndpoint && run.navigationEndpoint.browseEndpoint && run.navigationEndpoint.browseEndpoint.canonicalBaseUrl) return URL_BASE + run.navigationEndpoint.browseEndpoint.canonicalBaseUrl; } } function extractNavigationEndpoint_Url(navEndpoint, baseUrl) { if(!baseUrl) baseUrl = URL_BASE; if(!navEndpoint) return null; if(navEndpoint?.browseEndpoint?.browseId && navEndpoint?.browseEndpoint?.canonicalBaseUrl && navEndpoint.browseEndpoint.canonicalBaseUrl.startsWith("/@")) return baseUrl + "/channel/" + navEndpoint?.browseEndpoint?.browseId; if(navEndpoint?.browseEndpoint?.canonicalBaseUrl) return baseUrl + navEndpoint?.browseEndpoint?.canonicalBaseUrl; if(navEndpoint.commandMetadata?.webCommandMetadata?.url) return baseUrl + navEndpoint.commandMetadata?.webCommandMetadata?.url; return null; } function extractAgoTextRuns_Timestamp(runs) { const runStr = (typeof runs === "string") ? runs : extractRuns_String(runs); return extractAgoText_Timestamp(runStr); } function extractAgoText_Timestamp(str) { if(!str) return 0; const match = str.match(REGEX_HUMAN_AGO); if(!match) return 0; const value = parseInt(match[1]); const now = parseInt(new Date().getTime() / 1000); switch(match[2]) { case "second": case "seconds": return now - value; case "minute": case "minutes": return now - value * 60; case "hour": case "hours": return now - value * 60 * 60; case "day": case "days": return now - value * 60 * 60 * 24; case "week": case "weeks": return now - value * 60 * 60 * 24 * 7; case "month": case "months": return now - value * 60 * 60 * 24 * 30; //For now it will suffice case "year": case "years": return now - value * 60 * 60 * 24 * 365; default: if(bridge.devSubmit) bridge.devSubmit("extractAgoText_Timestamp - Unknown time type: " + match[2], match[2]); throw new ScriptException("Unknown time type: " + match[2]); } } function extractRuns_ViewerCount(runs) { if(runs && runs.length > 0) { const item = runs[0].text.replaceAll(".", "").replaceAll(",", ""); if(isNaN(item)) return -1; return parseInt(item); } return -1; } function extractHumanTime_Seconds(str) { if(!str) return 0; if(str.indexOf(" ") >= 0) str = str.split(" ")[0]; const parts = str.split(":"); let scale = 1; let seconds = 0; for(let i = parts.length-1; i >= 0; i--) { if(isNaN(parts[i])) return seconds; seconds += parseInt(parts[i]) * scale; scale *= 60; } return parseInt(seconds); } function extractFirstNumber_Integer(str) { if(str) { const parts = str.split(' '); if(parts && parts.length > 0) { const num = parts[0].replaceAll(".","").replaceAll(",",""); if(isNaN(num)) return -1; return parseInt(num); } } return -1; } function extractHumanNumber_Integer(str) { if(!str) return -1; const match = str.match(REGEX_HUMAN_NUMBER); if(!match) return extractFirstNumber_Integer(str); const value = parseFloat(match[1]); switch(match[2]) { case "T": return parseInt(1000000000000 * value); case "B": return parseInt(1000000000 * value); case "M": return parseInt(1000000 * value); case "K": return parseInt(1000 * value); default: return parseInt(value); } } function extractDate_Timestamp(dateStr) { if(!dateStr) return -1; if(dateStr.indexOf("ago") > 0) return extractAgoText_Timestamp(dateStr); let matchDate = dateStr.match(REGEX_DATE_HUMAN); if(matchDate) return extractHumanDate_Timestamp(matchDate.slice(1)); matchDate = dateStr.match(REGEX_DATE_EU); if(matchDate) return new Date(matchDate[0]).getTime() / 1000; matchDate = dateStr.match(REGEX_DATE_EU); if(matchDate) return new Date(matchDate[0]).getTime() / 1000; return -1; } function extractHumanDate_Timestamp(dateParts) { if(dateParts.length != 3) return -1; let day = -1; let month = -1; let year = -1; for(let i = 0; i < dateParts.length; i++) { const part = dateParts[i]; if(part.length > 2) { const newMonth = monthNameToNumber(part); if(newMonth > 0) month = newMonth; } if(part.length == 4 && !isNaN(part)) year = parseInt(part); if(part.length <= 2 && !isNaN(part)) day = parseInt(part); } return (day > 0 && month > 0 && year > 0) ? new Date(year + "-" + month + "-" + day).getTime() / 1000 : -1; } function escapeUnicode(str) { if(!str) return str; return str.replace("\\u0026", "&"); } //#endregion //#region Filters const FILTER_DATE_HOUR = 1; const FILTER_DATE_DAY = 2; const FILTER_DATE_WEEK = 3; const FILTER_DATE_MONTH = 4; const FILTER_DATE_YEAR = 5; const FILTER_DURATION_4MIN = 1; const FILTER_DURATION_4_20MIN = 3; const FILTER_DURATION_20MIN = 2; const FILTER_HD = 32; const FILTER_SUBS = 40; const FILTER_LIVE = 64; const FILTER_4K = 112; const FILTER_CreativeCommons = 48; const FILTER_360 = 120 const FILTER_VR = 208; const FILTER_3D = 56; const FILTER_HDR = 200 const FILTERS = [ { id: "date", name: "Upload Date", isMultiSelect: false, filters: [ new FilterCapability("Last Hour", FILTER_DATE_HOUR, Type.Date.LastHour), new FilterCapability("This Day", FILTER_DATE_DAY, Type.Date.Today), new FilterCapability("This Week", FILTER_DATE_WEEK, Type.Date.LastWeek), new FilterCapability("This Month", FILTER_DATE_MONTH, Type.Date.LastMonth), new FilterCapability("This Year", FILTER_DATE_YEAR, Type.Date.LastYear), ] }, { id: "duration", name: "Duration", isMultiSelect: false, filters: [ new FilterCapability("Under 4 minutes", FILTER_DURATION_4MIN, Type.Duration.Short), new FilterCapability("4-20 minutes", FILTER_DURATION_4_20MIN, Type.Duration.Medium), new FilterCapability("Over 20 minutes", FILTER_DURATION_20MIN, Type.Duration.Long) ] }, { id: "features", name: "Features", isMultiSelect: true, filters: [ new FilterCapability("HD", FILTER_HD), new FilterCapability("4K", FILTER_4K), new FilterCapability("HDR", FILTER_HDR), new FilterCapability("Subtitles", FILTER_SUBS), new FilterCapability("Live", FILTER_LIVE), new FilterCapability("Creative Commons", FILTER_CreativeCommons), new FilterCapability("VR", FILTER_VR), new FilterCapability("3D", FILTER_3D), new FilterCapability("360", FILTER_360) ] } ] const SORT_RELEVANCE = 18; const SORT_DATE = 2; const SORT_VIEWS = 3; const SORT_RATING = 1; const TYPE_VIDEO = 1; const TYPE_CHANNEL = 2; const TYPE_PLAYLIST = 3; const TYPE_MOVIES = 4; const PREFIX_TYPE = 16; const PREFIX_LENGTH = 18; const PREFIX_ORDER = 8; const PREFIX_DATE = 8; const PREFIX_DURATION = 24; function sortToByte(sort) { switch(sort) { case Type.Order.Chronological: return SORT_DATE; case SORT_RATING_STRING: return SORT_RATING; case SORT_VIEWS_STRING: return SORT_VIEWS; default: throw new ScriptException("Unknown sort"); } } function searchQueryToSP(sort, type, filters) { if(!type) type = TYPE_VIDEO; let filter_date = (filters?.date && filters.date.length > 0) ? filters.date[0] : null; let filter_duration = (filters?.duration && filters.duration.length > 0) ? filters.duration[0] : null; let filter_features = filters?.features ?? []; const sortByte = sort ? sortToByte(sort) : null;//SORT_RELEVANCE; let arrLength = 0; let filterLength = 0; if(sortByte) arrLength += 2; if(type) { filterLength += 2; arrLength += 2; } if(filter_date) { filterLength += 2; arrLength += 2; } if(filter_duration) { filterLength += 2; arrLength += 2; } if(filter_features.length > 0) { for(let i = 0; i < filter_features.length; i++) { arrLength += 2; filterLength += 2; if(filter_features[i] > 128) { arrLength += 1; filterLength += 1; } } } if(filterLength > 0) arrLength += 2; const array = new Uint8Array(arrLength); let index = 0; if(sortByte) { array[index] = PREFIX_ORDER; array[index + 1] = sortByte; index += 2; } if(filterLength > 0) { array[index] = PREFIX_LENGTH; array[index + 1] = filterLength; index += 2; } if(filter_date) { array[index] = PREFIX_DATE; array[index + 1] = filter_date; index += 2; } if(filter_duration) { array[index] = PREFIX_DURATION; array[index + 1] = filter_duration; index += 2; } if(type) { array[index] = PREFIX_TYPE; array[index + 1] = type; index += 2; } for(let i = 0; i < filter_features.length; i++) { array[index] = filter_features[i]; array[index + 1] = 1; index += 2; if(filter_features[i] > 128) { array[index] = 1; index += 1; } } return utility.toBase64(array); } //#endregion //#region Utility const htmlEncodedCharacters = { "amp": "&", "lt": "<", "gt": ">", "quot": "\"", "apos": "'" } function decodeHtml(text) { return text.replace(/(?:&|&)#([0-9]*);/gm, function(match, dec) { return String.fromCharCode(dec); }).replace(/&([a-z]*);(#.*?;)?/gm, function(match, c){ if(htmlEncodedCharacters[c]) return htmlEncodedCharacters[c]; return c; }); } function monthNameToNumber(month) { if(!month) return -1; month = month.toLowerCase(); //Either partial or full month name if(month.startsWith("jan")) return 1; if(month.startsWith("feb")) return 2; if(month.startsWith("mar")) return 3; if(month.startsWith("apr")) return 4; if(month.startsWith("may")) return 5; if(month.startsWith("jun")) return 6; if(month.startsWith("jul")) return 7; if(month.startsWith("aug")) return 8; if(month.startsWith("sep")) return 9; if(month.startsWith("oct")) return 10; if(month.startsWith("nov")) return 11; if(month.startsWith("dec")) return 12; return -1; } const ytLangMap = { "ar": Language.ARABIC, "es": Language.SPANISH, "fr": Language.FRENCH, "hi": Language.HINDI, "id": Language.INDONESIAN, "ko": Language.KOREAN, "pt-BR": Language.PORTBRAZIL, "ru": Language.RUSSIAN, "th": Language.THAI, "tr": Language.TURKISH, "vi": Language.VIETNAMESE, "en": Language.ENGLISH, "en-US": Language.ENGLISH }; function ytLangIdToLanguage(id) { if(!id) return Language.UNKNOWN; const langParts = id?.split("."); let langPart = (langParts && langParts.length > 0) ? langParts[0] : ""; if(ytLangMap[langPart]) return ytLangMap[langPart]; //Backwards compat if(langPart.indexOf("-") > 0) langPart = langPart.split("-")[0].trim(); if(ytLangMap[langPart]) return ytLangMap[langPart]; //Backwards compat if(langPart && langPart.length > 0) return langPart.trim(); return Language.UNKNOWN; } function findRenderer(obj, rendererName) { if(!obj) return null; const keys = Object.keys(obj); if(!keys || keys.length == 0) return null; const objName = keys[0]; const renderer = obj[objName]; if(objName == rendererName) return renderer; if(renderer.contents) { for(let content of renderer.contents) { const result = findRenderer(content, rendererName); if(result) return result; } } if(renderer.content) return findRenderer(renderer.content, rendererName); return null; } function switchKey(obj, handlers) { const objName = Object.keys(obj)[0]; if(!objName) { if(handlers["null"]) return handlers["null"]; return null; } if(handlers[objName]) return handlers[objName](obj[objName]); if(handlers["default"]) return handlers["default"](objName); return null; } //#endregion function validateContinuation(reqcb, useAuth = false) { const clientContext = getClientContext(useAuth); const result = reqcb(); const append = result?.onResponseReceivedCommands ?? result?.onResponseReceivedActions; if(append && append.length > 0 && append[0].appendContinuationItemsAction) { const appendResults = append[0].appendContinuationItemsAction.continuationItems; if(!appendResults) { if(IS_TESTING) console.log("Continuation found without items?", result); return []; } else return appendResults; } else if(!clientContext.INNERTUBE_CONTEXT.client.visitorData && result.responseContext?.visitorData) { log("[validateContinuation] No visitor data set, found visitor data in response, retrying"); clientContext.INNERTUBE_CONTEXT.client.visitorData = result.responseContext.visitorData; //Retry with visitorData const reResult = reqcb(); log("[validateContinuation] retry result"); if(append && append.length > 0 && append[0].appendContinuationItemsAction) { const appendResults = append[0].appendContinuationItemsAction.continuationItems; if(!appendResults) { if(IS_TESTING) console.log("Continuation found without items?", result); return []; } else return appendResults; } else return []; } else return []; } //#region Cipher/Decryption var _cipherDecode = { }; var _nDecrypt = { }; var _sts = { }; const REGEX_CIPHERS = [ new RegExp("(?:\\b|[^a-zA-Z0-9$])([a-zA-Z0-9$]{2,})\\s*=\\s*function\\(\\s*a\\s*\\)\\s*\\{\\s*a\\s*=\\s*a\\.split\\(\\s*\"\"\\s*\\)"), new RegExp("\\bm=([a-zA-Z0-9$]{2,})\\(decodeURIComponent\\(h\\.s\\)\\)"), new RegExp("\\bc&&\\(c=([a-zA-Z0-9$]{2,})\\(decodeURIComponent\\(c\\)\\)"), new RegExp("([\\w$]+)\\s*=\\s*function\\((\\w+)\\)\\{\\s*\\2=\\s*\\2\\.split\\(\"\"\\)\\s*;"), new RegExp("\\b([\\w$]{2,})\\s*=\\s*function\\((\\w+)\\)\\{\\s*\\2=\\s*\\2\\.split\\(\"\"\\)\\s*;"), new RegExp("\\bc\\s*&&\\s*d\\.set\\([^,]+\\s*,\\s*(:encodeURIComponent\\s*\\()([a-zA-Z0-9$]+)\\(") ]; const REGEX_DECRYPT_N_VARIANTS = [ /\.get\(\"n\"\)\)&&\([a-zA-Z0-9$_]=([a-zA-Z0-9$_]+)(?:\[(\d+)])?\([a-zA-Z0-9$_]\)/, /[a-zA-Z0-9$_]+=String\.fromCharCode\(110\),[a-zA-Z0-9$_]+=[a-zA-Z0-9$_]+\.get\([a-zA-Z0-9$_]+\)\)&&\([a-zA-Z0-9$_]=([a-zA-Z0-9$_]+)(?:\[(\d+)])?\([a-zA-Z0-9$_]\)/, /[a-zA-Z]+="[n]+"\[.+\],[a-zA-Z0-9$_]+=[a-zA-Z0-9$_]+\.get\([a-zA-Z0-9$_]+\)\)&&\([a-zA-Z0-9$_]=([a-zA-Z0-9$_]+)(?:\[(\d+)])?\([a-zA-Z0-9$_]\)/, /\/file\/index\.m3u8.+?[a-zA-Z0-9$_]=([a-zA-Z0-9$_]+)(?:\[(\d+)])?\([a-zA-Z0-9$_]\)/ ]; const REGEX_PARAM_N = new RegExp("[?&]n=([^&]*)"); const STS_REGEX = new RegExp("signatureTimestamp[=:](\\d+)"); source.decryptUrlTest = function(encrypted) { prepareCipher(); let url = decryptUrlN(encrypted.url, true); if(!url) url = decryptUrl(encrypted.cipher, true); if(!url) url = decryptUrl(encrypted.signatureCipher, true); return url; } source.decryptUrlTestN = function(n) { prepareCipher(); let url = "https://whatever.com/asdgdsag?a=b&n=" + n + "&u=asd" return decryptUrlN(url, true); } function decryptUrl(encrypted, jsUrl, doLogging) { if(!encrypted) return null; const query = parseQueryString(encrypted); const baseUrl = query.url; const sigKey = query.sp; const sigValue = decodeCipher(decodeURIComponent(query.s), jsUrl); let decryptedUrl = decodeURIComponent(baseUrl) + "&" + sigKey + "=" + sigValue; if(doLogging) { log("SigKey: " + sigKey); log("SigValue: " + sigValue); log("Decrypted: " + decryptedUrl); } return decryptUrlN(decryptedUrl, jsUrl, doLogging); } function decryptUrlN(url, jsUrl, doLogging) { const nParamMatch = REGEX_PARAM_N.exec(url); if(nParamMatch) { const encryptedN = nParamMatch[1]; const decryptedN = decryptN(encryptedN, jsUrl); if(doLogging) { log("Encrypt URL:" + url); log("NParam Found: " + encryptedN + " (length:" + encryptedN.length + ")"); log("NParam Decrypted: " + decryptedN + " (size:" + decryptedN.length + ")"); log("Decrypted URL:" + url.replace(encryptedN, decryptedN)); } url = url.replace(encryptedN, decryptedN); } else if(doLogging) log("No NParam found in (" + url + ")"); return url; } function decodeCipher(cipher, jsUrl) { if(!_cipherDecode[jsUrl]) throw new ScriptException("Cipher decoder was not available [" + jsUrl + "]"); return _cipherDecode[jsUrl](cipher); } function decryptN(encryptedN, jsUrl) { if(!_nDecrypt[jsUrl]) throw new ScriptException("N Decryptor was not available [" + jsUrl + "]"); return _nDecrypt[jsUrl](encryptedN); } function testCipher(hash) { const jsUrl = CIPHER_TEST_PREFIX + hash + CIPHER_TEST_SUFFIX; try{ const result = prepareCipher(jsUrl); clearCipher(jsUrl); return { success: result, exception: "" }; } catch(ex) { return { success: false, exception: ex }; } } source.testCipher = testCipher; function testCiphers() { let testResults = []; for(hash of CIPHER_TEST_HASHES) { const jsUrl = CIPHER_TEST_PREFIX + hash + CIPHER_TEST_SUFFIX; try{ if(prepareCipher(jsUrl)) testResults.push("CipherTest [" + hash + "]: PASSED"); else testResults.push("CipherTest [" + hash + "]: FAIL"); } catch(ex) { testResults.push(["CipherTest [" + hash + "]: FAIL", ex]); } clearCipher(jsUrl); } for(result of testResults) { if(result.constructor === Array) console.log(result[0], result[1]); else console.log(result); } } source.testCiphers = testCiphers; function prepareCipher(jsUrl) { if(_cipherDecode[jsUrl]) return false;//_cipherDecode[jsUrl]; log("New JS Url found: [" + jsUrl + "], fetching new js (total: " + (Object.keys(_cipherDecode).length + 1) + ")"); try{ const playerCodeResp = http.GET(URL_BASE + jsUrl, {}); if(!playerCodeResp.isOk) { if(bridge.devSubmit) bridge.devSubmit("prepareCipher - Failed to get player js", jsUrl); throw new ScriptException("Failed to get player js"); } console.log("Javascript Url: " + URL_BASE + jsUrl); const playerCode = playerCodeResp.body; const cipherFunctionCode = getCipherFunctionCode(playerCode, jsUrl); console.log("DecodeCipher Function: " + cipherFunctionCode); _cipherDecode[jsUrl] = eval(cipherFunctionCode); const decryptFunctionCode = getNDecryptorFunctionCode(playerCode, jsUrl); console.log("DecryptN Function: " + decryptFunctionCode); _nDecrypt[jsUrl] = eval(decryptFunctionCode); const stsMatch = playerCode.match(STS_REGEX); console.log("stsMatch: " + stsMatch); if (stsMatch !== null && stsMatch.length > 1) { const sts = stsMatch[1]; _sts[jsUrl] = sts; console.log("sts: " + sts); } return true;//_cipherDecode[jsUrl]; } catch(ex) { clearCipher(jsUrl); if(bridge.devSubmit) bridge.devSubmit("prepareCipher - Failed to get Cipher due to: " + ex, jsUrl); throw new ScriptException("Failed to get Cipher due to: " + ex); } } source.prepareCipher = prepareCipher; function clearCipher(jsUrl) { if(_cipherDecode[jsUrl]) _cipherDecode[jsUrl] = undefined; if(_nDecrypt[jsUrl]) _nDecrypt[jsUrl] = undefined; } function getNDecryptorFunctionCode(code, jsUrl) { if(_nDecrypt[jsUrl]) return _nDecrypt[jsUrl]; let nDecryptFunctionArrNameMatch = undefined; for(let i = 0; i < REGEX_DECRYPT_N_VARIANTS.length; i++) { nDecryptFunctionArrNameMatch = REGEX_DECRYPT_N_VARIANTS[i].exec(code); if(!nDecryptFunctionArrNameMatch) { console.log("NDecryptor failed, trying fallback to [" + i + 2 + "]"); } else break; } if(!nDecryptFunctionArrNameMatch) { if(bridge.devSubmit) bridge.devSubmit("getNDecryptorFunctionCode - Failed to find n decryptor (name)", jsUrl); throw new ScriptException("Failed to find n decryptor (name)\n" + jsUrl); } const nDecryptFunctionArrName = nDecryptFunctionArrNameMatch[1]; const nDecryptFunctionArrIndex = parseInt(nDecryptFunctionArrNameMatch[2]); const nDecryptFunctionNameMatch = code.match(escapeRegex(nDecryptFunctionArrName) + "\\s*=\\s*\\[([$a-zA-Z0-9,\\(,\\)\\.]+?)]"); if(!nDecryptFunctionNameMatch) { if(bridge.devSubmit) bridge.devSubmit("getNDecryptorFunctionCode - Failed to find n decryptor (array)", jsUrl); throw new ScriptException("Failed to find n decryptor (array)\n" + jsUrl); } const nDecryptArray = nDecryptFunctionNameMatch[1].split(","); if(nDecryptArray.length <= nDecryptFunctionArrIndex) { if(bridge.devSubmit) bridge.devSubmit("getNDecryptorFunctionCode - Failed to find n decryptor (index)", jsUrl); throw new ScriptException("Failed to find n decryptor (index)\n" + jsUrl); } const nDecryptFunctionName = nDecryptArray[nDecryptFunctionArrIndex] const nDecryptFunctionCodeMatches = [ escapeRegex(nDecryptFunctionName) + "=function\\(a\\)\\{[\\s\\S]*?join\\(\\\"\\\"\\)};", escapeRegex(nDecryptFunctionName) + "=function\\(a\\)\\{[\\s\\S]*?join\\.call\\([a-zA-Z$_]+,\\\"\\\"\\)};", new RegExp(escapeRegex(nDecryptFunctionName) + "=function\\(a\\)\\{[\\s\\S]*?join\\.call\\(.*?\\).*?};", "s") ] let nDecryptFunctionCodeMatch = undefined; for(let functionRegex of nDecryptFunctionCodeMatches) { const match = code.match(functionRegex); if(match && match.length > 0 && (!nDecryptFunctionCodeMatch || nDecryptFunctionCodeMatch.length > match[0].length)) nDecryptFunctionCodeMatch = match[0]; } if(!nDecryptFunctionCodeMatch) { if(bridge.devSubmit) bridge.devSubmit("getNDecryptorFunctionCode - Failed to find n decryptor (code)", jsUrl, code); throw new ScriptException("Failed to find n decryptor (code)\n" + jsUrl); } return "(function(){" + "var " + nDecryptFunctionCodeMatch + "\n" + "return function decryptN(nEncrypted){ return " + nDecryptFunctionName + "(nEncrypted); } \n" + "})()"; } function getCipherFunctionCode(playerCode, jsUrl) { if(_cipherDecode[jsUrl]) return _cipherDecode[jsUrl]; let cipherFunctionName = null; for(let i = 0; i < REGEX_CIPHERS.length; i++) { const match = playerCode.match(REGEX_CIPHERS[i]); if(match) { cipherFunctionName = match[1]; break; } } if(!cipherFunctionName) { if(bridge.devSubmit) bridge.devSubmit("getCipherFunctionCode - Failed to find cipher (name)", jsUrl); throw new ScriptException("Failed to find cipher (name)\n" + jsUrl); } const cipherFunctionCodeMatch = playerCode.match("(" + escapeRegex(cipherFunctionName) + "=function\\([a-zA-Z0-9_]+\\)\\{.+?\\})"); if(!cipherFunctionCodeMatch) { if(IS_TESTING) console.log("Failed to find cipher function in: ", playerCode); if(bridge.devSubmit) bridge.devSubmit("getCipherFunctionCode - Failed to find cipher (function)", jsUrl); throw new ScriptException("Failed to find cipher (function)\n" + jsUrl); } const cipherFunctionCode = cipherFunctionCodeMatch[1]; const cipherFunctionCodeVar = "var " + cipherFunctionCode; const helperObjNameMatch = cipherFunctionCode.match(";([A-Za-z0-9_\\$]{2,3})\\...\\("); if(!helperObjNameMatch) { if(IS_TESTING) console.log("Failed to find helper name in: ", playerCode); if(bridge.devSubmit) bridge.devSubmit("getCipherFunctionCode - Failed to find helper (name)", jsUrl); throw new ScriptException("Failed to find helper (name)\n" + jsUrl); } if(IS_TESTING) console.log("Cipher Code: ", cipherFunctionCode); const helperObjName = helperObjNameMatch[1]; const helperObjMatch = playerCode.match("(var " + escapeRegex(helperObjName) + "=\\{[\\s\\S]*?\\};)"); if(!helperObjMatch) { if(IS_TESTING) console.log("Failed to find helper method [" + helperObjName + "] in: ", playerCode); if(bridge.devSubmit) bridge.devSubmit("getCipherFunctionCode - Failed to find helper (methods)", jsUrl); throw new ScriptException("Failed to extract helper (methods)\n" + jsUrl); } const helperObj = helperObjMatch[1]; const functionCode = "return function decodeCipher(str){ return " + cipherFunctionName + "(str); }"; return "(function(){" + helperObj + "\n" + cipherFunctionCodeVar + "\n" + functionCode + "})()"; } function escapeRegex(str) { return str?.replace("$", "\\$"); } function decodeHexEncodedString(str) { return str.replace(/\\x([0-9A-Fa-f]{2})/g, function() { return String.fromCharCode(parseInt(arguments[1], 16)); }); } function parseQueryString(query) { if(query.indexOf("?") >= 0) query = query.substring(query.indexOf("?") + 1); const parts = query.split("&"); const results = {}; for(let i = 0; i < parts.length; i++) { const part = parts[i]; const valueIndex = part.indexOf("="); if(valueIndex == -1) results[part] = true; else results[part.substring(0, valueIndex)] = part.substring(valueIndex + 1); } return results; } //#endregion //#region Others const RANDOM_CHARACTER_SET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; function randomString(length) { let str = ""; for(let i = 0; i < length; i++) str += RANDOM_CHARACTER_SET[Math.floor(Math.random() * RANDOM_CHARACTER_SET.length)] return str; } function randomInt(start, end) { return Math.floor(random() * (end + start) - end); } //#endregion console.log("LOADED");