Play the hls manifest from the ios player response. Based on https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass
// ==UserScript== // @name Youtube HLS Enabler // @namespace https://github.com/pepeloni-away // @author pploni // @run-at document-start // @insert-into page // @version 1.81 // @description Play the hls manifest from the ios player response. Based on https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_setClipboard // @require https://cdn.jsdelivr.net/npm/hls.js@1 // @match https://www.youtube.com/* // ==/UserScript== /* user options */ // show a toast notification when successfully obtaining the hls manifest const notifyOnSuccess = false // only fetch the hls manifest when premium 1080p is available // NOTE: youtube doesn't show the premium 1080p option in embeds or when the user is not logged in const onlyOnPremiumAvailable = true // automatically switch to the hls manifest when it is added to the player const onByDefault = false // show a toasat notification when 616 is in the hls manifest const notify616 = false // switch to the hls manifest when it contains 616 const onBy616 = true const disableLogging = false // what is 616? what do the changing numbers on the toggle button mean? // they are youtube specific format ids, ctrl-f them on https://gist.github.com/MartinEesmaa/2f4b261cb90a47e9c41ba115a011a4aa /* end user options */ const console = { log: disableLogging ? function () {} : unsafeWindow.console.log } if (unsafeWindow.trustedTypes && unsafeWindow.trustedTypes.createPolicy) { if (!unsafeWindow.trustedTypes.defaultPolicy) { const fn = _ => _ trustedTypes.createPolicy('default', { createHTML: fn, createScriptURL: fn, createScript: fn, }) } else { console.log('there already is a default trustedtypes policy, should probably intercept it') } } const VALID_PLAYABILITY_STATUSES = ['OK', 'LIVE_STREAM_OFFLINE']; const GOOGLE_AUTH_HEADER_NAMES = [ // 'Authorization', // 'X-Goog-AuthUser', // 'X-Origin', 'X-Goog-Visitor-Id', ]; var proxy = { getPlayer, getNext, getGoogleVideoUrl, }; let nextResponseCache = {}; function getGoogleVideoUrl(originalUrl) { return Config.VIDEO_PROXY_SERVER_HOST + '/direct/' + btoa(originalUrl.toString()); } function getPlayer(payload) { // Also request the /next response if a later /next request is likely. if (!nextResponseCache[payload.videoId] && !isMusic && !isEmbed) { payload.includeNext = 1; } return sendRequest('getPlayer', payload); } function getNext(payload) { // Next response already cached? => Return cached content if (nextResponseCache[payload.videoId]) { return nextResponseCache[payload.videoId]; } return sendRequest('getNext', payload); } function sendRequest(endpoint, payload) { const queryParams = new URLSearchParams(payload); const proxyUrl = `${Config.ACCOUNT_PROXY_SERVER_HOST}/${endpoint}?${queryParams}&client=js`; try { const xmlhttp = new XMLHttpRequest(); xmlhttp.open('GET', proxyUrl, false); xmlhttp.send(null); const proxyResponse = nativeJSONParse(xmlhttp.responseText); // Mark request as 'proxied' proxyResponse.proxied = true; // Put included /next response in the cache if (proxyResponse.nextResponse) { nextResponseCache[payload.videoId] = proxyResponse.nextResponse; delete proxyResponse.nextResponse; } return proxyResponse; } catch (err) { console.log(err, 'Proxy API Error'); return { errorMessage: 'Proxy Connection failed' }; } } var Config = window[Symbol()] = { // UNLOCKABLE_PLAYABILITY_STATUSES, VALID_PLAYABILITY_STATUSES, // ACCOUNT_PROXY_SERVER_HOST, // VIDEO_PROXY_SERVER_HOST, // ENABLE_UNLOCK_CONFIRMATION_EMBED, // ENABLE_UNLOCK_NOTIFICATION, // SKIP_CONTENT_WARNINGS, GOOGLE_AUTH_HEADER_NAMES, // BLURRED_THUMBNAIL_SQP_LENGTHS, }; var innertube = { getPlayer: getPlayer$1, getNext: getNext$1, }; function getPlayer$1(payload, useAuth) { return sendInnertubeRequest('v1/player', payload, useAuth); } function getNext$1(payload, useAuth) { return sendInnertubeRequest('v1/next', payload, useAuth); } function sendInnertubeRequest(endpoint, payload, useAuth) { const xmlhttp = new XMLHttpRequest(); xmlhttp.open('POST', `/youtubei/${endpoint}?key=${getYtcfgValue('INNERTUBE_API_KEY')}&prettyPrint=false`, false); if (useAuth /*&& isUserLoggedIn()*/) { xmlhttp.withCredentials = true; Config.GOOGLE_AUTH_HEADER_NAMES.forEach((headerName) => { xmlhttp.setRequestHeader(headerName, get(headerName)); }); } xmlhttp.send(JSON.stringify(payload)); return nativeJSONParse(xmlhttp.responseText); } const localStoragePrefix = '1080pp_'; function set(key, value) { localStorage.setItem(localStoragePrefix + key, JSON.stringify(value)); } function get(key) { try { return JSON.parse(localStorage.getItem(localStoragePrefix + key)); } catch { return null; } } function getSignatureTimestamp() { return ( getYtcfgValue('STS') || (() => { var _document$querySelect; // STS is missing on embedded player. Retrieve from player base script as fallback... const playerBaseJsPath = (_document$querySelect = document.querySelector('script[src*="/base.js"]')) === null || _document$querySelect === void 0 ? void 0 : _document$querySelect.src; if (!playerBaseJsPath) return; const xmlhttp = new XMLHttpRequest(); xmlhttp.open('GET', playerBaseJsPath, false); xmlhttp.send(null); return parseInt(xmlhttp.responseText.match(/signatureTimestamp:([0-9]*)/)[1]); })() ); } function getCurrentVideoStartTime(currentVideoId) { // Check if the URL corresponds to the requested video // This is not the case when the player gets preloaded for the next video in a playlist. if (window.location.href.includes(currentVideoId)) { var _ref; // "t"-param on youtu.be urls // "start"-param on embed player // "time_continue" when clicking "watch on youtube" on embedded player const urlParams = new URLSearchParams(window.location.search); const startTimeString = (_ref = urlParams.get('t') || urlParams.get('start') || urlParams.get('time_continue')) === null || _ref === void 0 ? void 0 : _ref.replace('s', ''); if (startTimeString && !isNaN(startTimeString)) { return parseInt(startTimeString); } } return 0; } function getUnlockStrategies(videoId, reason) { const clientName = getYtcfgValue('INNERTUBE_CLIENT_NAME') || 'WEB'; const clientVersion = getYtcfgValue('INNERTUBE_CLIENT_VERSION') || '2.20220203.04.00'; const signatureTimestamp = getSignatureTimestamp(); const startTimeSecs = getCurrentVideoStartTime(videoId); const hl = getYtcfgValue('HL'); return [ { name: 'ios', requiresAuth: true, payload: { context: { client: { clientName: 'IOS', clientVersion: '19.09.3', deviceModel: 'iPhone14,3', // check https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L176 for client name/ver updates // userAgent: 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iOS 15_6 like Mac OS X)', hl, }, }, playbackContext: { contentPlaybackContext: { signatureTimestamp, }, }, videoId, startTimeSecs, racyCheckOk: true, contentCheckOk: true, }, endpoint: innertube, }, ] } let cachedPlayerResponse = {}; function createDeepCopy(obj) { return nativeJSONParse(JSON.stringify(obj)); } function isUserLoggedIn() { // LOGGED_IN doesn't exist on embedded page, use DELEGATED_SESSION_ID or SESSION_INDEX as fallback if (typeof getYtcfgValue('LOGGED_IN') === 'boolean') return getYtcfgValue('LOGGED_IN'); if (typeof getYtcfgValue('DELEGATED_SESSION_ID') === 'string') return true; if (parseInt(getYtcfgValue('SESSION_INDEX')) >= 0) return true; return false; } function getUnlockedPlayerResponse(videoId, reason, copy) { // Check if response is cached // if (cachedPlayerResponse.videoId === videoId) return createDeepCopy(cachedPlayerResponse); if (cachedPlayerResponse.videoId === videoId && !copy) { try { // check if hls manifest expired on the cached response // for the edge case of pausing a video at night and continuing it next morning const expireDate = cachedPlayerResponse.streamingData.hlsManifestUrl.match(/(?<=expire\/)\d+/)[0] const initialSecondsLeft = cachedPlayerResponse.streamingData.expiresInSeconds // 21540, almost 6h. This is a minute before reaching expire date const offset = 100 const secondsNow = Math.floor(Date.now() / 1000) const age = expireDate - secondsNow - offset if (initialSecondsLeft - (initialSecondsLeft - age) < 0) { console.log('cached player response expired, refetching ...') } else { console.log( 'using cached response', // cachedPlayerResponse, ) return createDeepCopy(cachedPlayerResponse); } } catch(err) { console.log('failed to check cached response age, page reload might be necessary', err) return createDeepCopy(cachedPlayerResponse); } } const unlockStrategies = getUnlockStrategies(videoId, reason); let unlockedPlayerResponse = {}; // Try every strategy until one of them works unlockStrategies.every((strategy, index) => { var _unlockedPlayerRespon6; // Skip strategy if authentication is required and the user is not logged in // if (strategy.skip || strategy.requiresAuth && !isUserLoggedIn()) return true; console.log(`Trying Player Unlock Method #${index + 1} (${strategy.name})`); try { unlockedPlayerResponse = strategy.endpoint.getPlayer(strategy.payload, strategy.requiresAuth || strategy.optionalAuth); } catch (err) { console.log(err, `Player Unlock Method ${index + 1} failed with exception`); } const isStatusValid = Config.VALID_PLAYABILITY_STATUSES.includes( (_unlockedPlayerRespon6 = unlockedPlayerResponse) === null || _unlockedPlayerRespon6 === void 0 || (_unlockedPlayerRespon6 = _unlockedPlayerRespon6.playabilityStatus) === null || _unlockedPlayerRespon6 === void 0 ? void 0 : _unlockedPlayerRespon6.status, ); if (isStatusValid) { var _unlockedPlayerRespon7; /** * Workaround: https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues/191 * * YouTube checks if the `trackingParams` in the response matches the decoded `trackingParam` in `responseContext.mainAppWebResponseContext`. * However, sometimes the response does not include the `trackingParam` in the `responseContext`, causing the check to fail. * * This workaround addresses the issue by hardcoding the `trackingParams` in the response context. */ if ( !unlockedPlayerResponse.trackingParams || !((_unlockedPlayerRespon7 = unlockedPlayerResponse.responseContext) !== null && _unlockedPlayerRespon7 !== void 0 && (_unlockedPlayerRespon7 = _unlockedPlayerRespon7.mainAppWebResponseContext) !== null && _unlockedPlayerRespon7 !== void 0 && _unlockedPlayerRespon7.trackingParam) ) { unlockedPlayerResponse.trackingParams = 'CAAQu2kiEwjor8uHyOL_AhWOvd4KHavXCKw='; unlockedPlayerResponse.responseContext = { mainAppWebResponseContext: { trackingParam: 'kx_fmPxhoPZRzgL8kzOwANUdQh8ZwHTREkw2UqmBAwpBYrzRgkuMsNLBwOcCE59TDtslLKPQ-SS', }, }; } /** * Workaround: Account proxy response currently does not include `playerConfig` * * Stays here until we rewrite the account proxy to only include the necessary and bare minimum response */ if (strategy.payload.startTimeSecs && strategy.name === 'Account Proxy') { unlockedPlayerResponse.playerConfig = { playbackStartConfig: { startSeconds: strategy.payload.startTimeSecs, }, }; } } return !isStatusValid; }); // Cache response to prevent a flood of requests in case youtube processes a blocked response mutiple times. if (!copy) { cachedPlayerResponse = { videoId, ...createDeepCopy(unlockedPlayerResponse) }; } return unlockedPlayerResponse; } let lastPlayerUnlockVideoId = null; let lastPlayerUnlockReason = null; function waitForElement(elementSelector, timeout) { const deferred = new Deferred(); const checkDomInterval = setInterval(() => { const elem = document.querySelector(elementSelector); if (elem) { clearInterval(checkDomInterval); deferred.resolve(elem); } }, 100); if (timeout) { setTimeout(() => { clearInterval(checkDomInterval); deferred.reject(); }, timeout); } return deferred; } // const nativeJSONParse = window.JSON.parse; // const nativeXMLHttpRequestOpen = window.XMLHttpRequest.prototype.open; const nativeJSONParse = unsafeWindow.JSON.parse; const nativeXMLHttpRequestOpen = unsafeWindow.XMLHttpRequest.prototype.open; const isDesktop = window.location.host !== 'm.youtube.com'; const isMusic = window.location.host === 'music.youtube.com'; const isEmbed = window.location.pathname.indexOf('/embed/') === 0; function createElement(tagName, options) { const node = document.createElement(tagName); options && Object.assign(node, options); return node; } class Deferred { constructor() { return Object.assign( new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }), this, ); } } function pageLoaded() { if (document.readyState === 'complete') return Promise.resolve(); const deferred = new Deferred(); unsafeWindow.addEventListener('load', deferred.resolve, { once: true }); return deferred; } var tDesktop = '<tp-yt-paper-toast></tp-yt-paper-toast>\n'; var tMobile = '<c3-toast>\n <ytm-notification-action-renderer>\n <div class="notification-action-response-text"></div>\n </ytm-notification-action-renderer>\n</c3-toast>\n'; const template = isDesktop ? tDesktop : tMobile; const nToastContainer = createElement('div', { id: 'toast-container', innerHTML: template }); const nToast = nToastContainer.querySelector(':scope > *'); async function show(message, duration = 5) { // if (!Config.ENABLE_UNLOCK_NOTIFICATION) return; if (isEmbed) return; await pageLoaded(); // Do not show notification when tab is in background if (document.visibilityState === 'hidden') return; // Append toast container to DOM, if not already done if (!nToastContainer.isConnected) document.documentElement.append(nToastContainer); nToast.duration = duration * 1000; nToast.show(message); } var Toast = { show }; const messagesMap = { success: 'hls manifest available', fail: 'Failed to fetch hls manifest', _616: '616 available', }; function isPlayerObject(parsedData) { return (parsedData === null || parsedData === void 0 ? void 0 : parsedData.videoDetails) && (parsedData === null || parsedData === void 0 ? void 0 : parsedData.playabilityStatus); } function isPremium1080pAvailable(parsedData) { return parsedData?.paygatedQualitiesMetadata?.qualityDetails?.reduce((found, current) => { if (current.key === '1080p Premium') { return current } return found }, undefined) } function getYtcfgValue(name) { var _window$ytcfg; return (_window$ytcfg = unsafeWindow.ytcfg) === null || _window$ytcfg === void 0 ? void 0 : _window$ytcfg.get(name); } function unlockResponse$1(playerResponse) { var _playerResponse$video, _playerResponse$playa, _playerResponse$previ, _unlockedPlayerRespon, _unlockedPlayerRespon3; const videoId = ((_playerResponse$video = playerResponse.videoDetails) === null || _playerResponse$video === void 0 ? void 0 : _playerResponse$video.videoId) || getYtcfgValue('PLAYER_VARS').video_id; const reason = ((_playerResponse$playa = playerResponse.playabilityStatus) === null || _playerResponse$playa === void 0 ? void 0 : _playerResponse$playa.status) || ((_playerResponse$previ = playerResponse.previewPlayabilityStatus) === null || _playerResponse$previ === void 0 ? void 0 : _playerResponse$previ.status); // if (!Config.SKIP_CONTENT_WARNINGS && reason.includes('CHECK_REQUIRED')) { // console.log(`SKIP_CONTENT_WARNINGS disabled and ${reason} status detected.`); // return; // } lastPlayerUnlockVideoId = videoId; lastPlayerUnlockReason = reason; const unlockedPlayerResponse = getUnlockedPlayerResponse(videoId, reason); // console.log('ios response', unlockedPlayerResponse) // // account proxy error? // if (unlockedPlayerResponse.errorMessage) { // Toast.show(`${messagesMap.fail} (ProxyError)`, 10); // throw new Error(`Player Unlock Failed, Proxy Error Message: ${unlockedPlayerResponse.errorMessage}`); // } // check if the unlocked response isn't playable if ( !Config.VALID_PLAYABILITY_STATUSES.includes( (_unlockedPlayerRespon = unlockedPlayerResponse.playabilityStatus) === null || _unlockedPlayerRespon === void 0 ? void 0 : _unlockedPlayerRespon.status, ) ) { var _unlockedPlayerRespon2; Toast.show(`${messagesMap.fail} (PlayabilityError)`, 10); throw new Error( `Player Unlock Failed, playabilityStatus: ${ (_unlockedPlayerRespon2 = unlockedPlayerResponse.playabilityStatus) === null || _unlockedPlayerRespon2 === void 0 ? void 0 : _unlockedPlayerRespon2.status }`, ); } if (!unlockedPlayerResponse.streamingData.hlsManifestUrl) { Toast.show(`${messagesMap.fail} (undefined)`, 10) throw new Error('response is playable but doesn\'t contain hls manifest (???)', unlockedPlayerResponse) } // Overwrite the embedded (preview) playabilityStatus with the unlocked one if (playerResponse.previewPlayabilityStatus) { playerResponse.previewPlayabilityStatus = unlockedPlayerResponse.playabilityStatus; } // Transfer all unlocked properties to the original player response // Object.assign(playerResponse, unlockedPlayerResponse); playerResponse.streamingData.__hlsManifestUrl = unlockedPlayerResponse.streamingData.hlsManifestUrl // is there a player library that can play dash, hls and mix and match by selecting video and audio streams? like playing 616+251 // playerResponse.streamingData.__adaptiveFormats = unlockedPlayerResponse.streamingData.adaptiveFormats // playerResponse.playabilityStatus.paygatedQualitiesMetadata.qualityDetails[0].value = {} // this closes the popup after click and selects normal 1080p // playerResponse.playabilityStatus.paygatedQualitiesMetadata.qualityDetails[0].value.paygatedIndicatorText = 'HLS Manifest' // playerResponse.playabilityStatus.paygatedQualitiesMetadata.qualityDetails[0].value.endpoint = {} // remove popup on click, do nothing // playerResponse.playabilityStatus.paygatedQualitiesMetadata.restrictedAdaptiveFormats = [] // this removed the option alltogether // playerResponse.unlocked = true; console.log('set hls manifest') if (notifyOnSuccess) { Toast.show(messagesMap.success, 2); } } /** * Handles XMLHttpRequests and * - Rewrite Googlevideo URLs to Proxy URLs (if necessary) * - Store auth headers for the authentication of further unlock requests. * - Add "content check ok" flags to request bodys */ function handleXhrOpen(method, url, xhr) { const url_obj = new URL(url); // let proxyUrl = unlockGoogleVideo(url_obj); // if (proxyUrl) { // // Exclude credentials from XMLHttpRequest // Object.defineProperty(xhr, 'withCredentials', { // set: () => {}, // get: () => false, // }); // return proxyUrl.toString(); // } if (url_obj.pathname.indexOf('/youtubei/') === 0) { // Store auth headers in storage for further usage. attach$4(xhr, 'setRequestHeader', ([headerName, headerValue]) => { if (Config.GOOGLE_AUTH_HEADER_NAMES.includes(headerName)) { set(headerName, headerValue); } }); } // if (Config.SKIP_CONTENT_WARNINGS && method === 'POST' && ['/youtubei/v1/player', '/youtubei/v1/next'].includes(url_obj.pathname)) { // // Add content check flags to player and next request (this will skip content warnings) // attach$4(xhr, 'send', (args) => { // if (typeof args[0] === 'string') { // args[0] = setContentCheckOk(args[0]); // } // }); // } } /** * Handles Fetch requests and * - Rewrite Googlevideo URLs to Proxy URLs (if necessary) * - Store auth headers for the authentication of further unlock requests. * - Add "content check ok" flags to request bodys */ function handleFetchRequest(url, requestOptions) { const url_obj = new URL(url); // const newGoogleVideoUrl = unlockGoogleVideo(url_obj); // if (newGoogleVideoUrl) { // // Exclude credentials from Fetch Request // if (requestOptions.credentials) { // requestOptions.credentials = 'omit'; // } // return newGoogleVideoUrl.toString(); // } if (url_obj.pathname.indexOf('/youtubei/') === 0 && isObject(requestOptions.headers)) { // Store auth headers in authStorage for further usage. for (let headerName in requestOptions.headers) { if (Config.GOOGLE_AUTH_HEADER_NAMES.includes(headerName)) { set(headerName, requestOptions.headers[headerName]); } } } // if (Config.SKIP_CONTENT_WARNINGS && ['/youtubei/v1/player', '/youtubei/v1/next'].includes(url_obj.pathname)) { // // Add content check flags to player and next request (this will skip content warnings) // requestOptions.body = setContentCheckOk(requestOptions.body); // } } function processYtData(ytData) { try { // if (isPlayerObject(ytData) && isPremium1080pAvailable(ytData.playabilityStatus)) { // if (!ytData.streamingData.__hlsManifestUrl) { // unlockResponse$1(ytData) // console.log('baa', ytData) // } // } // if (isPlayerObject(ytData)) { // if (isPremium1080pAvailable(ytData)) { // console.log('si prem') // // console.log(value, 'set', value.videoDetails.videoId) // if (!ytData.streamingData.__hlsManifestUrl) { // const id = ytData.videoDetails.videoId // // getIosResponse(id, ytData) // unlockResponse$1(ytData) // } // } else { // console.log('ni prem') // } // } } catch (err) { // console.log(err, 'Premium 1080p unlock failed') } return ytData; } try { attach$3(processYtData); attach$2(processYtData); attach(handleXhrOpen); attach$1(handleFetchRequest); } catch (err) { console.log(err, 'Error while attaching data interceptors'); } function attach$4(obj, prop, onCall) { if (!obj || typeof obj[prop] !== 'function') { return; } let original = obj[prop]; obj[prop] = function() { try { onCall(arguments); } catch {} original.apply(this, arguments); }; } let ageRestricted = false let live = false function attach$3(onInitialData) { interceptObjectProperty('playerResponse', (obj, playerResponse) => { // console.log(`playerResponse property set, contains sidebar: ${!!obj.response}`); // The same object also contains the sidebar data and video description if (isObject(obj.response)) onInitialData(obj.response); // If the script is executed too late and the bootstrap data has already been processed, // a reload of the player can be forced by creating a deep copy of the object. // This is especially relevant if the userscript manager does not handle the `@run-at document-start` correctly. // playerResponse.unlocked = false; onInitialData(playerResponse); const id = playerResponse?.videoDetails?.videoId // don't run on unavailable videos // don't run when hovering over videos on the youtube home page // don't run on unavailable videos (no streaming data) if ( id && location.href.includes(id) && playerResponse.streamingData && playerResponse.videoDetails ) { if (id !== sharedPlayerElements.id) { ageRestricted = !!playerResponse.unlocked || !!playerResponse.YHEageRestricted // for cached responses live = !!playerResponse.videoDetails.isLive console.log( '-----------------------------------------------------\nnew vid', id, '\nis live:', live, '\nis SYARB unlocked:', ageRestricted, // playerResponse, ) resetPlayer() sharedPlayerElements.hlsUrl = false // mark response as ageRestricted so we know if we meet it agan from cache without playerResponse.unlocked ageRestricted && (playerResponse.YHEageRestricted = true) } // don't run when https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass unlocked the video // don't run on live content if (!ageRestricted && !live && (isPremium1080pAvailable(playerResponse.playabilityStatus) || !onlyOnPremiumAvailable)) { if (!playerResponse.streamingData.__hlsManifestUrl) { unlockResponse$1(playerResponse) // console.log('unlock fn, obj', unlockResponse$1, playerResponse) // sharedPlayerElements.hlsUrl = playerResponse.streamingData.__hlsManifestUrl } sharedPlayerElements.hlsUrl = playerResponse.streamingData.__hlsManifestUrl setupPlayer() } sharedPlayerElements.id = id } currentVideoId = id // return playerResponse.unlocked ? createDeepCopy(playerResponse) : playerResponse; return playerResponse }); // The global `ytInitialData` variable can be modified on the fly. // It contains search r###lts, sidebar data and meta information // Not really important but fixes https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/issues/127 unsafeWindow.addEventListener('DOMContentLoaded', () => { if (isObject(unsafeWindow.ytInitialData)) { onInitialData(unsafeWindow.ytInitialData); } }); } function attach$2(onJsonDataReceived) { unsafeWindow.JSON.parse = function() { const data = nativeJSONParse.apply(this, arguments); return isObject(data) ? onJsonDataReceived(data) : data; }; } function attach$1(onRequestCreate) { if (typeof unsafeWindow.Request !== 'function') { return; } unsafeWindow.Request = new Proxy(unsafeWindow.Request, { construct(target, args) { let [url, options] = args; try { if (typeof url === 'string') { if (url.indexOf('/') === 0) { url = window.location.origin + url; } if (url.indexOf('https://') !== -1) { const modifiedUrl = onRequestCreate(url, options); if (modifiedUrl) { args[0] = modifiedUrl; } } } } catch (err) { console.log(err, `Failed to intercept Request()`); } return Reflect.construct(target, args); }, }); } function attach(onXhrOpenCalled) { unsafeWindow.XMLHttpRequest.prototype.open = function(...args) { let [method, url] = args; try { if (typeof url === 'string') { if (url.indexOf('/') === 0) { url = window.location.origin + url; } if (url.indexOf('https://') !== -1) { const modifiedUrl = onXhrOpenCalled(method, url, this); if (modifiedUrl) { args[1] = modifiedUrl; } } } } catch (err) { console.log(err, `Failed to intercept XMLHttpRequest.open()`); } nativeXMLHttpRequestOpen.apply(this, args); }; } function isObject(obj) { return obj !== null && typeof obj === 'object'; } function interceptObjectProperty(prop, onSet) { var _Object$getOwnPropert; // Allow other userscripts to decorate this descriptor, if they do something similar // const dataKey = '__SYARB_' + prop; const dataKey = '__1080pp_' + prop; const { get: getter, set: setter } = (_Object$getOwnPropert = Object.getOwnPropertyDescriptor(Object.prototype, prop)) !== null && _Object$getOwnPropert !== void 0 ? _Object$getOwnPropert : { set(value) { this[dataKey] = value; }, get() { return this[dataKey]; }, }; // Intercept the given property on any object // The assigned attribute value and the context (enclosing object) are passed to the onSet function. Object.defineProperty(Object.prototype, prop, { set(value) { setter.call(this, isObject(value) ? onSet(this, value) : value); }, get() { return getter.call(this); }, configurable: true, }); } // const hls = new Hls() // api guide at https://github.com/video-dev/hls.js/blob/master/docs/API.md // method 1 /* class fLoader extends Hls.DefaultConfig.loader { constructor(config) { super(config); const load = this.load.bind(this); this.load = function (context, config, callbacks) { // console.log(...arguments) const onError = callbacks.onError callbacks.onError = function (error, context, xhr) { // hls.js doesn' retry on code 0 cors error, change it here for shouldRetry to be called next // https://github.com/video-dev/hls.js/blob/773fe886ed45cc83a015045c314763953b9a49d9/src/utils/error-helper.ts#L77 console.log('err', ...arguments, 'errrrr', this.requestTimeout) if (error.code === 0 && new URL(context.url).hostname.endsWith('.googlevideo.com')) { GM_xmlhttpRequest({ url: context.url, onload: function (r) { if (r.status === 200 && r.finalUrl !== context.url) { error.code = 302 error.recoverable = true // this gets passed to shouldRetry // context.frag._url is the url used if shouldRetry returns true context.frag._url = r.finalUrl onError(error, context, xhr) } }, onerror: function (r) { console.log( 'Failed to recover cors error', r, ) onError(error, context, xhr) } }) } else { onError(error, context, xhr) } } load(context, config, callbacks); } } } */ // method 3 // fLoader only runs on fragments // add .isFragment to xhr here to use it in xhrSetup class fLoader2 extends Hls.DefaultConfig.loader { constructor(config) { super(config); this.loadInternal = function() { var t = this, e = this.config, r = this.context; if (e && r) { var i = this.loader = new self.XMLHttpRequest, n = this.stats; i.isFragment = true // just adding this to the original loadInternal function, we use it in xhrSetup n.loading.first = 0, n.loaded = 0, n.aborted = !1; var a = this.xhrSetup; a ? Promise.resolve().then((function() { if (!t.stats.aborted) return a(i, r.url) })).catch((function(t) { return i.open("GET", r.url, !0), a(i, r.url) })).then((function() { t.stats.aborted || t.openAndSendXhr(i, r, e) })).catch((function(e) { t.callbacks.onError({ code: i.status, text: e.message }, r, i, n) })) : this.openAndSendXhr(i, r, e) } }.bind(this) } } // const desc = Object.getOwnPropertyDescriptor(Hls.DefaultConfig.abrController.prototype, "nextAutoLevel") // // Object.defineProperty(Hls.DefaultConfig.abrController.prototype, "nextAutoLevel", { // get: desc.get, // set: new Proxy(desc.set, { // apply(target, thisArg, args) { // console.log('set nextautolvl', ...arguments, 'bb', ) // return Reflect.apply(...arguments) // } // }) // }) const hls = new Hls({ // sometimes segment urls redirect to a different url (usually 234 audio) // the redirect loses the Origin request header and we get blocked by cors // https://stackoverflow.com/a/22625354 - related info // workaround method 1 requests redirecting fragments twice, once via gm_xhr to get the final url // and hls.js requests the final url again. // inefficient but nothing compared to the amount of abuse the yt fragment urls can take. // :::: it seems it breaks the built in hls.js autobitrate controller and sometimes gets stuck on low quality // :::: makes hls.bandwidthEstimate grow forever? // // check git here for older discarded workaround ideas if the current one fails later on debug: false, // startLevel: 15, // this gets the player stuck if your internet isn't up to par with 616, or 1440p if 616 is not available // startFragPrefetch: true, abrEwmaDefaultEstimate: 5000000, // bump to 5MBps from 0.5 MBps default, reasonable if you have good enough internet to consider this userscript i'd say // also doesn't take away the ability to auto adjust to lower res if needed // methon 1 /* fragLoadPolicy: { default: { maxTimeToFirstByteMs: 9000, maxLoadTimeMs: 100000, timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0, }, errorRetry: { maxNumRetry: 5, retryDelayMs: 3000, maxRetryDelayMs: 15000, backoff: 'linear', // can't find a way to define shouldRetry alone without this entire block shouldRetry: function(retryConfig, retryCount, isTimeout, loaderResponse, originalShouldRetryResponse) { if (loaderResponse.recoverable) { console.log( 'Retrying recoverable cors error. Attempt nr:', retryCount, ) // retryConfig.retryDelayMs = 150 retryConfig.retryDelayMs = 0 // hmm, this actually changes the entire config retryConfig.maxRetryDelayMs = 0 return true } retryConfig.retryDelayMs = 3000 retryConfig.maxRetryDelayMs = 15000 return originalShouldRetryResponse } }, }, }, fLoader: fLoader, */ fLoader: fLoader2, xhrSetup(xhr, url) { // method 2 // this block alone works perfectly but requests everything twice so it is slower /* return new Promise(function(resolve, reject) { // console.log('req') GM_xmlhttpRequest({ url: url, onload: function(r) { // console.log('loaded') if (r.status === 200) { xhr.open('GET', r.finalUrl) resolve() } }, onerror: function(r) { console.log( 'Failed to recover cors error', r, ) reject() } }) }) */ // method 3 // source code reference https://github.com/video-dev/hls.js/blob/773fe886ed45cc83a015045c314763953b9a49d9/src/utils/xhr-loader.ts#L153 // this only requests fragments once with gm_xhr // seems to also work perfectly so far if (xhr.isFragment) { // const ogsend = xhr.send.bind(xhr) xhr.send = function(...args) { // console.log('sent') xhr._onreadystatechange = xhr.onreadystatechange xhr._onprogress = xhr.onprogress xhr.onprogress = null xhr.onreadystatechange = null Object.defineProperty(xhr, "readyState", {writable: true}) Object.defineProperty(xhr, "status", {writable: true}) Object.defineProperty(xhr, "response", {writable: true}) // return ogsend(...args) } return new Promise(function(resolve, reject) { // console.log('req') GM_xmlhttpRequest({ url: url, responseType: 'arraybuffer', // onprogress: function(e) { // xhr._onprogress({ // loaded: e.loaded, // total: e.total // }) // }, onprogress: xhr._onprogress, onreadystatechange: function(e) { // console.log( // 'rsc', // // e, // // xhr // ) xhr.status = e.status xhr.readyState = e.readyState xhr.response = e.response xhr._onreadystatechange() } }) resolve() }) } } }) const sharedPlayerElements = {} unsafeWindow.Hls = Hls unsafeWindow.hls = hls unsafeWindow.sharedPlayerElements = sharedPlayerElements // self.hls = hls // self.sharedPlayerElements = sharedPlayerElements function setupPlayer() { if (sharedPlayerElements.hlsToggle) return const div = document.createElement('div') div.innerHTML = `<div id="yt1080pp" class="ytp-menuitem" role="menuitemcheckbox" aria-checked="false" tabindex="0"><div style="text-align: center;" class="ytp-menuitem-icon">pp</div><div class="ytp-menuitem-label"><span>Hls manifest</span><br><div style="display: none;"><span id="yt1080pp_vitag">0</span><span id="yt1080pp_va_separator">/</span><span id="yt1080pp_aitag">0</span></div></div><div class="ytp-menuitem-content"><div class="ytp-menuitem-toggle-checkbox"></div></div></div>` const wtf = div.firstChild if (isEmbed) { wtf.firstChild.innerText = '' } wtf.addEventListener('click', _ => { if (wtf.ariaChecked === 'false') { wtf.ariaChecked = 'true' // block the normal quality button wtf.previousSibling.style.position = 'relative' const blocker = createElement('div', { style: 'background-color: rgba(0 0 0 / 0.5);width: 100%;height: 100%;position: absolute;top: 0;left: 0;cursor: not-allowed;', onclick: e => { e.stopPropagation() e.preventDefault() } }) wtf.previousSibling.append(blocker) wtf.querySelector('br').nextSibling.style.display = '' sharedPlayerElements.blocker = blocker hookHlsjs() } else { wtf.ariaChecked = 'false' wtf.previousSibling.style.position = '' wtf.querySelector('br').nextSibling.style.display = 'none' sharedPlayerElements.blocker?.remove?.() sharedPlayerElements.blocker = false unhookHlsjs() } }) function panelReady() { const panel = document.querySelector('div:not(.ytp-contextmenu) > div.ytp-panel > .ytp-panel-menu') const vid = document.querySelector('video.html5-main-video') const settings = document.querySelector('.ytp-settings-button') if (panel && panel.childElementCount === 0 && settings) { // settings panel is empty until opened when first loading the page settings.click() settings.click() } return (panel && vid && settings && panel.firstChild) ? panel : undefined } function addTo(target) { target.append(wtf) sharedPlayerElements.hlsToggle = wtf console.log('added toggle') if (onByDefault) { wtf.click() console.log('autostarted hls') } if (notify616 || onBy616) { fetch(sharedPlayerElements.hlsUrl) .then(r => r.text()) .then(r => { const match = r.match(/\/itag\/616\//) if (match) { if (notify616) { Toast.show(messagesMap._616, 2) console.log('616 detected') } if (!onByDefault && onBy616) { wtf.click() console.log('started hls because 616') } } }) } } if (panelReady()) { // addTo(panelReady()) setTimeout(addTo.bind(null, panelReady())) } else { new MutationObserver(function(m) { label: for (const i of m) { const panel = panelReady() if (panel) { this.disconnect() addTo(panel) break label } } }).observe(document, {subtree: true, childList: true}) } sharedPlayerElements.hlsToggle = true console.log('adding toggle') } function resetPlayer() { if (sharedPlayerElements.hlsToggle) { if (sharedPlayerElements.hlsToggle.ariaChecked === 'true') { sharedPlayerElements.hlsToggle.click() } sharedPlayerElements.hlsToggle.remove() sharedPlayerElements.hlsToggle = false console.log('removed toggle') } } function hookHlsjs() { const vid = document.querySelector('video') const time = vid.currentTime if (vid.src) { sharedPlayerElements.pre_hlsjs_hook_src = vid.src } hls.loadSource(sharedPlayerElements.hlsUrl) hls.attachMedia(vid) hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { // console.log(event, data) const itag = hls.levels[data.level].url[0].match(/(?<=itag\/)\d+/)?.[0] || '?' document.querySelector('#yt1080pp_vitag').innerText = itag }) hls.on(Hls.Events.AUDIO_TRACK_SWITCHED , (event, data) => { // console.log(event, data) const itag = data?.attrs?.["GROUP-ID"] || '?' document.querySelector('#yt1080pp_aitag').innerText = itag }) hls.on(Hls.Events.ERROR, (event, data) => { console.log(event, data) // we can check if the error was solved in data.errorAction.resolved if (data.fatal) { console.log('fatal error, disabling. A page reload might fix this') Toast.show('fatal playback error') if (sharedPlayerElements.hlsToggle.ariaChecked === 'true') { sharedPlayerElements.hlsToggle.click() } } // should self disable if we can't play because cors issues or anything else really }) vid.currentTime = time vid.pause() vid.play() } function unhookHlsjs() { const vid = hls.media hls.detachMedia(vid) // this also removes the src attribute if (sharedPlayerElements.pre_hlsjs_hook_src) { vid.src = sharedPlayerElements.pre_hlsjs_hook_src delete sharedPlayerElements.pre_hlsjs_hook_src } // vid.src = undefined // it seems youtube fixes this almost instantly } let currentVideoId let menuCommandId = 'copyHls' const opts = { id: menuCommandId, autoClose: false, } const initialCaption = 'Copy new hls manifest' function menuCommandFn() { console.log('copy new hls manifest clicked') menuCommandId = GM_registerMenuCommand('Fetching...', _ => {}, opts) const newResponse = getUnlockedPlayerResponse(currentVideoId, '', true) const url = newResponse?.streamingData?.hlsManifestUrl if (url) { GM_setClipboard(url, 'text/plain') menuCommandId = GM_registerMenuCommand('Copied!', _ => {}, opts) setTimeout( _ => { menuCommandId = GM_registerMenuCommand(initialCaption, menuCommandFn, opts) }, 1000 ) return } menuCommandId = GM_registerMenuCommand('Error!', _ => {}, opts) console.log('failed to copy hls manifest', newResponse) setTimeout( _ => { menuCommandId = GM_registerMenuCommand(initialCaption, menuCommandFn, opts) }, 3000 ) } menuCommandId = GM_registerMenuCommand(initialCaption, menuCommandFn, opts)