Skip to content

Instantly share code, notes, and snippets.

@lbmaian
Last active December 18, 2023 09:39
Show Gist options
  • Save lbmaian/5b1ff91593c9c0dc9262bfa74e8a5ad9 to your computer and use it in GitHub Desktop.
Save lbmaian/5b1ff91593c9c0dc9262bfa74e8a5ad9 to your computer and use it in GitHub Desktop.
YouTube - Playback Fixes
// ==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
}
})();
@lbmaian
Copy link
Author

lbmaian commented Aug 31, 2022

Note: doesn't work reliably

edit: Definitely does NOT work

@lbmaian
Copy link
Author

lbmaian commented Nov 22, 2022

Should work now
Now also intercepts https://www.youtube.com/youtubei/v1/player fetch in addition to the original intercepts of https://www.youtube.com/youtubei/v1/player/heartbeat XHR

@lbmaian
Copy link
Author

lbmaian commented Dec 17, 2022

Revamped to now work when navigating b/w YT pages (enables when navigating to watch playlist page, and disables when navigating away)

@lbmaian
Copy link
Author

lbmaian commented Dec 18, 2022

Should now also work in YT music

@lbmaian
Copy link
Author

lbmaian commented Jan 19, 2023

Now skips members-only videos and upcoming streams in playlists
Userscript name changed accordingly along with minor internal changes
Some internal notes and debug logging for preventing end of playlist from sometimes autoplaying to a recommended non-playlist video (WIP)

@lbmaian
Copy link
Author

lbmaian commented Jan 27, 2023

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

@lbmaian
Copy link
Author

lbmaian commented Apr 22, 2023

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

@lbmaian
Copy link
Author

lbmaian commented Sep 18, 2023

Fix: XMLHttpRequest.prototype.responseXML apparently no longer exists in Chrome? YT doesn't seem to use it anyway, so removed proxying of it

@lbmaian
Copy link
Author

lbmaian commented Oct 13, 2023

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.

@lbmaian
Copy link
Author

lbmaian commented Oct 24, 2023

Found and fixed actual root cause - it wasn't working for "Queue" playlists.

@lbmaian
Copy link
Author

lbmaian commented Dec 18, 2023

Fixed regression where it didn't properly catch heartbeats due to bad responseType handling

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment