// ==UserScript==
// @name Youtube HLS Enabler
// @namespace https://github.com/pepeloni-away
// @author pploni
// @run-at document-start
// @insert-into page
// @version 1.8
// @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
}
const VALID_PLAYABILITY_STATUSES = ['OK', 'LIVE_STREAM_OFFLINE'];
const GOOGLE_AUTH_HEADER_NAMES = ['Authorization', 'X-Goog-AuthUser', 'X-Origin'];
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: false,
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);
}
}
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);
} catch (err) {
console.log(err, 'Error while attaching data interceptors');
}
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 results, 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 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)