-
-
Save lbmaian/5b1ff91593c9c0dc9262bfa74e8a5ad9 to your computer and use it in GitHub Desktop.
| // ==UserScript== | |
| // @name YouTube - Playback Fixes | |
| // @namespace https://gist.github.com/lbmaian/5b1ff91593c9c0dc9262bfa74e8a5ad9 | |
| // @downloadURL https://gist.github.com/lbmaian/5b1ff91593c9c0dc9262bfa74e8a5ad9/raw/youtube-playback-fixes.user.js | |
| // @updateURL https://gist.github.com/lbmaian/5b1ff91593c9c0dc9262bfa74e8a5ad9/raw/youtube-playback-fixes.user.js | |
| // @version 0.9.1 | |
| // @description Workarounds for various YouTube playback annoyances: prevent "Playback paused because your account is being used in another location", skip unplayable members-only videos and upcoming streams in playlists | |
| // @author lbmaian | |
| // @match https://www.youtube.com/* | |
| // @match https://music.youtube.com/* | |
| // @exclude https://www.youtube.com/embed/* | |
| // @icon https://www.youtube.com/favicon.ico | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| const DEBUG = false; | |
| const logContext = '[YouTube - Playback Fixes]'; | |
| var debug; | |
| if (DEBUG) { | |
| debug = function(...args) { | |
| console.debug(logContext, ...args); | |
| }; | |
| } else { | |
| debug = function() {}; | |
| } | |
| function log(...args) { | |
| console.log(logContext, ...args); | |
| } | |
| function info(...args) { | |
| console.info(logContext, ...args); | |
| } | |
| function warn(...args) { | |
| console.warn(logContext, ...args); | |
| } | |
| function error(...args) { | |
| console.error(logContext, ...args); | |
| } | |
| // Extremely basic sscanf | |
| // Parameters: | |
| // - formatStr: format string, only supports: | |
| // %%, %d, %s (matches regex /[^/?&.]+/ to match url path components & query parameters) | |
| // trailing ... (format string only needs to match start of url) | |
| // - searchStr: string to search | |
| // Returns: [match for 1st format specifier, match for 2nd format specifier, ...] or null if no match found | |
| var sscanfUrl = (() => { | |
| const cache = new Map(); | |
| const REGEX = 0; | |
| const STRING = 1; | |
| const FULLSTRING = 2; | |
| return function sscanfUrl(formatStr, searchStr) { | |
| let cached = cache.get(formatStr); | |
| if (!cached) { | |
| const cacheKey = formatStr; | |
| const fullMatch = !formatStr.endsWith('...'); | |
| if (!fullMatch) { | |
| formatStr = formatStr.substring(0, formatStr.length - 3); | |
| } | |
| if (formatStr.replaceAll('%%').includes('%')) { | |
| formatStr = formatStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // from https://stackoverflow.com/a/6969486/344828 | |
| const formatSpecifiers = []; | |
| formatStr = '^' + formatStr.replace(/%./g, m => { | |
| if (m === '%%') { | |
| return '%'; | |
| } else if (m === '%d') { | |
| formatSpecifiers.push('d'); | |
| return '(-?\\d+)'; | |
| } else if (m === '%s') { | |
| formatSpecifiers.push('s'); | |
| return '([^/?&.]+)'; | |
| } else { | |
| throw Error(`format specifier '${m}' unsupported`); | |
| } | |
| }); | |
| if (fullMatch) { | |
| formatStr += '$'; | |
| } else { | |
| formatStr += '(.*)$'; | |
| } | |
| cached = {type: REGEX, value: new RegExp(formatStr), formatSpecifiers}; | |
| } else { | |
| cached = {type: fullMatch ? FULLSTRING : STRING, value: formatStr, formatSpecifiers: null}; | |
| } | |
| debug('sscanfUrl caching for key', cacheKey, ':', cached); | |
| cache.set(cacheKey, cached); | |
| } | |
| const value = cached.value; | |
| switch (cached.type) { | |
| case REGEX: { | |
| const m = value.exec(searchStr); | |
| if (m) { | |
| return cached.formatSpecifiers.map((specifier, i) => { | |
| const matched = m[i + 1]; | |
| if (specifier === 'd') { | |
| return Number.parseInt(matched); | |
| } else { | |
| return matched; | |
| } | |
| }); | |
| } else { | |
| return null; | |
| } | |
| } | |
| case STRING: | |
| if (searchStr.startsWith(value)) { | |
| return [searchStr.slice(value.length)]; | |
| } else { | |
| return null; | |
| } | |
| case FULLSTRING: | |
| return searchStr === value ? [] : null; | |
| } | |
| } | |
| })(); | |
| // Extremely basic sprintf, supporting: | |
| // %%, %d, %s | |
| function sprintf(formatStr, ...args) { | |
| let i = 0; | |
| let str = formatStr.replaceAll(/%./g, m => { | |
| if (m === '%%') { | |
| return '%'; | |
| } else if (m === '%d') { | |
| return Math.trunc(args[i++]); | |
| } else if (m === '%s') { | |
| return args[i++]; | |
| } else { | |
| throw Error(`format specifier '${m}' unsupported`); | |
| } | |
| }); | |
| if (formatStr.endsWith('...')) { | |
| str = formatStr.slice(0, -3) + args[i]; | |
| } | |
| return str; | |
| } | |
| // Allows interception of HTTP requests | |
| // register method accepts a single configuration object with entries: | |
| // - sourceUrlFormat: sscanfUrl format string for source URL (URL to intercept) | |
| // - sourceUrlFormats: same as sourceUrlFormat except an array of such strings | |
| // Either sourceUrlFormat or sourceUrlFormats must be specified. | |
| // - destinationUrlFormat: sscanfUrl/sprintf format string for destination URL (URL to redirect to) | |
| // - destinationUrlFormats: same as destinationUrlFormat except an array of such strings | |
| // - destinationUrlFormatter: function that returns the destination URL given: | |
| // - destinationUrlFormatters if destinationUrlFormats is specified, else destinationUrlFormat | |
| // - match for 1st format specifier in sourceUrlFormat | |
| // - match for 2nd format specifier in sourceUrlFormat | |
| // - ... | |
| // If neither destinationUrlFormat nor destinationUrlFormats is specified, the source URL is used as-is. | |
| // If destinationUrlFormats is specified, destinationUrlFormatter must also be specified. | |
| // If destinationUrlFormat is specified instead, destinationUrlFormatter can be omitted and defaults to `sprintf`. | |
| // - responseHandler: function that handles and returns the response string, presumably in the same format that the | |
| // original request would've returned; parameters are: | |
| // - response text for HTTP request to destination URL (XMLHttpRequest.responseText) | |
| // - destination URL (XMLHttpRequest.responseURL) | |
| // - match for 1st format specifier in destinationUrlFormat | |
| // - match for 2nd format specifier in destinationUrlFormat | |
| // - ... | |
| // Only supports intercepting XMLHttpRequest API (not fetch API) and XMLHttpRequest.responseText (not response/responseXML). | |
| // Note that XMLHttpRequest.responseURL returns to original (pre-intercept) URL. | |
| class HttpRequestInterceptor { | |
| stats = null; | |
| statsKeyFunc = HttpRequestInterceptor.defaultStatsKeyFunc; | |
| #origDescs = null; | |
| #sourceToDest = new Map(); | |
| #destToResponseHandler = new Map(); | |
| constructor() { | |
| //this.stats = new Map(); // uncomment to record stats | |
| } | |
| register(config) { | |
| if (!config.sourceUrlFormat && !config.sourceUrlFormats) { | |
| throw Error('either sourceUrlFormat or sourceUrlFormats must be specified'); | |
| } | |
| if (!config.destinationUrlFormatter && config.destinationUrlFormats) { | |
| throw Error('destinationUrlFormatter must specified if destinationUrlFormats is specified'); | |
| } | |
| if (!config.responseHandler) { | |
| throw Error('responseHandler must be specified'); | |
| } | |
| const dest = { | |
| format: config.destinationUrlFormat, | |
| formats: config.destinationUrlFormats, | |
| formatter: config.destinationUrlFormatter, | |
| }; | |
| const anyDestFormatOpt = dest.format || dest.formats || dest.formatter; | |
| const sourceUrlFormats = config.sourceUrlFormats ?? [config.sourceUrlFormat]; | |
| for (const sourceUrlFormat of sourceUrlFormats) { | |
| if (anyDestFormatOpt) { // if no dest url format opts are specified, no URL transformation | |
| this.#sourceToDest.set(sourceUrlFormat, dest); | |
| } | |
| } | |
| const destUrlFormats = config.destinationUrlFormats ?? (config.destinationUrlFormat ? [config.destinationUrlFormat] : sourceUrlFormats); | |
| for (const destUrlFormat of destUrlFormats) { | |
| this.#destToResponseHandler.set(destUrlFormat, config.responseHandler); | |
| } | |
| debug('HttpRequestInterceptor.register:', config, '=>\n#sourceToDest:', new Map(this.#sourceToDest), | |
| '\n#destToResponseHandler:', new Map(this.#destToResponseHandler)); | |
| } | |
| get enabled() { | |
| return this.#origDescs !== null; | |
| } | |
| enable() { | |
| if (this.enabled) { | |
| return; | |
| } | |
| log('enabling HTTP request interception for url:', document.URL); | |
| this.#origDescs = []; | |
| this.#proxyMethod(XMLHttpRequest.prototype, 'open', (origMethod) => { | |
| const self = this; | |
| return function open(method, url) { | |
| if (url[0] === '/' && url[1] !== '/') { | |
| debug('XMLHttpRequest.open', ...arguments, '\nurl <=', location.origin + url); | |
| url = location.origin + url; | |
| } else { | |
| debug('XMLHttpRequest.open', ...arguments); | |
| } | |
| for (const [sourceUrlFormat, dest] of self.#sourceToDest) { | |
| debug('sscanfUrl("' + sourceUrlFormat + '", url)'); | |
| const m = sscanfUrl(sourceUrlFormat, url); | |
| if (m) { | |
| debug('matched', m, 'for source URL format', sourceUrlFormat, | |
| 'and destination URL format', dest); | |
| let {format, formats, formatter} = dest; | |
| if (formats || format || formatter) { | |
| format = formats ?? format ?? sourceUrlFormat; | |
| formatter ??= sprintf; | |
| const destUrl = formatter(format, ...m); | |
| if (url !== destUrl) { | |
| debug('redirecting', url, 'to', destUrl); | |
| arguments[1] = destUrl; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| return origMethod.apply(this, arguments); | |
| }; | |
| }); | |
| this.#proxyXhrResponseProperty('response'); | |
| this.#proxyXhrResponseProperty('responseText'); | |
| //this.#proxyXhrResponseProperty('responseXML'); // XXX: no longer seems to exist in Chrome? YT doesn't seem to use it anyway | |
| this.#proxyMethod(window, 'fetch', (origMethod) => { | |
| const self = this; | |
| return function fetch(resource, options) { | |
| const url = resource instanceof Request ? resource.url : String(resource); | |
| if (url[0] === '/' && url[1] !== '/') { | |
| debug('fetch', ...arguments, '\nurl <=', location.origin + url); | |
| url = location.origin + url; | |
| } else { | |
| debug('fetch', ...arguments); | |
| } | |
| for (const [sourceUrlFormat, dest] of self.#sourceToDest) { | |
| const m = sscanfUrl(sourceUrlFormat, url); | |
| if (m) { | |
| debug('matched', m, 'for source URL format', sourceUrlFormat, | |
| 'and destination URL format', dest); | |
| let {format, formats, formatter} = dest; | |
| if (formats || format || formatter) { | |
| format = formats ?? format ?? sourceUrlFormat; | |
| formatter ??= sprintf; | |
| const destUrl = formatter(format, ...m); | |
| if (url !== destUrl) { | |
| debug('redirecting', url, 'to', destUrl); | |
| if (resource instanceof Request) { | |
| resource = new Request(destUrl, resource); | |
| } else { | |
| resource = destUrl; | |
| } | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| return origMethod.call(this, resource, options); | |
| }; | |
| }); | |
| this.#proxyResponsePropertyStatsOnly('body', 'get'); // TODO: implement non-binary handler for body stream if necessary, responseType should be body properties | |
| this.#proxyResponsePropertyStatsOnly('arrayBuffer'); // assumed to always be binary | |
| this.#proxyResponsePropertyStatsOnly('blob'); // TODO: implement non-binary handler (blob.text) if necessary, responseType should be the blob properties | |
| this.#proxyResponsePropertyStatsOnly('formData'); // TODO: implement non-binary handler (non-file) if necessary | |
| this.#proxyResponseProperty('json'); | |
| this.#proxyResponseProperty('text'); | |
| } | |
| disable() { | |
| if (this.enabled) { | |
| log('disabling HTTP request interception for url:', document.URL); | |
| for (const [obj, prop, origDesc] of this.#origDescs) { | |
| Object.defineProperty(obj, prop, origDesc); | |
| } | |
| this.#origDescs = null; | |
| } | |
| } | |
| #proxyMethod(obj, prop, defineFunc) { | |
| return this.#proxyProperty(obj, prop, 'value', defineFunc); | |
| } | |
| #proxyGetter(obj, prop, defineFunc) { | |
| return this.#proxyProperty(obj, prop, 'get', defineFunc); | |
| } | |
| #proxySetter(obj, prop, defineFunc) { | |
| return this.#proxyProperty(obj, prop, 'set', defineFunc); | |
| } | |
| #proxyProperty(obj, prop, descKey, defineFunc) { | |
| const origDesc = Object.getOwnPropertyDescriptor(obj, prop); | |
| if (!origDesc) { | |
| error('could not find', prop, 'on', obj); | |
| } | |
| const origFunc = origDesc[descKey]; | |
| if (!origFunc) { | |
| error('could not find', descKey, 'for', prop, 'on', obj); | |
| } | |
| this.#origDescs.push([obj, prop, origDesc]); | |
| const func = defineFunc(origFunc); | |
| if (func.name !== origFunc.name) { | |
| Object.defineProperty(func, 'name', { value: origFunc.name }); // for console debugging purposes | |
| } | |
| Object.defineProperty(obj, prop, { | |
| ...origDesc, | |
| [descKey]: func, | |
| }); | |
| } | |
| #prehandleResponse(request, url, srcMethod, responseType, contentType) { | |
| if (this.stats) { | |
| const statsKey = this.statsKeyFunc(url, srcMethod, responseType, contentType); | |
| this.stats.set(statsKey, (this.stats.get(statsKey) || 0) + 1); | |
| } | |
| // TODO: handle json, document (XHR-only), formData (fetch-only)? | |
| if (responseType !== 'text') { | |
| return false; | |
| } | |
| debug('intercepted YT request to\n', url, srcMethod, responseType, contentType, '\n', request); | |
| return true; | |
| } | |
| #handleResponse(request, url, srcMethod, responseType, contentType, response) { | |
| if (url[0] === '/' && url[1] !== '/') { | |
| debug('orig response for YT request to\n', url, srcMethod, responseType, contentType, '\nurl <=', location.origin + url, '\n', response); | |
| url = location.origin + url; | |
| } else { | |
| debug('orig response for YT request to\n', url, srcMethod, responseType, contentType, '\n', response); | |
| } | |
| for (const [destUrlFormat, responseHandler] of this.#destToResponseHandler) { | |
| const m = sscanfUrl(destUrlFormat, url); | |
| if (m) { | |
| //debug(response handler for', url, ':', responseHandler); | |
| return responseHandler(response, responseType, url, ...m); | |
| } | |
| } | |
| return response; | |
| } | |
| #proxyXhrResponseProperty(responseProp) { | |
| const self = this; | |
| this.#proxyGetter(XMLHttpRequest.prototype, responseProp, (origGetter) => { | |
| return function() { | |
| const srcMethod = 'XMLHttpRequest.' + responseProp; | |
| debug(srcMethod, 'for', this); | |
| // Note: this.responseType can be empty string, so must use || 'text' rather than ?? 'text' here. | |
| const [responseURL, responseType, contentType] = [this.responseURL, this.responseType || 'text', this.getResponseHeader('content-type')]; | |
| if (!self.#prehandleResponse(this, responseURL, srcMethod, responseType, contentType)) { | |
| return origGetter.call(this); | |
| } | |
| return self.#handleResponse(this, responseURL, srcMethod, responseType, contentType, origGetter.call(this)); | |
| }; | |
| }); | |
| } | |
| #proxyResponseProperty(responseProp, descKey='value') { | |
| const self = this; | |
| this.#proxyProperty(Response.prototype, responseProp, descKey, (origGetter) => { | |
| return function() { | |
| const [responseURL, responseType, contentType] = [this.url, responseProp, this.headers.get('content-type')]; | |
| if (!self.#prehandleResponse(this, responseURL, 'fetch', responseType, contentType)) { | |
| return origGetter.call(this); | |
| } | |
| return origGetter.call(this).then((response) => { | |
| return self.#handleResponse(response, responseURL, 'fetch', responseType, contentType, response); | |
| }); | |
| }; | |
| }); | |
| } | |
| #proxyResponsePropertyStatsOnly(responseProp, descKey='value') { | |
| if (this.stats) { | |
| const self = this; | |
| this.#proxyProperty(Response.prototype, responseProp, descKey, (origGetter) => { | |
| return function() { | |
| const statsKey = self.statsKeyFunc(this.url, 'fetch', responseProp, this.headers.get('content-type')); | |
| self.stats.set(statsKey, (self.stats.get(statsKey) || 0) + 1); | |
| return origGetter.call(this); | |
| }; | |
| }); | |
| } | |
| } | |
| static defaultStatsKeyFunc(url, responseProp, responseType, contentType) { | |
| const protocolIdx = url.indexOf('://'); | |
| if (protocolIdx !== -1) { | |
| url = url.substring(protocolIdx + 3); | |
| } | |
| const queryIdx = url.indexOf('?'); | |
| if (queryIdx !== -1) { | |
| url = url.substring(0, queryIdx); | |
| } | |
| if (contentType) { | |
| const mimeParamIdx = contentType.indexOf(';'); | |
| if (mimeParamIdx !== -1) { | |
| contentType = contentType.substring(0, mimeParamIdx); | |
| } | |
| } | |
| if (responseType) { | |
| const mimeParamIdx = responseType.indexOf(';'); | |
| if (mimeParamIdx !== -1) { | |
| responseType = responseType.substring(0, mimeParamIdx); | |
| } | |
| } | |
| if (contentType !== responseType) { | |
| responseType += '(' + contentType + ')'; | |
| } | |
| return url + ',' + responseProp + ',' + responseType; | |
| } | |
| } | |
| function jsonParse(str) { | |
| try { | |
| return JSON.parse(str); | |
| } catch (e) { | |
| throw Error('could not parse JSON from: ' + str); | |
| } | |
| } | |
| const isYtMusic = location.hostname === 'music.youtube.com'; | |
| const interceptor = new HttpRequestInterceptor(); | |
| window.httpRequestInterceptor = interceptor; // for devtools console access | |
| interceptor.statsKeyFunc = function(url, ...args) { | |
| url = HttpRequestInterceptor.defaultStatsKeyFunc(url, ...args); | |
| const idx = url.indexOf('.googlevideo.com/'); | |
| if (idx !== -1) { | |
| url = '*' + url.substring(idx); | |
| } | |
| return url; | |
| }; | |
| interceptor.register({ | |
| // Assume that there's no "internal navigation" via AJAX between www.youtube.com and music.youtube.com, | |
| // so don't need to watch for both sets of URLs | |
| sourceUrlFormats: isYtMusic ? [ | |
| 'https://music.youtube.com/youtubei/v1/player/heartbeat?...', | |
| 'https://music.youtube.com/youtubei/v1/player?...', | |
| ] : [ | |
| 'https://www.youtube.com/youtubei/v1/player/heartbeat?...', | |
| 'https://www.youtube.com/youtubei/v1/player?...', | |
| ], | |
| responseHandler(responseText, responseType, url) { | |
| //log(url, ':', responseText); | |
| const responseContext = jsonParse(responseText); // responseType currently must be 'text' | |
| const playabilityStatus = responseContext.playabilityStatus; | |
| debug('intercepted', url, 'with playabilityStatus:', playabilityStatus); | |
| let newPlayabilityStatus = null; | |
| let changeReason = null; | |
| if (playabilityStatus.status === 'UNPLAYABLE') { | |
| if (playabilityStatus?.errorScreen?.playerLegacyDesktopYpcOfferRenderer?.offerId === 'sponsors_only_video') { | |
| // Skip unplayable members-only videos in playlists | |
| if (location.search.includes("&list=")) { | |
| newPlayabilityStatus = { | |
| ...playabilityStatus, | |
| // playabilityStatus should have this as false, so override it | |
| skip: { | |
| playabilityErrorSkipConfig: { | |
| skipOnPlayabilityError: true, | |
| } | |
| }, | |
| }; | |
| // This is actually required, and not just done for more concise logs | |
| delete newPlayabilityStatus.errorScreen; | |
| changeReason = 'skipping members-only video'; | |
| } | |
| } else { | |
| // Assume any other UNPLAYABLE is "Playback paused because your account is being used in another location" | |
| // or some other YT annoyance - coerce them to be playable | |
| newPlayabilityStatus = { | |
| ...playabilityStatus, | |
| status: 'OK', | |
| }; | |
| // Following not really necessary; only for more concise logs | |
| delete newPlayabilityStatus.reason; | |
| delete newPlayabilityStatus.errorScreen; | |
| delete newPlayabilityStatus.skip; | |
| changeReason = 'fixing "Playback paused..."'; | |
| } | |
| } else if (playabilityStatus.status === 'LIVE_STREAM_OFFLINE') { | |
| // Skip upcoming streams in playlists by coercing them as unplayable | |
| if (location.search.includes("&list=")) { | |
| newPlayabilityStatus = { | |
| ...playabilityStatus, | |
| status: 'UNPLAYABLE', | |
| // XXX: Not sure if this is necessary, since this seems to default to true anyway | |
| skip: { | |
| playabilityErrorSkipConfig: { | |
| skipOnPlayabilityError: true, | |
| } | |
| }, | |
| }; | |
| // Following not really necessary; only for more concise logs | |
| delete newPlayabilityStatus.liveStreamability; | |
| delete newPlayabilityStatus.miniplayer; | |
| changeReason = 'skipping upcoming stream'; | |
| } | |
| } | |
| if (newPlayabilityStatus) { | |
| warn(changeReason, 'with playabilityStatus:', playabilityStatus, '=>', newPlayabilityStatus); | |
| responseContext.playabilityStatus = newPlayabilityStatus; | |
| responseText = JSON.stringify(responseContext); | |
| } | |
| return responseText; | |
| }, | |
| }); | |
| // interceptor.register({ | |
| // sourceUrlFormat: "https://www.youtube.com/youtubei/v1/browse/edit_playlist?...", | |
| // responseHandler(responseText, responseType, url) { | |
| // // const response = jsonParse(responseText); // responseType currently must be 'text' | |
| // // if (response.actions) { | |
| // // for (const action of response.actions) { | |
| // // if (action.updatePlaylistAction?.updatedRenderer?.playlistVideoListRenderer?.contents) { | |
| // // action.updatePlaylistAction.updatedRenderer.playlistVideoListRenderer.contents = []; | |
| // // } | |
| // // } | |
| // // responseText = JSON.stringify(response); | |
| // // } | |
| // // return responseText; | |
| // // Interesting note: Returning an invalid json value here prevents a follow up call to /youtubei/v1/next | |
| // return responseText; | |
| // }, | |
| // }); | |
| // interceptor.register({ | |
| // sourceUrlFormat: "https://www.youtube.com/youtubei/v1/next?...", | |
| // destinationUrlFormatter(format, ...args) { | |
| // return sprintf(format, ...args); | |
| // }, | |
| // responseHandler(responseText, responseType, url) { | |
| // return responseText; | |
| // }, | |
| // }); | |
| // Assume that there's no "internal navigation" via AJAX between www.youtube.com and music.youtube.com | |
| if (isYtMusic) { | |
| // Always enable on music.youtube.com since the player can appear on any page and playlists are implicit | |
| interceptor.enable(); | |
| // The same player remains when navigating between pages, | |
| // so there's no good point to log and clear interceptor stats | |
| // (and there doesn't seem to be a good event to hook into that tracks such navigation here anyway) | |
| } else { | |
| // Navigating to YouTube watch page can happen via AJAX rather than new page load | |
| // We can monitor this "internal navigation" with YT's custom yt-navigate-finish event, | |
| // which conveniently also fires even for new/refreshed pages | |
| document.addEventListener('yt-navigate-finish', evt => { | |
| const url = evt.detail.response.url; | |
| debug('Navigated to', url); | |
| // YT pages are very heavy with extraneous traffic, so only enable interceptor for watch pages, | |
| // including non-playlist pages since "Playback paused because your account is being used in another location" | |
| // can still happen on them. | |
| if (evt.detail.pageType === 'watch') { | |
| interceptor.enable(); | |
| } else { | |
| interceptor.disable(); | |
| } | |
| }); | |
| // Log and clear interceptor stats when navigating to another page | |
| if (interceptor.stats) { | |
| document.addEventListener('yt-navigate-start', evt => { | |
| if (interceptor.stats.size) { | |
| log('interceptor.stats:', new Map(interceptor.stats)); | |
| interceptor.stats.clear(); | |
| } | |
| }); | |
| } | |
| // TODO: somehow fix large playlists being renumbered incorrectly after drag-drop reordering? | |
| // playlistMgr.playlistComponent.dataChanged fires too late to adjust data and doesn't handle playlistMgr.autoplayData | |
| // Attempt to fix above drag-drop issue - didn't work | |
| // document.addEventListener('DOMContentLoaded', evt => { | |
| // window.ytcfg.get('EXPERIMENT_FLAGS').kevlar_player_playlist_use_local_index = false; | |
| // }); | |
| // Prevent end of playlist from autoplaying to a recommended non-playlist video | |
| // when there are hidden videos in the playlist | |
| const symPlaylistMgrFixed = Symbol(logContext + ' playlist fixed'); | |
| function fixPlaylistManager(playlistMgr) { | |
| const playlist = playlistMgr.playlistComponent; | |
| // if (playlist) { | |
| // const data = playlist.data; | |
| // if (data?.totalVideosText) { | |
| // const totalVideosFixed = parseTotalVideosText(data.totalVideosText); | |
| // if (totalVideosFixed !== null && data.totalVideos !== totalVideosFixed) { | |
| // log('totalVideos:', data.totalVideos, '=>', totalVideosFixed); | |
| // data.totalVideos = totalVideosFixed; | |
| // } | |
| // } | |
| // if (!playlist[symPlaylistMgrFixed]) { | |
| // const origDataChanged = playlist.dataChanged; | |
| // log('orig playlist.dataChanged func:', origDataChanged); | |
| // playlist.dataChanged = function(data) { | |
| // log('playlist.dataChanged:', window.structuredClone(data)); | |
| // log('current playlist data:', window.structuredClone(this.data)); | |
| // return origDataChanged.call(this, data); | |
| // }; | |
| // playlist[symPlaylistMgrFixed] = true; | |
| // } | |
| // } | |
| if (!playlistMgr[symPlaylistMgrFixed]) { | |
| const origNavigateToAutoplayWatchEndpoint = playlistMgr.navigateToAutoplayWatchEndpoint_; | |
| playlistMgr.navigateToAutoplayWatchEndpoint_ = function(...args) { | |
| debug('yt-playlist-manager.navigateToAutoplayWatchEndpoint_', args); | |
| try { | |
| // Hack that temporarily updates this.playlistComponent.data.totalVideos to exclude hidden videos, | |
| // which is contained in this.playlistComponent.data.totalVideosText, | |
| // so that "end of playlist" check works properly | |
| // This is temporary since totalVideos is used elsewhere and changing it permanently | |
| // might cause unexpected issues | |
| const playlistData = this.playlistComponent.data; | |
| debug('yt-playlist-manager.navigateToAutoplayWatchEndpoint_ playlist data:', playlistData); | |
| const totalVideos = playlistData.totalVideos; | |
| const totalVideosFixed = parseTotalVideosText(playlistData.totalVideosText); | |
| // Only update totalVideos if: | |
| // 1) we can parse totalVideosText to exclude hidden videos | |
| // 2) there are any hidden videos | |
| // 3) at the end of the playlist | |
| if (totalVideosFixed !== null && totalVideos !== totalVideosFixed && | |
| playlistData.currentIndex + 1 >= totalVideosFixed) { | |
| try { | |
| log('end of playlist with hidden videos: totalVideos:', totalVideos, '=>', | |
| totalVideosFixed, 'temporarily'); | |
| playlistData.totalVideos = totalVideosFixed; | |
| return origNavigateToAutoplayWatchEndpoint.apply(this, args); | |
| } finally { | |
| playlistData.totalVidoes = totalVideos; | |
| } | |
| } else { | |
| let returnVal = origNavigateToAutoplayWatchEndpoint.apply(this, args); | |
| debug('yt-playlist-manager.navigateToAutoplayWatchEndpoint_ orig =>', returnVal); | |
| return returnVal; | |
| } | |
| } catch (e) { | |
| error('yt-playlist-manager.navigateToAutoplayWatchEndpoint_ error', e); | |
| throw e; | |
| } | |
| }; | |
| log('yt-playlist-manager fixed to account for hidden videos at end of playlist'); | |
| playlistMgr[symPlaylistMgrFixed] = true; | |
| } | |
| } | |
| function parseTotalVideosText(totalVideosText) { | |
| if (totalVideosText?.runs) { | |
| for (const run of totalVideosText.runs) { | |
| const totalVideosFixed = Number(run.text); | |
| if (!Number.isNaN(totalVideosFixed)) { | |
| return totalVideosFixed; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| // This event should fire once the playlist manager is (re)initialized, | |
| // so is a good hook for fixing the playlist manager once its created | |
| // It also fires for every playlist update, so fixPlaylistManager is idempotent | |
| document.addEventListener('yt-playlist-data-updated', evt => { | |
| debug('yt-playlist-data-updated:', evt); | |
| const playlistMgr = evt.srcElement; | |
| fixPlaylistManager(playlistMgr); | |
| }); | |
| // In case playlist manager already exists due to slow userscript loading | |
| const playlistMgr = document.getElementsByTagName('yt-playlist-manager')[0]; | |
| if (playlistMgr) { | |
| debug('yt-playlist-manager at startup:', playlistMgr); | |
| fixPlaylistManager(playlistMgr); | |
| } | |
| // TODO: scroll to playlist item after page load | |
| // Tricky part is determining when to scroll - may need MutationObserver | |
| // Current item is ytd-playlist-panel-video-renderer with selected=true | |
| } | |
| })(); |
Renamed to "playback fixes", now applies to non-playlist watch pages since "Playback paused because your account is being used in another location" can also occur on them
edit: fixed skipping unplayable members-only videos and upcoming streams applying to non-playlist watch pages - should only apply to playlist watch pages
Fix: XMLHttpRequest.prototype.responseXML apparently no longer exists in Chrome? YT doesn't seem to use it anyway, so removed proxying of it
Fix apparent bug caused by unnecessarily reading fetch Response non-text properties which exhausted the internal stream - restructured to avoid such reads. Not sure why it started becoming a problem, but root cause should be addressed.
Found and fixed actual root cause - it wasn't working for "Queue" playlists.
Fixed regression where it didn't properly catch heartbeats due to bad responseType handling
Now fixes edge case where end-of-playlist autoplays to non-playlist video when playlist has any hidden videos
Misc internal changes and commented-out WIP stuff