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