diff --git a/base/base.user.js b/base/base.user.js deleted file mode 100644 index 97a009c..0000000 --- a/base/base.user.js +++ /dev/null @@ -1,659 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.12 -// @description Multiple solutions for blocking Twitch ads -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_ROLLING_DEVICE_ID = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - scope.gql_device_id_rolling = ''; - // Rolling device id crap... TODO: improve this - var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } - var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; - var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0; - for (var i = 0; i < bs.length; i++) { - scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; - } - } - declareOptions(window); - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } - else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.isMidroll, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } - else if (e.data.key == 'UboSeekPlayer') { - reloadTwitchPlayer(true); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - async function processM3U8(url, textStr, realFetch) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var currentResolution = null; - for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { - if (resUrl == url) { - currentResolution = resName; - //console.log(resName); - break; - } - } - streamInfo.HadAds[url] = true; - streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - // Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early. - // Deferred to after backup obtained to reduce slowdown. Midrolls are futile. - if (OPT_MODE_NOTIFY_ADS_WATCHED && !streamInfo.IsMidroll && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - await tryNotifyAdsWatchedM3U8(textStr); - } - postMessage({ - key: 'UboFoundAdSegment', - isMidroll: streamInfo.IsMidroll, - streamM3u8: textStr - }); - if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { - return ''; - } - // NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form. - // See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request) - try { - if (streamInfo.BackupRegRes != currentResolution) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < encodingsLines.length; i++) { - if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) { - if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION']; - if (res && res == currentResolution) { - streamInfo.BackupRegUrl = encodingsLines[i]; - streamInfo.BackupRegRes = currentResolution; - break; - } - } - } - } - } - } - } - if (streamInfo.BackupRegUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - //console.log('Try use regular resolution failed'); - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - } - } catch (err) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - console.log('Fetching backup (regular resolution) m3u8 failed'); - console.log(err); - } - // Fetch backup url - try { - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - if (streamInfo.BackupUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - } catch (err) { - console.log('Fetching backup m3u8 failed'); - console.log(err); - } - // Backups failed. Return nothing and reload the player (reload required as an empty result will terminate the stream). - console.log('Ad blocking failed. Stream might break.'); - postMessage({key:'UboReloadPlayer'}); - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = null; - return ''; - } - if (streamInfo.HadAds[url]) { - postMessage({key:'UboPauseResumePlayer'}); - streamInfo.HadAds[url] = false; - } - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.BackupRegUrl = null; - streamInfo.BackupRegRes = null; - streamInfo.IsMidroll = false; - streamInfo.HadAds = [];// xxx.m3u8 -> bool (had ads on prev request) - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls[lines[i]] = -1; - if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lines[i - 1])['RESOLUTION']; - if (res) { - streamInfo.Urls[lines[i]] = res; - } - } - streamInfo.HadAds[lines[i]] = false; - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - try { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } catch (err) { - console.log(err); - return 0; - } - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('gql')) { - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - if (OPT_ROLLING_DEVICE_ID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = gql_device_id_rolling; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = gql_device_id_rolling; - } - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(isMidroll, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - } - } - function reloadTwitchPlayer(isSeek, isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - if (isSeek) { - console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered())); - var pos = player.getPosition(); - player.seekTo(0); - player.seekTo(pos); - return; - } - if (isPausePlay) { - player.pause(); - player.play(); - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - hookFetch(); - function onContentLoaded() { - // This stops Twitch from pausing the player when in another tab and an ad shows. - // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 - try { - Object.defineProperty(document, 'visibilityState', { - get() { - return 'visible'; - } - }); - }catch{} - try { - Object.defineProperty(document, 'hidden', { - get() { - return false; - } - }); - }catch{} - var block = e => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }; - document.addEventListener('visibilitychange', block, true); - document.addEventListener('webkitvisibilitychange', block, true); - document.addEventListener('mozvisibilitychange', block, true); - document.addEventListener('hasFocus', block, true); - try { - if (/Firefox/.test(navigator.userAgent)) { - Object.defineProperty(document, 'mozHidden', { - get() { - return false; - } - }); - } else { - Object.defineProperty(document, 'webkitHidden', { - get() { - return false; - } - }); - } - }catch{} - } - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); \ No newline at end of file diff --git a/bypass/bypass-ublock-origin.js b/bypass/bypass-ublock-origin.js deleted file mode 100644 index fba36ee..0000000 --- a/bypass/bypass-ublock-origin.js +++ /dev/null @@ -1,22 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string' && url.includes('gql') && typeof init.body == 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('"isVod":true')) { - if (typeof init.headers['Authorization'] === 'string') { - init.headers['Authorization'] = ''; - } - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = 'twitch-web-wall-mason'; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = 'twitch-web-wall-mason'; - } - } - return realFetch.apply(this, arguments); - } - } - hookFetch(); -})(); \ No newline at end of file diff --git a/bypass/bypass.user.js b/bypass/bypass.user.js deleted file mode 100644 index 70deb73..0000000 --- a/bypass/bypass.user.js +++ /dev/null @@ -1,33 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (bypass) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/bypass/bypass.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/bypass/bypass.user.js -// @description Multiple solutions for blocking Twitch ads (bypass) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string' && url.includes('gql') && typeof init.body == 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('"isVod":true')) { - if (typeof init.headers['Authorization'] === 'string') { - init.headers['Authorization'] = ''; - } - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = 'twitch-web-wall-mason'; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = 'twitch-web-wall-mason'; - } - } - return realFetch.apply(this, arguments); - } - } - hookFetch(); -})(); diff --git a/low-res/low-res-ublock-origin.js b/low-res/low-res-ublock-origin.js deleted file mode 100644 index 68aa2b8..0000000 --- a/low-res/low-res-ublock-origin.js +++ /dev/null @@ -1,27 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function hookFetch() { - var OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //var OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - } - return realFetch.apply(this, arguments); - } - } - hookFetch(); -})(); \ No newline at end of file diff --git a/low-res/low-res.user.js b/low-res/low-res.user.js deleted file mode 100644 index d3c158c..0000000 --- a/low-res/low-res.user.js +++ /dev/null @@ -1,37 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (low-res) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.0 -// @description Avoids Twitch ads by using a lower resolution live stream without ads -// @author pixeltris -// @match *://*.twitch.tv/* -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res.user.js -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function hookFetch() { - var OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //var OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - } - return realFetch.apply(this, arguments); - } - } - hookFetch(); -})(); diff --git a/notify-reload/notify-reload-ublock-origin.js b/notify-reload/notify-reload-ublock-origin.js deleted file mode 100644 index a47fb98..0000000 --- a/notify-reload/notify-reload-ublock-origin.js +++ /dev/null @@ -1,650 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_ROLLING_DEVICE_ID = true; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - scope.gql_device_id_rolling = ''; - // Rolling device id crap... TODO: improve this - var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } - var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; - var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0; - for (var i = 0; i < bs.length; i++) { - scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; - } - } - declareOptions(window); - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } - else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.isMidroll, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } - else if (e.data.key == 'UboSeekPlayer') { - reloadTwitchPlayer(true); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - async function processM3U8(url, textStr, realFetch) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var currentResolution = null; - for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { - if (resUrl == url) { - currentResolution = resName; - //console.log(resName); - break; - } - } - streamInfo.HadAds[url] = true; - streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - // Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early. - // Deferred to after backup obtained to reduce slowdown. Midrolls are futile. - if (OPT_MODE_NOTIFY_ADS_WATCHED && !streamInfo.IsMidroll && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - await tryNotifyAdsWatchedM3U8(textStr); - } - postMessage({ - key: 'UboFoundAdSegment', - isMidroll: streamInfo.IsMidroll, - streamM3u8: textStr - }); - if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { - return ''; - } - // NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form. - // See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request) - try { - if (streamInfo.BackupRegRes != currentResolution) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < encodingsLines.length; i++) { - if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) { - if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION']; - if (res && res == currentResolution) { - streamInfo.BackupRegUrl = encodingsLines[i]; - streamInfo.BackupRegRes = currentResolution; - break; - } - } - } - } - } - } - } - if (streamInfo.BackupRegUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - //console.log('Try use regular resolution failed'); - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - } - } catch (err) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - console.log('Fetching backup (regular resolution) m3u8 failed'); - console.log(err); - } - // Fetch backup url - try { - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - if (streamInfo.BackupUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - } catch (err) { - console.log('Fetching backup m3u8 failed'); - console.log(err); - } - // Backups failed. Return nothing and reload the player (reload required as an empty result will terminate the stream). - console.log('Ad blocking failed. Stream might break.'); - postMessage({key:'UboReloadPlayer'}); - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = null; - return ''; - } - if (streamInfo.HadAds[url]) { - postMessage({key:'UboPauseResumePlayer'}); - streamInfo.HadAds[url] = false; - } - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.BackupRegUrl = null; - streamInfo.BackupRegRes = null; - streamInfo.IsMidroll = false; - streamInfo.HadAds = [];// xxx.m3u8 -> bool (had ads on prev request) - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls[lines[i]] = -1; - if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lines[i - 1])['RESOLUTION']; - if (res) { - streamInfo.Urls[lines[i]] = res; - } - } - streamInfo.HadAds[lines[i]] = false; - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - try { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } catch (err) { - console.log(err); - return 0; - } - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('gql')) { - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - if (OPT_ROLLING_DEVICE_ID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = gql_device_id_rolling; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = gql_device_id_rolling; - } - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(isMidroll, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - } - } - function reloadTwitchPlayer(isSeek, isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - if (isSeek) { - console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered())); - var pos = player.getPosition(); - player.seekTo(0); - player.seekTo(pos); - return; - } - if (isPausePlay) { - player.pause(); - player.play(); - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - hookFetch(); - function onContentLoaded() { - // This stops Twitch from pausing the player when in another tab and an ad shows. - // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 - try { - Object.defineProperty(document, 'visibilityState', { - get() { - return 'visible'; - } - }); - }catch{} - try { - Object.defineProperty(document, 'hidden', { - get() { - return false; - } - }); - }catch{} - var block = e => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }; - document.addEventListener('visibilitychange', block, true); - document.addEventListener('webkitvisibilitychange', block, true); - document.addEventListener('mozvisibilitychange', block, true); - document.addEventListener('hasFocus', block, true); - try { - if (/Firefox/.test(navigator.userAgent)) { - Object.defineProperty(document, 'mozHidden', { - get() { - return false; - } - }); - } else { - Object.defineProperty(document, 'webkitHidden', { - get() { - return false; - } - }); - } - }catch{} - } - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/notify-reload/notify-reload.cfg b/notify-reload/notify-reload.cfg deleted file mode 100644 index d7011d4..0000000 --- a/notify-reload/notify-reload.cfg +++ /dev/null @@ -1,5 +0,0 @@ -OPT_ROLLING_DEVICE_ID true -OPT_MODE_STRIP_AD_SEGMENTS true -OPT_MODE_NOTIFY_ADS_WATCHED true -OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT true -OPT_ACCESS_TOKEN_PLAYER_TYPE 'site' \ No newline at end of file diff --git a/notify-reload/notify-reload.user.js b/notify-reload/notify-reload.user.js deleted file mode 100644 index e4c69e7..0000000 --- a/notify-reload/notify-reload.user.js +++ /dev/null @@ -1,661 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (notify-reload) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.12 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js -// @description Multiple solutions for blocking Twitch ads (notify-reload) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_ROLLING_DEVICE_ID = true; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - scope.gql_device_id_rolling = ''; - // Rolling device id crap... TODO: improve this - var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } - var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; - var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0; - for (var i = 0; i < bs.length; i++) { - scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; - } - } - declareOptions(window); - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } - else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.isMidroll, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } - else if (e.data.key == 'UboSeekPlayer') { - reloadTwitchPlayer(true); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - async function processM3U8(url, textStr, realFetch) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var currentResolution = null; - for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { - if (resUrl == url) { - currentResolution = resName; - //console.log(resName); - break; - } - } - streamInfo.HadAds[url] = true; - streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - // Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early. - // Deferred to after backup obtained to reduce slowdown. Midrolls are futile. - if (OPT_MODE_NOTIFY_ADS_WATCHED && !streamInfo.IsMidroll && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - await tryNotifyAdsWatchedM3U8(textStr); - } - postMessage({ - key: 'UboFoundAdSegment', - isMidroll: streamInfo.IsMidroll, - streamM3u8: textStr - }); - if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { - return ''; - } - // NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form. - // See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request) - try { - if (streamInfo.BackupRegRes != currentResolution) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < encodingsLines.length; i++) { - if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) { - if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION']; - if (res && res == currentResolution) { - streamInfo.BackupRegUrl = encodingsLines[i]; - streamInfo.BackupRegRes = currentResolution; - break; - } - } - } - } - } - } - } - if (streamInfo.BackupRegUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - //console.log('Try use regular resolution failed'); - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - } - } catch (err) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - console.log('Fetching backup (regular resolution) m3u8 failed'); - console.log(err); - } - // Fetch backup url - try { - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - if (streamInfo.BackupUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - } catch (err) { - console.log('Fetching backup m3u8 failed'); - console.log(err); - } - // Backups failed. Return nothing and reload the player (reload required as an empty result will terminate the stream). - console.log('Ad blocking failed. Stream might break.'); - postMessage({key:'UboReloadPlayer'}); - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = null; - return ''; - } - if (streamInfo.HadAds[url]) { - postMessage({key:'UboPauseResumePlayer'}); - streamInfo.HadAds[url] = false; - } - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.BackupRegUrl = null; - streamInfo.BackupRegRes = null; - streamInfo.IsMidroll = false; - streamInfo.HadAds = [];// xxx.m3u8 -> bool (had ads on prev request) - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls[lines[i]] = -1; - if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lines[i - 1])['RESOLUTION']; - if (res) { - streamInfo.Urls[lines[i]] = res; - } - } - streamInfo.HadAds[lines[i]] = false; - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - try { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } catch (err) { - console.log(err); - return 0; - } - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('gql')) { - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - if (OPT_ROLLING_DEVICE_ID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = gql_device_id_rolling; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = gql_device_id_rolling; - } - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(isMidroll, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - } - } - function reloadTwitchPlayer(isSeek, isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - if (isSeek) { - console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered())); - var pos = player.getPosition(); - player.seekTo(0); - player.seekTo(pos); - return; - } - if (isPausePlay) { - player.pause(); - player.play(); - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - hookFetch(); - function onContentLoaded() { - // This stops Twitch from pausing the player when in another tab and an ad shows. - // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 - try { - Object.defineProperty(document, 'visibilityState', { - get() { - return 'visible'; - } - }); - }catch{} - try { - Object.defineProperty(document, 'hidden', { - get() { - return false; - } - }); - }catch{} - var block = e => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }; - document.addEventListener('visibilitychange', block, true); - document.addEventListener('webkitvisibilitychange', block, true); - document.addEventListener('mozvisibilitychange', block, true); - document.addEventListener('hasFocus', block, true); - try { - if (/Firefox/.test(navigator.userAgent)) { - Object.defineProperty(document, 'mozHidden', { - get() { - return false; - } - }); - } else { - Object.defineProperty(document, 'webkitHidden', { - get() { - return false; - } - }); - } - }catch{} - } - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/notify-strip/notify-strip-ublock-origin.js b/notify-strip/notify-strip-ublock-origin.js deleted file mode 100644 index 0b006ee..0000000 --- a/notify-strip/notify-strip-ublock-origin.js +++ /dev/null @@ -1,651 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_ROLLING_DEVICE_ID = true; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_BACKUP_PLAYER_TYPE = 'thunderdome';//'picture-by-picture';'thunderdome'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - scope.gql_device_id_rolling = ''; - // Rolling device id crap... TODO: improve this - var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } - var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; - var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0; - for (var i = 0; i < bs.length; i++) { - scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; - } - scope.gql_device_id_rolling = '1';//temporary - } - declareOptions(window); - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } - else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.isMidroll, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } - else if (e.data.key == 'UboSeekPlayer') { - reloadTwitchPlayer(true); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - async function processM3U8(url, textStr, realFetch) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var currentResolution = null; - for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { - if (resUrl == url) { - currentResolution = resName; - //console.log(resName); - break; - } - } - streamInfo.HadAds[url] = true; - streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - // Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early. - // Deferred to after backup obtained to reduce slowdown. Midrolls are futile. - if (OPT_MODE_NOTIFY_ADS_WATCHED && !streamInfo.IsMidroll && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - await tryNotifyAdsWatchedM3U8(textStr); - } - postMessage({ - key: 'UboFoundAdSegment', - isMidroll: streamInfo.IsMidroll, - streamM3u8: textStr - }); - if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { - return ''; - } - // NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form. - // See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request) - try { - if (streamInfo.BackupRegRes != currentResolution) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < encodingsLines.length; i++) { - if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) { - if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION']; - if (res && res == currentResolution) { - streamInfo.BackupRegUrl = encodingsLines[i]; - streamInfo.BackupRegRes = currentResolution; - break; - } - } - } - } - } - } - } - if (streamInfo.BackupRegUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - //console.log('Try use regular resolution failed'); - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - } - } catch (err) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - console.log('Fetching backup (regular resolution) m3u8 failed'); - console.log(err); - } - // Fetch backup url - try { - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - if (streamInfo.BackupUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - } catch (err) { - console.log('Fetching backup m3u8 failed'); - console.log(err); - } - // Backups failed. Return nothing and reload the player (reload required as an empty result will terminate the stream). - console.log('Ad blocking failed. Stream might break.'); - postMessage({key:'UboReloadPlayer'}); - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = null; - return ''; - } - if (streamInfo.HadAds[url]) { - postMessage({key:'UboPauseResumePlayer'}); - streamInfo.HadAds[url] = false; - } - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.BackupRegUrl = null; - streamInfo.BackupRegRes = null; - streamInfo.IsMidroll = false; - streamInfo.HadAds = [];// xxx.m3u8 -> bool (had ads on prev request) - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls[lines[i]] = -1; - if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lines[i - 1])['RESOLUTION']; - if (res) { - streamInfo.Urls[lines[i]] = res; - } - } - streamInfo.HadAds[lines[i]] = false; - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - try { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } catch (err) { - console.log(err); - return 0; - } - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('gql')) { - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - if (OPT_ROLLING_DEVICE_ID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = gql_device_id_rolling; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = gql_device_id_rolling; - } - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(isMidroll, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - } - } - function reloadTwitchPlayer(isSeek, isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - if (isSeek) { - console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered())); - var pos = player.getPosition(); - player.seekTo(0); - player.seekTo(pos); - return; - } - if (isPausePlay) { - player.pause(); - player.play(); - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - hookFetch(); - function onContentLoaded() { - // This stops Twitch from pausing the player when in another tab and an ad shows. - // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 - try { - Object.defineProperty(document, 'visibilityState', { - get() { - return 'visible'; - } - }); - }catch{} - try { - Object.defineProperty(document, 'hidden', { - get() { - return false; - } - }); - }catch{} - var block = e => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }; - document.addEventListener('visibilitychange', block, true); - document.addEventListener('webkitvisibilitychange', block, true); - document.addEventListener('mozvisibilitychange', block, true); - document.addEventListener('hasFocus', block, true); - try { - if (/Firefox/.test(navigator.userAgent)) { - Object.defineProperty(document, 'mozHidden', { - get() { - return false; - } - }); - } else { - Object.defineProperty(document, 'webkitHidden', { - get() { - return false; - } - }); - } - }catch{} - } - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/notify-strip/notify-strip.cfg b/notify-strip/notify-strip.cfg deleted file mode 100644 index 371b012..0000000 --- a/notify-strip/notify-strip.cfg +++ /dev/null @@ -1,4 +0,0 @@ -OPT_ROLLING_DEVICE_ID true -OPT_MODE_STRIP_AD_SEGMENTS true -OPT_MODE_NOTIFY_ADS_WATCHED true -OPT_ACCESS_TOKEN_PLAYER_TYPE 'site' \ No newline at end of file diff --git a/notify-strip/notify-strip.user.js b/notify-strip/notify-strip.user.js deleted file mode 100644 index 586ef7e..0000000 --- a/notify-strip/notify-strip.user.js +++ /dev/null @@ -1,662 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (notify-strip) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.14 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip.user.js -// @description Multiple solutions for blocking Twitch ads (notify-strip) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_ROLLING_DEVICE_ID = true; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_BACKUP_PLAYER_TYPE = 'thunderdome';//'picture-by-picture';'thunderdome'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - scope.gql_device_id_rolling = ''; - // Rolling device id crap... TODO: improve this - var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } - var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; - var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0; - for (var i = 0; i < bs.length; i++) { - scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; - } - scope.gql_device_id_rolling = '1';//temporary - } - declareOptions(window); - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } - else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.isMidroll, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } - else if (e.data.key == 'UboSeekPlayer') { - reloadTwitchPlayer(true); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - async function processM3U8(url, textStr, realFetch) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var currentResolution = null; - for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { - if (resUrl == url) { - currentResolution = resName; - //console.log(resName); - break; - } - } - streamInfo.HadAds[url] = true; - streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - // Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early. - // Deferred to after backup obtained to reduce slowdown. Midrolls are futile. - if (OPT_MODE_NOTIFY_ADS_WATCHED && !streamInfo.IsMidroll && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - await tryNotifyAdsWatchedM3U8(textStr); - } - postMessage({ - key: 'UboFoundAdSegment', - isMidroll: streamInfo.IsMidroll, - streamM3u8: textStr - }); - if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { - return ''; - } - // NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form. - // See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request) - try { - if (streamInfo.BackupRegRes != currentResolution) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < encodingsLines.length; i++) { - if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) { - if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION']; - if (res && res == currentResolution) { - streamInfo.BackupRegUrl = encodingsLines[i]; - streamInfo.BackupRegRes = currentResolution; - break; - } - } - } - } - } - } - } - if (streamInfo.BackupRegUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - //console.log('Try use regular resolution failed'); - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - } - } - } catch (err) { - streamInfo.BackupRegRes = null; - streamInfo.BackupRegUrl = null; - console.log('Fetching backup (regular resolution) m3u8 failed'); - console.log(err); - } - // Fetch backup url - try { - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - if (streamInfo.BackupUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - return backupM3u8; - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - } catch (err) { - console.log('Fetching backup m3u8 failed'); - console.log(err); - } - // Backups failed. Return nothing and reload the player (reload required as an empty result will terminate the stream). - console.log('Ad blocking failed. Stream might break.'); - postMessage({key:'UboReloadPlayer'}); - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = null; - return ''; - } - if (streamInfo.HadAds[url]) { - postMessage({key:'UboPauseResumePlayer'}); - streamInfo.HadAds[url] = false; - } - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.BackupRegUrl = null; - streamInfo.BackupRegRes = null; - streamInfo.IsMidroll = false; - streamInfo.HadAds = [];// xxx.m3u8 -> bool (had ads on prev request) - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls[lines[i]] = -1; - if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lines[i - 1])['RESOLUTION']; - if (res) { - streamInfo.Urls[lines[i]] = res; - } - } - streamInfo.HadAds[lines[i]] = false; - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - try { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } catch (err) { - console.log(err); - return 0; - } - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('gql')) { - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - if (OPT_ROLLING_DEVICE_ID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = gql_device_id_rolling; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = gql_device_id_rolling; - } - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(isMidroll, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - } - } - function reloadTwitchPlayer(isSeek, isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - if (isSeek) { - console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered())); - var pos = player.getPosition(); - player.seekTo(0); - player.seekTo(pos); - return; - } - if (isPausePlay) { - player.pause(); - player.play(); - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - hookFetch(); - function onContentLoaded() { - // This stops Twitch from pausing the player when in another tab and an ad shows. - // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 - try { - Object.defineProperty(document, 'visibilityState', { - get() { - return 'visible'; - } - }); - }catch{} - try { - Object.defineProperty(document, 'hidden', { - get() { - return false; - } - }); - }catch{} - var block = e => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }; - document.addEventListener('visibilitychange', block, true); - document.addEventListener('webkitvisibilitychange', block, true); - document.addEventListener('mozvisibilitychange', block, true); - document.addEventListener('hasFocus', block, true); - try { - if (/Firefox/.test(navigator.userAgent)) { - Object.defineProperty(document, 'mozHidden', { - get() { - return false; - } - }); - } else { - Object.defineProperty(document, 'webkitHidden', { - get() { - return false; - } - }); - } - }catch{} - } - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/notify-swap/notify-swap-ublock-origin.js b/notify-swap/notify-swap-ublock-origin.js deleted file mode 100644 index 718f7ca..0000000 --- a/notify-swap/notify-swap-ublock-origin.js +++ /dev/null @@ -1,665 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_ROLLING_DEVICE_ID = true; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false; - scope.OPT_BACKUP_PLAYER_TYPE = 'thunderdome';//'picture-by-picture';'thunderdome'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - scope.gql_device_id_rolling = ''; - // Rolling device id crap... TODO: improve this - var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } - var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; - var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0; - for (var i = 0; i < bs.length; i++) { - scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; - } - scope.gql_device_id_rolling = '1';//temporary - } - declareOptions(window); - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } - else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } - else if (e.data.key == 'UboSeekPlayer') { - reloadTwitchPlayer(true); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - async function processM3U8(url, textStr, realFetch) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - //postMessage({key:'UboHideAdBanner'}); - return textStr; - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (streamInfo.IsLowResNoAds) { - var wasBackupNull = streamInfo.BackupUrl == null; - for (var i = 0; i < 2; i++) { - try { - if (i != 0 && streamInfo.IsMidroll) { - // Doesn't work well with midrolls (often wont see the ad until a few requests in, which creates a reload loop) - continue; - } - let index = i; - var targetUrl = index == 0 ? streamInfo.BackupUrl : null; - if (index != 0 || (streamInfo.BackupUrl == null && !streamInfo.IsRequestingBackup)) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - if (index == 0) { - streamInfo.IsRequestingBackup = true; - } - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - targetUrl = streamM3u8Url; - streamInfo.BackupEncodings[index] = encodingsM3u8; - if (index == 0) { - streamInfo.BackupUrl = streamM3u8Url; - streamInfo.IsRequestingBackup = false; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - if (targetUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(targetUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - if (streamInfo.LastBackupRequestWithoutAds[index] < Date.now() - 1333) { - streamInfo.LastBackupRequestWithoutAds[index] = Date.now(); - streamInfo.NumBackupRequestWithoutAds[index]++; - } - } else { - // TODO: Throttle this. Currently this sends way too many requests. - if (index == 0 && !streamInfo.IsMidroll) { - /*await */tryNotifyAdsWatchedM3U8(backupM3u8); - } - streamInfo.NumBackupRequestWithoutAds[index] = 0; - } - } - } catch (err) { - console.log('Fetching backup m3u8 failed'); - console.log(err); - } - if (wasBackupNull) { - continue; - } - } - if (streamInfo.NumBackupRequestWithoutAds[0] >= 3 || streamInfo.NumBackupRequestWithoutAds[1] >= 4) { - console.log('No more ads ' + streamInfo.NumBackupRequestWithoutAds[0] + ' ' + streamInfo.NumBackupRequestWithoutAds[1]); - postMessage({key:'UboHideAdBanner'}); - streamInfo.SwappedEncodings = streamInfo.BackupEncodings[streamInfo.NumBackupRequestWithoutAds[0] >= 3 ? 0 : 1]; - streamInfo.SwappedEncodingsTime = Date.now(); - streamInfo.IsLowResNoAds = false; - postMessage({key:'UboReloadPlayer'}); - } - if (haveAdTags) { - console.log('Double dipping ads?'); - return ''; - } - } else if (haveAdTags) { - console.log('Found ads, switch to low res'); - streamInfo.IsLowResNoAds = true; - streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - postMessage({key:'UboReloadPlayer'}); - return ''; - } else { - postMessage({key:'UboHideAdBanner'}); - } - return textStr; - } - function hookWorkerFetch() { - console.log('hookWorkerFetch'); - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - url = url.trimEnd(); - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var hasSwappedEncodings = false; - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } else if (streamInfo.SwappedEncodings != null && streamInfo.SwappedEncodingsTime >= Date.now() - 10000) { - encodingsM3u8 = streamInfo.SwappedEncodings; - hasSwappedEncodings = true; - } - var forcedLowRes = false; - var existingStreamInfo = StreamInfos[channelName]; - if (existingStreamInfo.IsLowResNoAds || (!hasSwappedEncodings && streamM3u8.includes(AD_SIGNIFIER))) { - var accessTokenResponse = await getAccessToken(channelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8' + (new URL(url)).search); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var lowResEncodingsM3u8Response = await realFetch(urlInfo.href); - if (lowResEncodingsM3u8Response != null && lowResEncodingsM3u8Response.status === 200) { - var lowResEncodingsM3u8 = await lowResEncodingsM3u8Response.text(); - var lowResLines = lowResEncodingsM3u8.replace('\r', '').split('\n'); - var lowResBestUrl = null; - for (var i = 0; i < lowResLines.length; i++) { - if (lowResLines[i].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lowResLines[i])['RESOLUTION']; - if (res && lowResLines[i + 1].endsWith('.m3u8')) { - // Assumes resolutions are correctly ordered - lowResBestUrl = lowResLines[i + 1]; - break; - } - } - } - if (lowResBestUrl != null) { - var normalEncodingsM3u8 = encodingsM3u8; - var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < normalLines.length - 1; i++) { - if (normalLines[i].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(normalLines[i])['RESOLUTION']; - if (res) { - lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique - normalLines[i + 1] = lowResBestUrl; - } - } - } - encodingsM3u8 = normalLines.join('\r\n'); - } else { - encodingsM3u8 = lowResEncodingsM3u8; - } - forcedLowRes = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - } - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupEncodings = [null,null]; - streamInfo.IsRequestingBackup = false; - streamInfo.NumBackupRequestWithoutAds = [0,0]; - streamInfo.LastBackupRequestWithoutAds = [0,0]; - streamInfo.SwappedEncodings = null; - streamInfo.SwappedEncodingsTime = 0; - streamInfo.IsMidroll = !!streamInfo.IsMidroll; - streamInfo.IsLowResNoAds = forcedLowRes; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - StreamInfosByUrl[lines[i].trimEnd()] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - try { - //console.log(streamM3u8); - if (!streamM3u8 || !streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } catch (err) { - console.log(err); - return 0; - } - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('gql')) { - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - if (OPT_ROLLING_DEVICE_ID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = gql_device_id_rolling; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = gql_device_id_rolling; - } - } - } - } - } - return realFetch.apply(this, arguments); - }; - } - function reloadTwitchPlayer(isSeek, isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - if (isSeek) { - console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered())); - var pos = player.getPosition(); - player.seekTo(0); - player.seekTo(pos); - return; - } - if (isPausePlay) { - player.pause(); - player.play(); - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - hookFetch(); - function onContentLoaded() { - // This stops Twitch from pausing the player when in another tab and an ad shows. - // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 - try { - Object.defineProperty(document, 'visibilityState', { - get() { - return 'visible'; - } - }); - }catch{} - try { - Object.defineProperty(document, 'hidden', { - get() { - return false; - } - }); - }catch{} - var block = e => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }; - document.addEventListener('visibilitychange', block, true); - document.addEventListener('webkitvisibilitychange', block, true); - document.addEventListener('mozvisibilitychange', block, true); - document.addEventListener('hasFocus', block, true); - try { - if (/Firefox/.test(navigator.userAgent)) { - Object.defineProperty(document, 'mozHidden', { - get() { - return false; - } - }); - } else { - Object.defineProperty(document, 'webkitHidden', { - get() { - return false; - } - }); - } - }catch{} - // Hooks for preserving volume / resolution - var keysToCache = [ - 'video-quality', - 'video-muted', - 'volume', - 'lowLatencyModeEnabled',// Low Latency - 'persistenceEnabled',// Mini Player - ]; - var cachedValues = new Map(); - for (var i = 0; i < keysToCache.length; i++) { - cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); - } - var realSetItem = localStorage.setItem; - localStorage.setItem = function(key, value) { - if (cachedValues.has(key)) { - cachedValues.set(key, value); - } - realSetItem.apply(this, arguments); - }; - var realGetItem = localStorage.getItem; - localStorage.getItem = function(key) { - if (cachedValues.has(key)) { - return cachedValues.get(key); - } - return realGetItem.apply(this, arguments); - }; - } - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/notify-swap/notify-swap.user.js b/notify-swap/notify-swap.user.js deleted file mode 100644 index b7df06f..0000000 --- a/notify-swap/notify-swap.user.js +++ /dev/null @@ -1,676 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (notify-swap) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.14 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-swap/notify-swap.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-swap/notify-swap.user.js -// @description Multiple solutions for blocking Twitch ads (notify-swap) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_ROLLING_DEVICE_ID = true; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false; - scope.OPT_BACKUP_PLAYER_TYPE = 'thunderdome';//'picture-by-picture';'thunderdome'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - scope.gql_device_id_rolling = ''; - // Rolling device id crap... TODO: improve this - var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } - var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; - var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0; - for (var i = 0; i < bs.length; i++) { - scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; - } - scope.gql_device_id_rolling = '1';//temporary - } - declareOptions(window); - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } - else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } - else if (e.data.key == 'UboSeekPlayer') { - reloadTwitchPlayer(true); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - async function processM3U8(url, textStr, realFetch) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - //postMessage({key:'UboHideAdBanner'}); - return textStr; - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (streamInfo.IsLowResNoAds) { - var wasBackupNull = streamInfo.BackupUrl == null; - for (var i = 0; i < 2; i++) { - try { - if (i != 0 && streamInfo.IsMidroll) { - // Doesn't work well with midrolls (often wont see the ad until a few requests in, which creates a reload loop) - continue; - } - let index = i; - var targetUrl = index == 0 ? streamInfo.BackupUrl : null; - if (index != 0 || (streamInfo.BackupUrl == null && !streamInfo.IsRequestingBackup)) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - if (index == 0) { - streamInfo.IsRequestingBackup = true; - } - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - targetUrl = streamM3u8Url; - streamInfo.BackupEncodings[index] = encodingsM3u8; - if (index == 0) { - streamInfo.BackupUrl = streamM3u8Url; - streamInfo.IsRequestingBackup = false; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - if (targetUrl != null) { - var backupM3u8 = null; - var backupM3u8Response = await realFetch(targetUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } - if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { - if (streamInfo.LastBackupRequestWithoutAds[index] < Date.now() - 1333) { - streamInfo.LastBackupRequestWithoutAds[index] = Date.now(); - streamInfo.NumBackupRequestWithoutAds[index]++; - } - } else { - // TODO: Throttle this. Currently this sends way too many requests. - if (index == 0 && !streamInfo.IsMidroll) { - /*await */tryNotifyAdsWatchedM3U8(backupM3u8); - } - streamInfo.NumBackupRequestWithoutAds[index] = 0; - } - } - } catch (err) { - console.log('Fetching backup m3u8 failed'); - console.log(err); - } - if (wasBackupNull) { - continue; - } - } - if (streamInfo.NumBackupRequestWithoutAds[0] >= 3 || streamInfo.NumBackupRequestWithoutAds[1] >= 4) { - console.log('No more ads ' + streamInfo.NumBackupRequestWithoutAds[0] + ' ' + streamInfo.NumBackupRequestWithoutAds[1]); - postMessage({key:'UboHideAdBanner'}); - streamInfo.SwappedEncodings = streamInfo.BackupEncodings[streamInfo.NumBackupRequestWithoutAds[0] >= 3 ? 0 : 1]; - streamInfo.SwappedEncodingsTime = Date.now(); - streamInfo.IsLowResNoAds = false; - postMessage({key:'UboReloadPlayer'}); - } - if (haveAdTags) { - console.log('Double dipping ads?'); - return ''; - } - } else if (haveAdTags) { - console.log('Found ads, switch to low res'); - streamInfo.IsLowResNoAds = true; - streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - postMessage({key:'UboReloadPlayer'}); - return ''; - } else { - postMessage({key:'UboHideAdBanner'}); - } - return textStr; - } - function hookWorkerFetch() { - console.log('hookWorkerFetch'); - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - url = url.trimEnd(); - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var hasSwappedEncodings = false; - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } else if (streamInfo.SwappedEncodings != null && streamInfo.SwappedEncodingsTime >= Date.now() - 10000) { - encodingsM3u8 = streamInfo.SwappedEncodings; - hasSwappedEncodings = true; - } - var forcedLowRes = false; - var existingStreamInfo = StreamInfos[channelName]; - if (existingStreamInfo.IsLowResNoAds || (!hasSwappedEncodings && streamM3u8.includes(AD_SIGNIFIER))) { - var accessTokenResponse = await getAccessToken(channelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8' + (new URL(url)).search); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var lowResEncodingsM3u8Response = await realFetch(urlInfo.href); - if (lowResEncodingsM3u8Response != null && lowResEncodingsM3u8Response.status === 200) { - var lowResEncodingsM3u8 = await lowResEncodingsM3u8Response.text(); - var lowResLines = lowResEncodingsM3u8.replace('\r', '').split('\n'); - var lowResBestUrl = null; - for (var i = 0; i < lowResLines.length; i++) { - if (lowResLines[i].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lowResLines[i])['RESOLUTION']; - if (res && lowResLines[i + 1].endsWith('.m3u8')) { - // Assumes resolutions are correctly ordered - lowResBestUrl = lowResLines[i + 1]; - break; - } - } - } - if (lowResBestUrl != null) { - var normalEncodingsM3u8 = encodingsM3u8; - var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < normalLines.length - 1; i++) { - if (normalLines[i].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(normalLines[i])['RESOLUTION']; - if (res) { - lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique - normalLines[i + 1] = lowResBestUrl; - } - } - } - encodingsM3u8 = normalLines.join('\r\n'); - } else { - encodingsM3u8 = lowResEncodingsM3u8; - } - forcedLowRes = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - } - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupEncodings = [null,null]; - streamInfo.IsRequestingBackup = false; - streamInfo.NumBackupRequestWithoutAds = [0,0]; - streamInfo.LastBackupRequestWithoutAds = [0,0]; - streamInfo.SwappedEncodings = null; - streamInfo.SwappedEncodingsTime = 0; - streamInfo.IsMidroll = !!streamInfo.IsMidroll; - streamInfo.IsLowResNoAds = forcedLowRes; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - StreamInfosByUrl[lines[i].trimEnd()] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - try { - //console.log(streamM3u8); - if (!streamM3u8 || !streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } catch (err) { - console.log(err); - return 0; - } - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('gql')) { - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - if (OPT_ROLLING_DEVICE_ID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = gql_device_id_rolling; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = gql_device_id_rolling; - } - } - } - } - } - return realFetch.apply(this, arguments); - }; - } - function reloadTwitchPlayer(isSeek, isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - if (isSeek) { - console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered())); - var pos = player.getPosition(); - player.seekTo(0); - player.seekTo(pos); - return; - } - if (isPausePlay) { - player.pause(); - player.play(); - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - hookFetch(); - function onContentLoaded() { - // This stops Twitch from pausing the player when in another tab and an ad shows. - // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 - try { - Object.defineProperty(document, 'visibilityState', { - get() { - return 'visible'; - } - }); - }catch{} - try { - Object.defineProperty(document, 'hidden', { - get() { - return false; - } - }); - }catch{} - var block = e => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }; - document.addEventListener('visibilitychange', block, true); - document.addEventListener('webkitvisibilitychange', block, true); - document.addEventListener('mozvisibilitychange', block, true); - document.addEventListener('hasFocus', block, true); - try { - if (/Firefox/.test(navigator.userAgent)) { - Object.defineProperty(document, 'mozHidden', { - get() { - return false; - } - }); - } else { - Object.defineProperty(document, 'webkitHidden', { - get() { - return false; - } - }); - } - }catch{} - // Hooks for preserving volume / resolution - var keysToCache = [ - 'video-quality', - 'video-muted', - 'volume', - 'lowLatencyModeEnabled',// Low Latency - 'persistenceEnabled',// Mini Player - ]; - var cachedValues = new Map(); - for (var i = 0; i < keysToCache.length; i++) { - cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); - } - var realSetItem = localStorage.setItem; - localStorage.setItem = function(key, value) { - if (cachedValues.has(key)) { - cachedValues.set(key, value); - } - realSetItem.apply(this, arguments); - }; - var realGetItem = localStorage.getItem; - localStorage.getItem = function(key) { - if (cachedValues.has(key)) { - return cachedValues.get(key); - } - return realGetItem.apply(this, arguments); - }; - } - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/strip-alt/strip-alt-ublock-origin.js b/strip-alt/strip-alt-ublock-origin.js deleted file mode 100644 index 648372d..0000000 --- a/strip-alt/strip-alt-ublock-origin.js +++ /dev/null @@ -1,194 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${pushSegUrlInfo.toString()} - AD_SIGNIFIER = 'stitched-ad'; - LIVE_SIGNIFIER = ',live'; - IsMidroll = false; - HasAd = false; - StreamUrlCache = []; - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - if (e.data.resetPlayer) { - // There's some audio sync issues from the replaced segments. Resetting the player should hopefully fix this. - resetTwitchPlayer(); - console.log('[strip-alt] Reset player'); - } - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function pushSegUrlInfo(segUrl, isLive) { - var segInfo = { - expireDate: new Date(Date.now() + 120000), - isAd: !isLive, - url: segUrl - }; - StreamUrlCache[segUrl] = segInfo; - return segInfo; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var dateNow = new Date(); - for (const [segUrl, segUrlInfo] of Object.entries(StreamUrlCache)) { - if (segUrlInfo.expireDate < dateNow) { - delete StreamUrlCache[segUrl]; - } - } - // FIXME: Twitch ad banner issues. Maybe detect and remove from DOM? - // FIXME: Sometimes freezes after midroll? - // NOTE: Midroll might invoke player-by-picture player? Might need to change MIDROLL to PREROLL? - IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - var lines = textStr.replace('\r', '').split('\n'); - var isLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - var replaceTags = ['X-TV-TWITCH-AD-URL', 'X-TV-TWITCH-AD-CLICK-TRACKING-URL']; - for (var j = 0; j < replaceTags.length; j++) { - var adTag = replaceTags[j] + '="'; - var adTagIndex = line.indexOf(adTag); - var adTagEndIndex = line.indexOf('"', adTagIndex + adTag.length); - line = line.substring(0, adTagIndex) + adTag + 'http://twitch.tv' + line.substring(adTagEndIndex); - } - lines[i] = line; - } else if (line.startsWith('#EXTINF') && lines.length > i + 1) { - isLive = !(pushSegUrlInfo(lines[i + 1], line.includes(LIVE_SIGNIFIER))).isAd; - } else if (line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { - if ((pushSegUrlInfo(line.substring(line.indexOf(':') + 1), isLive || !IsMidroll)).isAd) { - console.log('[strip-alt] Removing prefetch url');// NOTE: This currently strips some legit prefetch urls (might invalidate low latency). Preroll shouldn't have a prefetch ad, assume live segment to avoid 2 second delay on stream starting. - } - } else if (line.startsWith('#EXT-X-DISCONTINUITY')) { - isLive = false; - } - } - textStr = lines.join('\n'); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('.ts')) { - var segUrlInfo = StreamUrlCache[url]; - if (segUrlInfo && segUrlInfo.isAd) { - url = 'data:image/png;base64,I0VYVE0zVQojRVhULVgtVkVSU0lPTjozCiNFWFQtWC1UQVJHRVREVVJBVElPTjo2CiNFWFQtWC1NRURJQS1TRVFVRU5DRToxNzY0NwojRVhULVgtVFdJVENILUVMQVBTRUQtU0VDUzozNDY2Ni4xMTcKI0VYVC1YLVRXSVRDSC1UT1RBTC1TRUNTOjM0Njk4LjExNwojRVhULVgtREFURVJBTkdFOklEPSJzb3VyY2UtMTYxMzMzODM4NiIsQ0xBU1M9InR3aXRjaC1zdHJlYW0tc291cmNlIixTVEFSVC1EQVRFPSIyMDIxLTAyLTE0VDIxOjMzOjA2LjUzNloiLEVORC1PTi1ORVhUPVlFUyxYLVRWLVRXSVRDSC1TVFJFQU0tU09VUkNFPSJsaXZlIgojRVhULVgtREFURVJBTkdFOklEPSJ0cmlnZ2VyLTE2MTMzMzgzODIiLENMQVNTPSJ0d2l0Y2gtdHJpZ2dlciIsU1RBUlQtREFURT0iMjAyMS0wMi0xNFQyMTozMzowMi43MzZaIixFTkQtT04tTkVYVD1ZRVMsWC1UVi1UV0lUQ0gtVFJJR0dFUi1VUkw9Imh0dHBzOi8vdmlkZW8td2VhdmVyLmxocjAzLmhscy50dHZudy5uZXQvdHJpZ2dlci9DdjhFZmtQT29POHBCRkxpeEhpVzVzQkh0ajF3VWR0SnhMc2RRZFlxaHZIakpvY05HaTlxeExQbEowNDNrRS03UmtaQWxUZUVWbi1mVVE1ZHJ5RVFFVVhDbTJYZWFtZk1XbHY4aDAxcDlVam1wbEpQWXNqbzRjRzRlaWJRakhBQkJTbkdfMWtCS25YdEUtc0ljZTlsZXdKSlZYdmRsN19FM2gyYmpCMVpWVU5KT29DNzFvLXpFZFRvNUszX2RQcVhKWDE5Y2lpMEZ5VnQ3dVZEaklKYzNVMGhrYmM0cGVOaDRZbEVkUVlkSWE3OTFQWDdfTGJDZmJkdWdTUXFrNXhLX2NUNlpHTE8yWDNVUU9lTDhSTWRlVkpIVllWUDVxYmQyNWZ4MzlqcWRsTTBLeEJRS1lVVk9iWmprTEtQd3RWMEpQeFFzZ0dFSVRZb2hKMm1KV29UTUktQ01rQTRPTDhpSTZZTHB1WmVneUVBeGRaUERzMUlucWFhSVpTUUlxUl9HOGZJYXZvWUVoa3BwRDNpN1NnaDhKaThaQ253d3MtZ0ZHUnRvRVhWSFZPZlZjZHEtQThmZURMNGZJNDlrS0xtSy12Tkc4VTNvU3ZQbFN2LWx5eHlxYnZNMk83blBPZDhSUFpoNUgxSmVmWDZDbENpUmNXYm95Qk9NcXZ2RGw4OERqbG1faTdXdmNHTmlXdjdKMl9tallKdlM3b0d2bUFBVlFaVFNPbUZDVDItaWtsdjZQVDdKZkpTV2h6ajY0SEV2QTVlazdvNWFweExOTy13OWpHbkR1SjJrRTBTblZoVHhXV1dVZm5GVERTYjJJcFgyWmlZMUFhUkt6NDNkYmRnbl9CYUtYR3dlRkdUVHNJOHByVzZQdjJCNE9uakZ0YVh2M1Zzd1VVVDRaVnNGM0E3VHZWM25nSDJrVVAtdFVRcTBJSzZ2YWFKeHFEaXR1c3JDeHk3bUh3ZTJYZWc0X2pPaTdfaTRMVWhKY3VJdU04VzA0WGRXWnhmb0lFVlotdnZBQUU0bDVZbTNKSm12SXhXTG1vZUd2ckVMOEs1R1Q3azhJanZSbG90OXlNeUNYTDNReVYzTWlwa0FvbVNfY2pCS0V2UjMwRGFneHFMWXl1bDZ5NHlrOGdTRWhCemRHcXkwT1ZZM0pvT1pkb2lhT3JuR2d5XzV0RFpCUDdaMjNGZF9LWSIKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZTenBUOGtSckxTbVh6YU5fWTN4dnJnWlF3eUlyYU5WZUxJTk41X2JHNXAyTVdSTU9uOGJsWTQwV2RjT3FMeVZOMHllUFQ2WWNjcENBZEEyRjd3NGVXb081dTRlUHRqM3ZOdjJsZS1QQ0ZrNHhIOEJ6X3VKWVRkZXN4M2tITUQtVkJwc0NDdlQwdUlVc1ZPQTdJd21CNGNWSHUxY2tiRUwzU2pxaGpUS3BqYjRfME1nd3pnZ2hkYzlHRkJ3UTdJN3V0MUlPaVhNbko5RnlLUHd6bGpBV3BsSjlRS0xLeVFHalhmT1dwRG5ReGJFdk5fc3BVakhjNTRHa0t4b3p1bDJGeXVZTEwtZE1PWURUTXl2cGJua3U3NFhJZzBZcFYwLXpDMDd5OGpPTjFJT2RsZTdtbDVpbFFXTFV0MS1QZ2pmOWpBN1RlZEtPX0RsaHYwNUlJdlJueGltU3ktVTQwT0JjZ3BISUo3M3JVaDRBMjdrRjVySnk1R1RfcERjWlRjSkFESjF5N19pekMzNEpyVl9jR2FlbE0waDhSX2VBOEV3ZmRQNHI5dXNHZi13THVPaVRsdGpUY21JVHZPUWo5VE5HX2FkUTEyRUJaQk00VzVSSEtXUDUxLWRnRnRzWmtNWUMwdnVqN0FDNXlscW5Ec01RU285UlZTSklkakdpX2VHT2FuMjhkUGt0RThQM2duemlVeWZsZWdfWTlzVkxDbi1jQzBzSzBKTGN3bW5GRmxUc0xjR01vVFFtQWtLeTZDZGNsMmhLSVFUUVhKQ2FrbjVWc19aQ0JENk9CMS1UN2U5WEhadm5QR251cVo1RUNPaHRmbEI4Xzd2aXdJZHU2SDhMaGg4WkdwVzBXalhNbGltWGpxS1cyN09kWmU1Mi01RHdzNTlybEN1Mm5nUUxBRUwwNjJ0N1A3TWZUZ0NfNmlabDVicmdGRWMtZDFhamZOM2I4V2pVaGYzb1VJWTVRRld2ZDdma0J1UXVjMDJpTlZVemRWblM2Q2lLTG9sY285b3BtNy1MQWdqYWYtVkhaMDZncExGS3BUTXlEWFBWYjFuSjhqdnRKQTNmMHJvVVJacTdrMERfMkx4SkxuQXhsMWNueVZSaWp3ZXAyeDlLc0hIbTVoaEJ2RnJidThLUEF5M3czWG0tWXBiMHR2V3FtOThxM2RyYUVoQ2RsaFZJQmxIZFdCWDVxeEN3b3Rib0dndzBUNXVxUE9ZVUk1b1NSQ1kudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Zaa09EYUxhV2xLRGVzUU54eTlfcDZvbTNLX3BHbWlCcUd4bk8wN0JDaTItN0JEUzlnZENkYXlXeF9JTEhZaTVNWUY0eVNEaTU0RWYxWVhmLTJ2MHYxS244TXpxdll4bFVVR3R6TkJlQmNrc2w3MDJZb0xpY3VJM1l0WHVfOUV6QVdibW4xdWRfUWdMdTItRlV3WXF3NG00TUdvOHh2NUhUZjBJa1JVRk9tZFVWdjNLeUllakc0aGVtQzRoeUNPd1NhRDZZUmotdFEtRm1FSnNXQTQ3ZDZNdHExeDFuV2gyTDZBYWhJOFFVVzQ4T1ozMW9WR3FjYTQxUmZnUVNoSkMzN09XQk1oS2V1OFBQX0tDQkduUzlvaDNZOVNIQWNlYWh6anRDTk9VdFhGUnRyZEtBdV84XzAtMUh6U1h0NDJzYTBIWno2aXJkc25HVmtBTnpwY3lkMDI1UENpUWxQbHNQa2FPSVA4T0FqWXJoQlUydG5rS0tPNnQyWkFnc00yZ21KS0pGVVpab2J3Zi1qYWNXY1JNeXlHVjFQaWlvWWdPRjFvZ0hwdnUzUzE5ZThtdzFEVzQ0WkVYUUtzRWc4b2h6dUZNWEc2REJHZ3RLUzNxam0zeGJjQWFSUFFsVkoxRUp2SWRhQ1VUTGEyVjRLR3BZbUpUdkk2Q0hGcEtodnFnYWhtYUtvTDdqcmZIbzNuWENqRmVwUzBudlc2X1BPWTRFZ0xjNDJiQ3BILVVBMDNXdGxMUEFqeTNZeEVaV1hEdHZlaHRDR2k2ME5CMkNOb2hVclE5UWZfdVhYTlNWT0dMeTAwTmo4Mkh3TEh6WWJwRGZNY3I5QndrS0xVZHlTekpwbmtmVGY0VjRBaEtjb3ByU2N1cXRScUJNUFFmdnRCUGFvemd5V3NHM1BvaE1QXzJOWVdmcEdaMXBfbDA3ZXlrbUhBQXVGbTNCcWs1eEZXdFdYTmhfT0ppRXZWMzlkam1KVk5wOFl6WGRhZUxRODQ2RkQ0WTR1S2NucU9UNnhleFdYSEdaSEF3THlFVW1EVUkycWJzdWR1X0VETEJDWlFJbWNUSWtMc0d1WWtaSHRWZmJ3SkVBTEtJMFFIT2s4dTdWZ0ZFcXhROFNDLXJWZmJjbWJZZjVJalpiYTdoaGdydjNoTDRFempHRWExbnQtSDk3clRURkVoQmdMRkp0RWRVX3hzRENGMlFOVEI4Z0dneWs0OEsxX0w3aTdxYUtUbTgudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZFTURGOUNTR21kYzNzTnRkRDR4d0NXV3hCN3NWNVptdV8waWZ2UF9SeE1rYVBYeVc5YndEcmdSclBqckpLMTc1TXBacVlMVzYxdzRxcEFYRUV0bUh4S1JJZzlzc3RIMnZxb2ZvSWo4TjM1RGxleE5SX3RHYV9XRmZuQ1hSNWhrYmQ3YTRxNjRHWk14cjBtMTYzOHdYdG9FWFlkbHN4N09YTjVVNWwyVG53NTlJOElaa0pvaF9wRHZrTDNRQVVic1Vsc1BPRWNrZzVsWVRVR2FHMnYybTZlNEhYQmo4Y05JcjQ1bEZ4T3J6VTVCeGlqZ3pUeEwwVWFhRXl0TWVyV2IzakVSaVFBZ2R4WkdUNzJ1YUt1XzU1TG9RN0w2czM3ZzNpVTF3SThITHRXWUVOVkpUU3dpRHJiRGdGOUgtb1FUZ0lEVDBiNDhpeS1ZVk05WldhZzZ4OEl1UVI4ZG5haFBMTDFMSDY2Y2xXd2Y2MXk4ZFZSZUdHbUN4X040Y1QyQnVJRmduclN0VEZ0R1M0NXZCM0dWN2RUbWpXZ3lqZFBINEZlUHVrOFZiejJ2bjJQQk8yNlRYOVpnSkZDYUJTQTFGcXFNTHh1bUdKWUZIMzZGUnV4T19FcVpCVDVWQ2xXa21Rb0pFNHFWbm9OQllTeWxGUTBQRHFoeGtLZUd3bUR1Sm94b2UwNW9jWl9YOEFDMUoyZDI3dllmamhYaHFYeWJVeEx4WXk4YVVOR1JsdEs2RDE4OVFGMjhWUGw5U3NZWDRwazE4QVBvel9uRVRWb3hEUFdkVlFtVVlPTTYtckRBR3dpbG1JN3JKUmRkdVE0M1JueXZFSEotWDN0OGlQZ0hzQlR0dUpZaE12UllucVJqVEhwekN1eDc2LTFUSTlyclhHam5nbXY0UXA0YjE5ZUJQVXc2eXVzdWVTeDVMNnY4eGlLUmZ0X3NXSDRjNWlscGJPSWhFbGxXcXhROUJWZ0k4bG9IR3ZoQkE3NzZIRTJ4U09SWklXSUdOSDRvc3VEaW9CX0FGQUEwMlFsdGxRMFUtWlgtU3JYNG45b3YtQjJsTmdWQV8xb3JVYnBCWkI4cmdDSjdmek9MTFRHVnFQckx3cHNVMEhQS3ZFbkgyd1gyRHJORTgwa3ZYbXhzNU9iRURMWGNMQzhUR2RCOHlDV1oweE1iSEVoQlRhSDNfSTM5XzdENzB0bFlGLVlPMEdneTcxUktEVEtlMk52MVNCdDAudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Y4cnFaTFJnRDQtNXVjQnFldW55dU01X1RHNXZOeW5qLXBwX0ZqclAtUEtHemluR1UzQjdqclFWYUhwQTBReXZYWDJ0VzQzbDZEX1hOVlVoN1BtT0l2MjVnbUF1TThheEc2RGh0WEtCR0ptUWlCbzhrZnZxeWZfbW5OYnRlYkRic1c4Zk95cEhkd3BWVHlBV2daanhqczVKaDV1ZWlkUDJOOTZxMWRuM2VRSHZ0V1R0NFE2dWlvSk1pb0FuLUNvQVMyUjR5WThScXlUYkIyMlQ2eUR0SzBHbkpnbEl1WGdKUHB3dXF6ZnRqRmxCVXhvd1VxWE5mU0FreU5zemJsMTV4eW05OExEMXBQZkFEUi1ra25fVFdzeTJtNlBjblh1LWdTQnZ0aWcwYmNGRThiSThjOC14R3REZ0dsWU93YllmandINW94NmFoV0NPSnY1NTRMT19Bc1hvcnU5cGJWLXBlSkUtbGhNMHRVQnJYU2R1eHRBWERwSmtlMzJtX3dwa3FnTDZWLVZyYmFXNS0ycFVSM19DaklCOUJYOXBqWmJKWjdDMVdNZDFPLWJ2SE1IS1psOU1aWmtEYllTVjExRS16X1VpcktxWHBSLVlnZWx5aVA2eVhuUEVlczRlblliYUNDTXFKZmpncGotNURyTkFia2xOU0Qwbm43WFR1UjN5MFpHdkVZT182QU01OTQ4NHlLSllheERKeWFfNnZhU08xc21XYzZ3R3RDdHJfLWNOLS1hczBubHlMcXktU0JnRTZhUHN3ck5qZGo4VUJYUS1iaDMtV2hJaUtnbjlXSVdiR1ZPNlVZaTVyMU9tcTcwdEVvQldoaDZzNVFhN2dBWEpaanVXZ0ZaQnlXQnhEbU00WUpWN1VBUk40MzBtX2hyUzdfTU5XQVQ3ZVpSampQTzFFOGpsMzBvZlJJZ2ZPOU5hZjJ5WUJqWlFHTmN5ZFI2YVlKN0dvYk9sa05oUEpwbk0tSTN2NEtmNmNsU1Q2Vnk5RWtmQ29PYmNBTk1NY003bE1rdmZNSElzbWV2d0FraW1jQWwzQmp5X1lhdTBWdG1pclBFc2lSQVo3LUFobnBWWkR2U0s5b1hEN21EV0hCM0s0TVVkT3RZRzZDZ2RfUHFzZEVaYWY1RXhjdnhDbU9YY3BTelp4YjRDcHlLYW9JR3p0S1RZd0VoRDQ2YWNQR3llcDhCVGRPYTVIbzc1ZUdndzdGek5XZE1DS2ZGaVBBaVkudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjAuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZzRnVJS1dnM3RCcHc5cVVyLUplTl9FdHVuSnlSbm9nbXBrUmMzVkRyUFloS3JscXp2YnB3dlFQdy1oaWxaU1dXeHIxS2xzcjU0bWU1cUJKQzZrbEVmMG9INFRrWXROQ1FWaTBmTE1IUVg2S0l0eXdSanU1QVZXS3EyQVFYVXlwcVozRktVNG5sTjgycUk1Sk90WXh3MTgyS2lMcmhtUXFEQmE3QUl3dTVLRGw0MEtjczhaYzI0dUVZUlp6ZmMwQTJGNjVGWVBURzk2MUFvdkxJc3NTb01OSEdOZkR1S21tN1YtWGxnaUxqdXl0RXlMVW94a2lKbVh2anpzSmZHeGRnNExxRExhaFk4TzVDRl81YXlrU2xuOURCZ2t1SnpjNGdBTmRzdndRdDFNUkZUX0Eta0Z0NEZEQ2dZcmNfN0FEbzNUSy00SHAtemltVm5QUHB5S1JKQ0NFb3YwblhXckI1dFBUQ3RiUDF2ZEpiVk1EOFVNcllWekVqZnZUeVU1cWNQTDkzMzJIcFdsazdhdjhuaU80TlZuSHBsV2Rabkh2Y1JhVVdfSmZldlpMVHhWXy1YSTFyU3M3a1ppdjNBSWptWmI3ZHdRYlozUmd6b1IxNmlSME9lWDZkNU5KSWU4cjZqTUFWTk82czRzTExHbXRDYXJBYWJTLWU1bnVFMGxpMVdnWEMzZ0pxanpDZlJ2NllNQ0FXWWM4ZGdydjdhMmExZWZHd3VycVAzTndMZnRZbldncFVjRGlDNXBmMGFJdl9ITG1SS3VHOUlXWG9rcTRXMVZ1RFdTV25SWWhRaWZFZlJ1NkZZWVZzZjRoNTlvcHRPa01RUUdQdC1OVW04MGxfX2R6ZjVpOTFrNVZ0Wkp1RzJSd2RwemN5cnJPXy16UXNUX1JaQ255amhtZ1RHd3FUVVRYQmNzWXdZTmRSak9Nd2p5X2VfNHljQ1p6bVd1Zm9uY1RjUEppWnpLcG05dG1hVmE4VVozRlhVSVV2dU8wRTRxSzFiU2NVWFZoN0sxZHRtMEdYYk5aLWVyM1BSWGUxVE9QNVM2amtYbE96M01ZTDJTcWNPREI1MjhETXhkQWg2RmR0RFZjNV9oLVJveU5ON3c5UFNJNVJ5MFhQQTBpWlgwZC1XTVhnQXZHRE5pTGZvMEpHbkdrTWd2cWtBN3FHYTFQU0VoQ1BCMl9UZGRwdW1QNENDbmVVLUpxREdnd3lYNFIyVEdFaUZLbXA4MnMudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZOa1JVNU9vWmNIS0FuaHNWS0lZWmgtUm1kSWtXT2REdGRFZ09kTk1qakZNVkhQOFlocE1IYXo0bjBkaTd4SEtqX0dycEFGMHJHSkhLbXdWSHJTaHFQbkFSYzNrbVlseVAwSjRSYkV1ckhuMjFTcEplZjc0NlhpRzhhRUpCWWp1Z3kyMng3YXBxMGRKaFVkNVVBYVRCMHpiYlpEbGptaVd6eThiXzljWGhEVGdmUk5ZY0FtNVVOY0ZZc09KeXgxX0ZvZGtPQkVGWU5NT2tteG5HU2tWV1JiV1ZvQzM0T3VvR200Q3NZbFE3emZ5dnB5YlF4Um9jby1KdnlnMTZLbDdaY2E3MmJEZmZ5aGttVm45ek8tbkE2TkZMZE1odW9BTUFPdnFNYW1McGtraGhkcE94UjJ0dGFtNEs2cWQyTjUzZFNrOWQxTkItb1pnZGlLTkYybHQtcGN3TTVGWTVwcl9uc3pTcm1lUE5CWHVsSVBGNzdmdExSMmhMaERYZlNDUng3TFNKR1VkXzgzejJFNUgtWWV5SHhHRy0yZlRKQ2xDUTY5QUdXX0U2UVhzcHV0Vm9HYXkxT3NuaUZCZm1tOXl2NGlGQUlfeFZza0J3Q1picHlCY2NxTXNrbS0zY0FVNGIyNTRTZWEyQWlPWVczOVFYMWYwMy0ydXRxTmRqV0VQcnpVVTNQbkpGdFd6MDMwYmdfLTBZcFNDOFp5M05aVW1kTWJNRmFEUlQ0dWdaX0hZcjA3UjJOYzV0dzVQdWlPdDMxb1dnMVdXMEpZVDBiOTl1dWdSZXVpQVc2MmFVbzJ6VU5BNThPb0pTUGM2WVVHUm9PMDVTeXp0TFJkMUdBRHNFVEZod0xQeDFtUlhpTU5mM2dnLXRMVjR6UGp0VmF5SWEwdHJDdEJSa0YwMjhDM2ExQnUxeEtDV25WRGhIRzJVaEo3U0JjZzI2YURnaHBPQkQ4LXU0STFPWTNzaWF5VlFHZ2U3NGE4WHJQZnVXaXJaMWtSbXRnMERGVzROSjBtdy1nM3lKbjh3RHpnUGVpVFZDRzVkbFRjUlo1ZEI2OUdiRWxzUHBlS1R1TE9Fd2FjSl9iT0FhSWQ3VE5RWkNWRXloc1lvM2JtcXo4WVE0TEpLRnJ3NUZSYUVDNXc1QTlNaFEycHNwV1NZUC1tWTlzN2dKRHJ6OEVoQTg5M2UxUXhfZ3hSR25Fc1YxenlCdkdnemN4ZGZZVnJyOTVIV2hFeXMudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZtcUxqcXlKbW1sMXMtMTlKTElVakZpem9qWTFSRTJGWnl1cHBlM0NEV1RUMFNGdDdIaXd0dlF2UmxyYnBVbzR0TUVOdm0tYk1iS2dYMi14OERHUFBQS2RWMmpXUk44Y3BfakExcDR6UHlMSnBXMmYxaTlxQVNJMEFvXzVRUWVZVHdqbEhTYkx6ck4yNFhsVmxaQUhiYTRUVzR1ZEdNY2Rqa2Zud2hqUW1kUmY4elZaWHUyeTNFOWNTM1Qwd3RvVTZNOHJ6bWp3VVJsOW1pT28wWC15WlhNeGhubUFXM0xrTlZ4Z3I2cUxHTndSMGFYbU5WVWsyVnlJUzdHby1MZ2RxdkcxWjl0TmE2MlJJYk1WVW01aWRZZUg4eWdjRk0wdTVnbDJMWlFMX19hQUVTYzRURG1DSm05anhsVmtyRHBWbjJYbjgtdzBWcFNGUEcyczViM0QwQTRkcHZWQVY4ZnJmeXFTUE5sQUpJWmlqZ19sZzRtWS1Qa0k3UDI4SmdsUkRTNkpaZ1JYRDNDWjhMc0tUNHRKTk1JTzRBY1JhX25wdlhMd1h1dGJMU19RZ2FBNnl3Sl9wTDdjX29yQnQyVG1UQmIxaEptX0VUMG9yMGJ0ZW8wUmRlNm9CNFFmMUlyTkd5cjhWX3d4bHc5cnFSWFVwa3BkR0lzMXZDOVpSZGRycC1MX3hCTWxBMmJlVVNBSWRqNlhLaHdHbHBKNHJiRDRvcEhnN283cHNkOEl3MklqbUlYU3FqQzNlMk5ZU2FuQ3VXQmRzYUs3QnFLby00RV9mZFVrRmVkbU9GSDV6RFAybW5teEdyUUIzRW1TVTFaR1JiSHNHQnZ5eWNLSmVHeTd4YVZ5ZDdsM2hnUy1rQzdHaG9UQ01ITW9DWm9OcnpYdWowdmRUbGpDOGdwMkFLVE9kV25PQWhsaE1aRGFXcHQ3WnN0aGlfUnB0WjNEbWdOLVdvOWJPMU4xWl9mcTNqNk52eUc2MWh4b1dVQXc0RTd2ZVBha1QzZHZJX0Y1MTUtcGQzejRwQk5vSGlmMlFpYWoyQzlPdHZOQXJpYy1mZ3VrSnJ2SGo4S29aS1o5NnVHRGc1M0pWTVVwWXlwVThHRXNZZ1BoMjJ4eUUtQmNpckNDSk40VTRkTkxYSnBTR3BsTlZ2ZXY4MnMtNWx4VTY4WDJudldSWkVoQmFla0c3b0xSbl9tenhaM3hHZTNHY0dnd0t0U3lUSDZkeHNla0tVRzgudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZNZWhXcXVjRVJVMHptQjA2Tkhvbk1CemxVbkd2MzNtN2tiNjRrQ1JKUmhxNTl6ZFVDQTNqc05IMGs3MlNDcC1oVjN0enI1OVZhMDJEUE9vdmlGQk1DcGk3aVRTUjMwSkM5eUVzUjVsNG5BdllFQ3ctVzF0QldWdlJnYTBGOUlRZ1Y4LVRwczltSEoyOXoza0d1U2ZfRDZBa0liaVJfNlRIQmRKZHhKeGpvaVBSaEJUSkpReXM0UXhGbjJpeU1DWlVVSmtVMkNYdHNYUkQ2aWwwb00yeXJ1dF9Ec3J5bmRSUUdVQVU5QlZzSURMTkxvUWtRZkFfUG5hMEJzeEFFY3JjWTZoWnRISmhOeGFPWHFkczdyb2lEWF9BQlhxQjJteWU1MVgxQXl1VEZydDRpeFFXY2ZSaTVuaHA0VjNaaDRvTTZ5aTh3R3VvUXYxbjRyMFhUd3FiX1FyVUtiNUJWRVJxek5EV04wZTZWMkFSOVI4VHlDczlmQXh0dEZ0Z3gzY3lhM0FJeEFFcUhqcEUweGY5dUFTdzVQbkV5MEUwWEgyNzRFS2ZnVW1VVTMyY1dxOW9PNWphbl9ENG9pdzQ3ekZEQXA1NTFWcllJVXRjY0FNMGFLUnF5SGo4TDhYeXV6V1B4SmZMM1lUVWl0dzRfZ01qWXAtUUNqYXRneVgyZHNNSjExUzQtc01obW5HS2hlSnpJZEJ4U0J1bzJSc09SbmR3ZUVSMEllNFc4c09oeEFIRXRZM1ZBMVVWdzlIREFhSVRWWEd2cXFFRW9lN1REY3k3d0VzdHBybWR5MllSRzlQZzltVjl0UGR0R0p3WHBabnctbFl0NFVBQUJNUjBWZ3NhUjk2UTRqYzlQb01uZ2ZfWE5kN196amxwa3RoekptY1FfZG9mOUxLRjBUdERINDAyZ1JoTzFKaUJiR1I3LUM0SW9QUGNUdy1NMW01TGF6VkdHWjlHX1VfUTVoSVg3VTJMY183N2Q5TXNTYXduR3A2MjlRU3ZTMkFjdmw0ZE5sTUpJeGl5X3Q0clJsTU50NFprejlESlJfbVBMX0J3QkZsMEd1UTByQkxsZXR2SWl1eEZ3NGNlYVVIaWFDdzZaMGJGN21oQXU3TVZoWnQyT1NMN3M1SmJCOXhPSFZJdVRxQnJJbl83SGpGYjBHNy1uQk5PcGNFMkVoRFFFQ1VJVzFXX180cXFsYUNYRTc4eEdneWNIZUNSOFlCR1VFQjJBSmcudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0YwdnJBXzJOdzBMejNWV2h5S3QwVVd5ZmtxUE1JVUh2X0FNUDRfV1lnMXNtdWJ1U1JGTlV1eFZzX2dqTnZYVi12d3lRa0lpVEl2dGluSlN4V1kxQWZYQ3E1RkpzWnhpTkV2Q3F4ekNicXpVbktzSU9oNXZmNXZuRno1ekhmN2JGNGJnbHA4WHJEQTRQNTZvV3VOY1NfNDlhZ0lHWW9sT1NCQjFkX2JJN09sT3V5SDVaN2l4N1ozXzdVeHJYQTVXUjc3ZVpfVW04SV9MbE11UWs4RWNtX2w4UzFUMzlmS3haNHkzMUZDOU9xUTBDampITndieXVkX2xVNldRa3NoUktCaHBxNjNMY2ZsMkxuQzkyMmhGVExDb0tIMlNvTnJBMHNTQ3dGWU16S1RKY0U0ck1YN1NRSEt4RDJ4X2M1VkdSWmk2Sk1hMVJ4Q2M4UURsaWVzZTlHRFNCZll3VjladzVlRWFub0lpNnZiVHlEV3FGNWEyUHpCX2NzREZCdVljaU5MS1dONElKd3ZRZUU1Mk4yY2hoUGlyYzJ3b2hyZjBwdzA0STI4MUdtd2xrR3doYmVnLWhDV3RVTWhwcFFWUHI2NWN2QmpuSzRVVm9vTmx5djdCSFpOdUJrXzJTREM2cmdTVHlDZnp1OFdTMG1Rd0NDX1BEMWxlbm5TZ0p3aGc5Ml9ObDlmb2tkV3E1em5BMEM5WGpEcXZjR0VtNWd5YXJlTkk3cEZTb2F2VW5lZkEtYU80SjJMVWs0dUVtVTVNbEtfR2FZcFhad3A1cU1ELWdHWTI5Mk93Ylpzb0x4SVpaREdSVzVsM25YY0ZFNXZzWk10RW55NTlYQVUxTkxMQVMycVFkYWpYbXFoUFY0d2lndEYxdEMwaDItZHhyb3Y2aDExLU5teHFjRmNvaElMNXN0QXdkZmdpcFB4NS00Y1BPcW9SVjZHY1M3c0RLQ1BUTDlaUEowZElHUHhzSTN5ZThVOUcxZUNlUnN3OC13TGl5aDM0dzhUMmQ3aHZ3UkhRZFBQalFEZWVHRkpMeHdxRHh3RDBQNzE4MHVkRnlJaXU1MXJzdWdfTjdfRGl2cHpMb0U5QzFrY1dVcnhra2dmTEFiLTUweUhYUm5QRl83eVZsOV9fdmNjX015NXpsUEVHYlpiMWRLZkxGWk1GX2k4ZUNlS3FtOEVoQXFSdm9fSWNWZG53V015c3pMNmNTdUdneWxyVVdQZHU4T2RfdC1tNEUudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzAuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Z5anRXVTZpcFdtMkNiZ21SNlRsdU04WG9QMllrWXNlUUNLdVdjQXljMnkzS0RneFBUN0pOTU9mdUJzS3NNcFQyRHdDbnNNTlJYTnZxTXBuRDQ4eGU4SjdZWTF6MndtSjBCTnVuNk8tZHRycEtsaF9RelRkQl9lajFaZnlJSXBHa2hNVUhhX1ozbFlzZldEanZGYm45a3BSQVVwMDlxX25uZm02QUpSeDh0NHRfWFM0TkcxZWl1MEMtSm5MX2lKanNPNS1Kb3BZU1Y3Nk5peXBEbTd0U2RiWHNMNWdIOGFwRGtjSzgtUC1pbEdnWkw2ZEF5dlpxRHBPTEpZMEVqWnhWeW9zNm1KdFZ4NXdCdHFJdDk4Z01rdFVMdThiQXpISFdNVkRzYVpzVGV1ejNEdEVLSnpNZmJpZXI2VnQ2eERHOUZnalNaVlFKLUFjR2RmUUR0bnV0RUNjVlE0ekJLMXN4U0VNQ0Z1UmVCekp0Xy1DLWhEYlpYc0hGN3NUT0I4dlpkTlN0OVNGbmNrNWEyNEJtd20yUDBsRWZRTDFFTUhubmRuN0UxVFg0OU0wTE1kaTdBanlWSHR3SkpldHQ5M1BZbC13dkNtaXVBNHVqdXoxZXBjWmhJRG9IdHhJRUdoVFNvMmxQQklBUl9wUDM0QkpBeUV5YkVzRUVBU3FlRXVacThXanVmcmRiblBISzZEXzRHS1RzSmJqTlV5ci1HOE1udlItU29EZnhzcDluMXNTeGRVUWZSR1NGc2lNN1J3dU5UMHJYZFd4Tnk0blc2TkxBcGYtQlZFMUxuMEx3VzJIenYyQWNFWDFZeEdMQmYwRHdtWnRxNDVZdmEwU1pCc29EVnJVeHoxV2xhTk14emFpUW9oVXVpazJBVldxTWcxVkI4MDVnNTU2Uy1mYmllZXR2WlFHa0dsRFZzMmxwbUZaWjF1TktPU1JMZTlYR2txQm90TmZmY0V4eThhTUJNOGE2aTFQMF9jTEFzZ1l2Wkt4WUQ3cWRFbHZ1aXdTc1duLWRwUjE1cjFuZkI4cWxUcmpXVk43NUNMa3g2MUZYelVKREhHb3FaTS1xZDQzZ3VBVGY5N2dKTEs4QnY2ai1aWU51Y05NYlVabWlQdGxZU1p0Z0ZPNmFWRzhUOC02OGJZSFh4dHRHc1NHaDhyYndWcmx2S0VHR0VoQk05TDZVQ3B6dE5QenExeTlZWDhWekdneTZReDN4LXNBVUNuQnptd3MudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZkQ1Flc0ZNYi0xQVZfdzBSSl92TjlJRXJrclYtMWprb0NQSTJMZkNJT1RrcEl6dFlaTWhvWXdxQllKUGpkcGwxVjhRalFQMVZoUE9YV0FuZF9fT2c5bXlCUW5QcjNEbEJ6Sy1GVHk1SGtoX0dQQjUta0IwNkxoZmF6bjhDT1RqY3cwbWEycEVOeVdCMWpUQ2poS1FBa0RFODlvWWlZcEt1NFhESVR1QTloQXY5MDJUdVhSMWFST2E3M3V1ZHkzNjRxZjR2Y0o0WF9FSHItZTFSSFUwUFctdXFUX2JlRXdGNEdoajZWblZxbE5USGhCcGRRMDdwZ0hiMEFoQkhMd2toRm12MTFqZ1U2cEU0Qmo4YkJEVDZ4bTRQMVhuOFhJWlg3blYyd2lYNy15anp4b0ViejRaUUtpeG9KVEhzOVFtOE82RkFSdVRQelJTaHhkZnJpbXhtNDlzYXlaNzluM1pjMnlQVXpZN1c5Y1NNYXVHR3A1b3lOdXpFbGNVWTJ1QVdqdDVyOFJBRlVIZndGVFp3ZjhCQ240ZWJuVTFTLV83b3BXakpRbngtOXhOYXRLcjZoX3dSYUt0aDlKOHZuOV9LOHktTk13aXpaQU9OS3lVMFIxMGpaLWhOazdwTlRxVEZYejBadXY2SVN4S0lDbTVLUUZLMHVScmFLWGY0RTlvZ3Z1Z25wWVFPbEp5MDdXblFsQjBxQlNIZGVKY2wzei0wSTU1R2c1Y0ExMTl4NkJpM3hVT0lRRURNbFlPWmJHanlPM1FtcGhab1JYUEVYaFRVUUFxeU5FQURfOG9sNjZuRlA1dkhpX0dRaHNwc0FjbUJiVGczZGdKLVpVZ290c2ktQmJoNHljTUpWckJpS2VkdDV2N2t3RmUxSWZKel9WS2IzVTQyc0tRd1laTEZ3MGNsUzhoS1hYX2ZIRUVabXVIRzE1TGR1c01GM1p1YTZpQm9wT2djQUtTcFRiX1A1WWpVekJfekVxQUVHdzl1Vkh3UTBqa2tSa3YtMlBnZk9EV0FDcU9fVkFZc3Uwa1RvbEt3TEdaVEE2YWxkbUxpeGtkTkdGV2h6bGhaT2NXb2tRVHE5QVNwUk5hQ21sdkdZVWtMaGpPWWk3TGt6V0tfWkxzUnpwZVk0WWxyczFvMHF3QzhtSTFZZHdtbi1iODMybUVucmNkbkVoQ0NGTzYyRWtTVDIxWVJWdE9heDg2eUdneUhPNW1HdE85NjNfMDROcGcudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZIV1FHaHZNQ2Y0QXlkRDN2QWZnWkFFTmgzRm1CWWhQa3ZIN0ktLVBCaXVUZnp1cnB6cG1NaXY3Ym9RdXpxQjJzbFFyZk9ESzNiMnVYZVA0N3YxWWpVdnBMNV9XZXdadlZlLUpoQUY3TjhDcjJ0Vi1ZVG03elRma1FSRVVGeFVMLUU0RHUyVk9tcjB5OWhINkRlSGpiME55MGJ6OFhiTWN4Uk9GMW03S1BJN2oybTE3anlmV0VkRV9oNnBPbjUyWXZvYVZIRzVrSGNDc0lxTmltZVN6Q0NCOHZLYkd4ZFFrUEF4dzlkRzQ0QklzcWYwWnVYYXJKbm5iMTNVblp6TG1pdlZxRF9hVWo3dDM1aG9seE9zSHI3QVg2VzQ5aGk4REVQYzllaVd4a016S3F2aEhMTXNBNVBCcU1VaS1VS0N0Z3M4STdWTVdkRDBlUnMyOEl4WHFQOFRqVmVzQkdrSkZ0N1M3TGc4NTNpczRlNjhaOVNRZWhUMS00V2dLejR5dEhzTElwYXdDU0lqV3RGa3o0NXRiMHowLVpHVFoxdDJUU1lWN3hQUmlwWUhKWEFHN3ZVcFd1OGRfa3BkTm5hUW1FYzZFdjBwbjBhSDdYVFdtbEpQek9MMGJCVHJ0Nm5yUko3N0h3SDVoOW14b0x4UHNQN0hRRXN4cWNDUGNvY2RjcWxYWkhMUzlYaHpobmEwSDFHclJvb0dCbWNRcENELTdYeHdCSlBpN3FicVVlR2RMMFpzanFGRmp0bVEwd2FSN3VhSTBBX0ZsdlA2eDdwdnZnWjh1c2JCZFpmNlZXXy04QkpsbWp1MURVN29UNEo0bmtxME53M0xBaDJ0b0treXlrVUlFWFlSZWlxTVp1SzV6UzZZaW9wTWJKOWdtSE45VnQtVGpfZmx5ZWFWN3diR20tNGx3TjdSY3ZMQjNISnp2X3dyYVhEWlVKdXN2UHlUc3hFcHh3RkRCTUxYcnEzVm5kS1A0SFY3Q3NtaWpobTRXYjRNTEFqajMtVnd4YzZ0Z05OVGVOTlhPNVltUHpQNV9rNGRqRFZ0MHNKNkZrT1VPbWtLcmFQamctaDF0QzJxTkQ1czBsZFhvVDNZYjJMZ3EyWWVHcE5XclRWWmpyNHpwaTYzS3NrR2NvZ1BVRUNNSi1Cd1ZtMTlGQ3E0MElIMnVvTkZKaUVoQWdQUkl4cFF2QUZGNFdndVB4WTBvakdneUpFTC1FSHYyN1czX3EtY0UudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Y0eFl2UGxCRUUxTHNwVVF6aGlWQlhGM2NfMlVkSE5CX3cxdEg1blNiTEIxWWswZmx2eVFwMDU1WWhGdFpVM3g5M3ZBU3NXTUV1WlFvRkFrWGF2NEZlNXdNWTYtYkNBOTlqRXFCOGJRcm9RUERuWXJtbGtvYkFwbGh4Z0IxQ1BIcjZDSzlxSV9Pb0hXVlM0STJ1NmdlRERrV1R2ekdWSlM0QXE2MlR5SXM0bm9xNFJ3Q0xWb2JvY3gtS3VtMVdOcmVFT2J0NHdSdUIwV0ZneFNQNjRNaG5DSGlVcnlMRnMtekJYbHRpc0EzMmFhckFYSlhadU5idGNzcHNoelhoQ1JJN1poSWZSbm14cUVuTXY5cWYtM1pqZnY3OHFjcTA1dTJ0ZzFxcVNkMG9sSnc0c1puT08zaV8ySVZjTEFKUDA5RnNPZmd6SFBBTXJRMXdrbGw4OFVsUnduSGlTMEw2SFlhZ1hOOHlSUzdZQ3pMRjRtMVVHMW1qZ3M3TmNsRm1xVGJUTTYtSGpKSGlzMmZQWUg4YTctTER1WXJFdHBGcDdxcmZDZ1NVc2p5QjM1NkEzMHc2c2l5eEctQVN1S2hiSWJjaXA3UEN0ZGxQenBjeFdQZGxCeEwzRnN5cHR3d3k0NWd1OE15dzJnQUJuTmxhUWdheE50ZUhIbkZWaDR5UjZtZG9jZzFmNTgxVWY4YUk1dGo0Sk5aZ0dIQ0Q2aERFejJGZU1LLXJwQUxDLUhwRzZiVHYyYkRfSlpkRi1XZUZhS3ZyRGh4b2hDbzJuYlY3ZXZLOGsxTkpocVhvRmtlcVRCSklWeFFTRjdqano2alhWcmZwRERmazRhOEcyYTVTelg5cGtEMmdlXzg3UG5WbWdZQWVTa1R6TGFSdVdzNjNmYTQ3OG4xUUpVQ1lvR2h4SjVRSzFxclF4YzdiNE5EZW5oZjJaSDI5blZKVmRBRFZsd1lZcWlWalJHVFFGeWxES2tUOWsyampkZlc1LTVZSGlKS1oyRHZ0Yk8zZm5qU2FHY3ozTTRKd1ppTFBQSUFibEtUaWZnOU1QaTFnYUZra1ZyZFhFeGtUNjNxNTdROHNZOW9JR3VOSVpHMGpBNmc0ODNvTDRpT1lTRDVnbGMwNUkyU3hBWUpDUG95dmdKczYzVlhkVjBfWjg2N3I0N3ZlbXFUNmNUaEVoQV96UmlhUmtJMHp3Q0E5NjF6ZXI3RkdneXNFTlhIcHlCdkVnLVpaTVEudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Z3ZlI1NDd3TWk5SXJtWmI2UjJpS21SWnExWDQ1VUZNSnNHU0Q3bmZma3FmN2ltYjAtdnUxcjJ0djdfeFY4QnNBcUxMMnRkdVpHak1HbDNzSk4wWVhWQ2ZfQkcyWWIxU2NXaUpfOWttN0FTS0pSLUhwTzUyR2NQaFRYX1NLRG9ZY243OW5QWG5wWEtIdkhsSW1BZFVLWTNjdXkzRlpDeHJ2R0lkQTM1TmwwaVhwVjFqLTRQQmN1ZVJYYkZ5Z0YwZWY0LUVKZWdyY2Q5bzhSeUN4WXRzeDJ5NWRIR3l2blJ0VGE4M01VaEpnSklFbmd1SFVKS014NDFjSzVXV2pLbW10QXhMS2tSc3VYeGxCN0lQVWdOaE9qRWFORzFjeDRGY01MT2p2b0FpSDM4cHZWOHhEMTU4cHhuVVNQWjk3VnlkX1BmaGRZNjZxS19MTjR3VU5xYW9HS1pvRG96TWprSHVzN3NSYjFLNUxXUEZxVndSOWU4eTdmdnN0M2tjNjljTS1aNkt2NGU2SnZsSjQ4OWJTZXhKUUplN0FTUjRYNkR1YjhUTHpkTGN0VVBuMEFuUjE5MzMyMUJxV0NkLXV1c2wwVVdYMUdyZ2k2ampMdFp1ZkdESHVvY0J6YVFrX1FFV3hhRnBpb2twdUJaWjlHWC1XbkVRVkl2cVFGZXlUdjRRbjlJR0RCSkgzd3hXTEZPMUl5MDU1N0xQLXEyUUJqQldyTVQ3WVlmbWZyQ0tZOGdxQ2dBY2VFb2g1V2NsUVBxc2hubFg3X0JrVUhSbzVJVDJ0OXZFd3ByNnpzT04zNl9XSjl4YmpNYy1rdTh0VlhvMmlDN0Rscy1fYkk2NHF1XzVOUGhELUtCaDlyLWJ6X3Z4LWtuSkwtTndYeHZidWJUTGplY2RkTmlib1U5a3FBVDU3R2xZTm83RnA1UVBVRkFvYW5OVXdGaGgxdUI5TDlDcURUUWFRZF9nejhuLWswMmhPNHh3Wno2LWl1aVNaMFVlb1F3WDA4MGlrdTZGVUZFVEdBUjNKVjdibDlLbl9hMmFKRmdUX292b0k0NmFoRWFwbkJpZUdqVEJpbnA1MGQ0WnZVa3VrTmg3bUJSZ093ejdyUEEwdUwzczJVbEpNTW9NV09VaDliRnZnN2tlYWVpeGZGRC1VWi1BUmlUYWlVM3N4TlBNM0VoQm5ZYmdfRGZsUDhkVnhCdnRpcl9GX0dneTl4a25RWVBzSERRUFc3STAudHMKI0VYVC1YLVRXSVRDSC1QUkVGRVRDSDpodHRwczovL3ZpZGVvLWVkZ2UtYzY4ODRjLmxocjAzLmFicy5obHMudHR2bncubmV0L3YxL3NlZ21lbnQvQ3BjRnNsaExtamd1OFo1R2RfUnpJMG1nSllLWHBLVmpXd250VEFWM1dDN0xFVldKeFFUZ1NBdVdNc0ZaRXVHb01XQmdCbFZMdUg0TlhEUzVWb1ZibWFwbkpQS2JjSTdSbThaZUFuV0lGNndDcVFuQmowMkZfcWZ1MUFEd3ZKVmw2YTYzRDhIVU93RjZ2QzdYRTBVMXpLZVdQanVIdUItU3IybDFqMl9FcnVKa2xVLUh1TGhQNTM5a19GaUFvTkRFVXVRdGtNalQtTDBUeUlHdmdTZFBldHNPdDRWT1Q3eHZTNjFCaHBHa2NrNmhZRHM3SkRwakpPYnZlYVpxNjIwcnNRcFk0X3BNRmxYLVRaVDgwZlNuSnRaZzlMYnBGd3BvVHVpOXJzSmVkalVvV3BqMFk1ekhkWDRPZm9zaUF1elZ6OTZTSWpCcXFNU3dzcWJxbzZwZ0JOQ2xZNk5LelRFdG5hV2UwdG96ODNsNXgzYVBrQ29KLVhKTm5DZEdHWjNXZ3dQa2NhaHFGOXlpSENFZEIzVVRnOTJEamx4U1lDSkNITTVFY2tGV3YtZlVoV0JSb285cGdfRXlfRlplR2xaQy15QVJYbFhBTlVuS2NpMW05T3FBQnB2c3R3NndIMFA1QnhFWlBocDJ1WDFDeEVaalNLYnlDenBrYjR4R1dwR3ZUSGJlcWxfdFBaS2Z2Uy15UnFHMkRPRGpud2ZrM3dmdVhibDdxeXl1SkRHRU1haVFPVGNJeldXcjNLaXJ4SDZGUy1hQzlMTjdZdWFoLVlsUmpOZzNUT0YxMG9EcWd4ZUhHb21YN1I2MEpnM0tnTTVtbmlCbEQwQklVcU8xdFJseWxmZlNDbm1QbVUxaHZpdkU4M2ZiVUZjUWFidDFIako5OVZ1VXIzUng2WnZuU3ViZWdZZjlvYTV1VWh6NWNRckxHZGlCeUxWUkdKWk91bXVoMXE1Tmk3MVdic0dUWmU4NFZHUmxUaWpuQzU4YXpZeGhvX1RSWl9fcDRITkZubkhBLUFUQjRBQVhMb1AwMWZiNHZMWGVvdmhQWG5QM3Nranp6RHJDS21hNGNMdkFiczlzYzdyVUJYQ1pjbmhQR09KVHhhcTJWd2wyai1hdmRzRkM1Z0J4QjFKOWE4R0R6dERGN21RVmVKQkRzNFFmVGNpdDltN3V6cTN0RWhEdGc1Y1NjTmlsWnVYOE94bE05M054R2d6SmhQczBhTmJpZXZlU3Bway50cwojRVhULVgtVFdJVENILVBSRUZFVENIOmh0dHBzOi8vdmlkZW8tZWRnZS1jNjg4NGMubGhyMDMuYWJzLmhscy50dHZudy5uZXQvdjEvc2VnbWVudC9DcGNGOTV4VjY1QnVlLTVjWDdVNWRVSjlpeVRWVGE1eTBWUmg3Vks5dG9rOHNSVnRjMk5VVU5zSlhTQjFpOFJPUGQ3ZUJuUWl0M2t6RndHVnVNeEgyZXQxM21UMTJDQmVZY2k5dXc0MU51VFVvanRfTlY1QXZKRERuOVUzRklWU3JvSGQ4NDJqSFlzVDBNV25tMnBmM0IwOEFDOGUwOE92dlNxZ05ReVQ4SGhWLVdUaDRTbUNFZnFXSU45TGpvZXJCTmZJUk5ueU1RWWpKWVVzY2dFbHZuQWxWREpOcDhqbDN1S1VqUlVTVHdoa1dIU3N4TllKenJiRnFJNjRaQW5ZNDdLZWZORG1GVzNOTk90b09henRHeU85c3Bmc2kta2gzRjk0UUNrSy1ZUjRMX0x0VWVJQ2ZJbThxb2NJZ0JzX3hTWkRQemV2QUxJaW9zNjl5cFlheXpEczMwWGkyeDRvekdpZXlfMVhOMjRvRzMwVnhEd1FsMXZOVGxuNGFQbUE0QUtjcWwtVnBSTTlEaEdEd2h0WVRWT2RPUzRFMWJISE9MUm8zZUF0dE4xVVlXeFpvd05adERIcUpnaFlVd1pJSFBmRzRrZjNIQ2J4NHRZWlJJbHJScUl0ajk3ajY2ZTBneGFMVFAza0NDQXYxa2pXMnZWMmJxcW5ZOC1tSlVkQm9Pb0lxTzdEVUUzZ0pFRzN0b2V0WHZvM1MtWlFmVktqR1hhYjFrOF9aWlM4Z29QQXZ1UV9uRGdEQXJqbktIM3RvSkNaZ2poSGFsOF9DdC1lcktXM0pta1ZaQ1Z5eGhZei04WjZBU2lseHI5SXpKaW5JZXdOUVdHU3RIMk5JZml2YUN6TWVCM1NnS3ZwQVlvckZiOXVweGN5b0c2Ry14T0FRWXVEZXJsMDJPaWlENkZ5SnB5QWdwS3dtRW9feVJjeHd0S3dvTU9LN0MyVXp1bkRYeVFaMjVNQm1zd3FiUkxsUU41cVBCSS11YzEtemRjUVRPckE3TXpibl9EcVk3V2ZKWm9RNlc5M3M1SGptMWJ0Z1RJbXBMVXM1OU11WDVLQ09ORHpiakFINDFBYUc4OUk4UWZibXNBR2NHRXNkcVlCLW92X2NrMW5SQjRFQ3ZMbjY3MVNmY3JCdzEybU5hVkNpZ1J0SkhXYUhvQ211RDMwd1k0NFRadFFFaEN1dmF3cnNzXzJnbVZqQ3F6TUNjbFBHZ3dPYXY5ZlVnamZDaVplVjVzLnRzCg=='; - postMessage({key:'UboShowAdBanner',isMidroll:IsMidroll}); - HasAd = true; - } else { - postMessage({key:'UboHideAdBanner',resetPlayer:HasAd}); - HasAd = false; - } - } - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - } - return realFetch.apply(this, arguments); - } - } - function resetTwitchPlayer(isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - if (!player) { - console.log('Could not find player'); - return; - } - player.seekTo(0); - } -})(); \ No newline at end of file diff --git a/strip-alt/strip-alt.user.js b/strip-alt/strip-alt.user.js deleted file mode 100644 index a61f787..0000000 --- a/strip-alt/strip-alt.user.js +++ /dev/null @@ -1,205 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (strip-alt) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @description Multiple solutions for blocking Twitch ads (strip-alt) -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip-alt/strip-alt.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip-alt/strip-alt.user.js -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${hookWorkerFetch.toString()} - ${pushSegUrlInfo.toString()} - AD_SIGNIFIER = 'stitched-ad'; - LIVE_SIGNIFIER = ',live'; - IsMidroll = false; - HasAd = false; - StreamUrlCache = []; - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; - adDiv.style.display = 'block'; - } - } else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; - } - if (e.data.resetPlayer) { - // There's some audio sync issues from the replaced segments. Resetting the player should hopefully fix this. - resetTwitchPlayer(); - console.log('[strip-alt] Reset player'); - } - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function pushSegUrlInfo(segUrl, isLive) { - var segInfo = { - expireDate: new Date(Date.now() + 120000), - isAd: !isLive, - url: segUrl - }; - StreamUrlCache[segUrl] = segInfo; - return segInfo; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var dateNow = new Date(); - for (const [segUrl, segUrlInfo] of Object.entries(StreamUrlCache)) { - if (segUrlInfo.expireDate < dateNow) { - delete StreamUrlCache[segUrl]; - } - } - // FIXME: Twitch ad banner issues. Maybe detect and remove from DOM? - // FIXME: Sometimes freezes after midroll? - // NOTE: Midroll might invoke player-by-picture player? Might need to change MIDROLL to PREROLL? - IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - var lines = textStr.replace('\r', '').split('\n'); - var isLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - var replaceTags = ['X-TV-TWITCH-AD-URL', 'X-TV-TWITCH-AD-CLICK-TRACKING-URL']; - for (var j = 0; j < replaceTags.length; j++) { - var adTag = replaceTags[j] + '="'; - var adTagIndex = line.indexOf(adTag); - var adTagEndIndex = line.indexOf('"', adTagIndex + adTag.length); - line = line.substring(0, adTagIndex) + adTag + 'http://twitch.tv' + line.substring(adTagEndIndex); - } - lines[i] = line; - } else if (line.startsWith('#EXTINF') && lines.length > i + 1) { - isLive = !(pushSegUrlInfo(lines[i + 1], line.includes(LIVE_SIGNIFIER))).isAd; - } else if (line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { - if ((pushSegUrlInfo(line.substring(line.indexOf(':') + 1), isLive || !IsMidroll)).isAd) { - console.log('[strip-alt] Removing prefetch url');// NOTE: This currently strips some legit prefetch urls (might invalidate low latency). Preroll shouldn't have a prefetch ad, assume live segment to avoid 2 second delay on stream starting. - } - } else if (line.startsWith('#EXT-X-DISCONTINUITY')) { - isLive = false; - } - } - textStr = lines.join('\n'); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('.ts')) { - var segUrlInfo = StreamUrlCache[url]; - if (segUrlInfo && segUrlInfo.isAd) { - url = 'data:image/png;base64,I0VYVE0zVQojRVhULVgtVkVSU0lPTjozCiNFWFQtWC1UQVJHRVREVVJBVElPTjo2CiNFWFQtWC1NRURJQS1TRVFVRU5DRToxNzY0NwojRVhULVgtVFdJVENILUVMQVBTRUQtU0VDUzozNDY2Ni4xMTcKI0VYVC1YLVRXSVRDSC1UT1RBTC1TRUNTOjM0Njk4LjExNwojRVhULVgtREFURVJBTkdFOklEPSJzb3VyY2UtMTYxMzMzODM4NiIsQ0xBU1M9InR3aXRjaC1zdHJlYW0tc291cmNlIixTVEFSVC1EQVRFPSIyMDIxLTAyLTE0VDIxOjMzOjA2LjUzNloiLEVORC1PTi1ORVhUPVlFUyxYLVRWLVRXSVRDSC1TVFJFQU0tU09VUkNFPSJsaXZlIgojRVhULVgtREFURVJBTkdFOklEPSJ0cmlnZ2VyLTE2MTMzMzgzODIiLENMQVNTPSJ0d2l0Y2gtdHJpZ2dlciIsU1RBUlQtREFURT0iMjAyMS0wMi0xNFQyMTozMzowMi43MzZaIixFTkQtT04tTkVYVD1ZRVMsWC1UVi1UV0lUQ0gtVFJJR0dFUi1VUkw9Imh0dHBzOi8vdmlkZW8td2VhdmVyLmxocjAzLmhscy50dHZudy5uZXQvdHJpZ2dlci9DdjhFZmtQT29POHBCRkxpeEhpVzVzQkh0ajF3VWR0SnhMc2RRZFlxaHZIakpvY05HaTlxeExQbEowNDNrRS03UmtaQWxUZUVWbi1mVVE1ZHJ5RVFFVVhDbTJYZWFtZk1XbHY4aDAxcDlVam1wbEpQWXNqbzRjRzRlaWJRakhBQkJTbkdfMWtCS25YdEUtc0ljZTlsZXdKSlZYdmRsN19FM2gyYmpCMVpWVU5KT29DNzFvLXpFZFRvNUszX2RQcVhKWDE5Y2lpMEZ5VnQ3dVZEaklKYzNVMGhrYmM0cGVOaDRZbEVkUVlkSWE3OTFQWDdfTGJDZmJkdWdTUXFrNXhLX2NUNlpHTE8yWDNVUU9lTDhSTWRlVkpIVllWUDVxYmQyNWZ4MzlqcWRsTTBLeEJRS1lVVk9iWmprTEtQd3RWMEpQeFFzZ0dFSVRZb2hKMm1KV29UTUktQ01rQTRPTDhpSTZZTHB1WmVneUVBeGRaUERzMUlucWFhSVpTUUlxUl9HOGZJYXZvWUVoa3BwRDNpN1NnaDhKaThaQ253d3MtZ0ZHUnRvRVhWSFZPZlZjZHEtQThmZURMNGZJNDlrS0xtSy12Tkc4VTNvU3ZQbFN2LWx5eHlxYnZNMk83blBPZDhSUFpoNUgxSmVmWDZDbENpUmNXYm95Qk9NcXZ2RGw4OERqbG1faTdXdmNHTmlXdjdKMl9tallKdlM3b0d2bUFBVlFaVFNPbUZDVDItaWtsdjZQVDdKZkpTV2h6ajY0SEV2QTVlazdvNWFweExOTy13OWpHbkR1SjJrRTBTblZoVHhXV1dVZm5GVERTYjJJcFgyWmlZMUFhUkt6NDNkYmRnbl9CYUtYR3dlRkdUVHNJOHByVzZQdjJCNE9uakZ0YVh2M1Zzd1VVVDRaVnNGM0E3VHZWM25nSDJrVVAtdFVRcTBJSzZ2YWFKeHFEaXR1c3JDeHk3bUh3ZTJYZWc0X2pPaTdfaTRMVWhKY3VJdU04VzA0WGRXWnhmb0lFVlotdnZBQUU0bDVZbTNKSm12SXhXTG1vZUd2ckVMOEs1R1Q3azhJanZSbG90OXlNeUNYTDNReVYzTWlwa0FvbVNfY2pCS0V2UjMwRGFneHFMWXl1bDZ5NHlrOGdTRWhCemRHcXkwT1ZZM0pvT1pkb2lhT3JuR2d5XzV0RFpCUDdaMjNGZF9LWSIKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZTenBUOGtSckxTbVh6YU5fWTN4dnJnWlF3eUlyYU5WZUxJTk41X2JHNXAyTVdSTU9uOGJsWTQwV2RjT3FMeVZOMHllUFQ2WWNjcENBZEEyRjd3NGVXb081dTRlUHRqM3ZOdjJsZS1QQ0ZrNHhIOEJ6X3VKWVRkZXN4M2tITUQtVkJwc0NDdlQwdUlVc1ZPQTdJd21CNGNWSHUxY2tiRUwzU2pxaGpUS3BqYjRfME1nd3pnZ2hkYzlHRkJ3UTdJN3V0MUlPaVhNbko5RnlLUHd6bGpBV3BsSjlRS0xLeVFHalhmT1dwRG5ReGJFdk5fc3BVakhjNTRHa0t4b3p1bDJGeXVZTEwtZE1PWURUTXl2cGJua3U3NFhJZzBZcFYwLXpDMDd5OGpPTjFJT2RsZTdtbDVpbFFXTFV0MS1QZ2pmOWpBN1RlZEtPX0RsaHYwNUlJdlJueGltU3ktVTQwT0JjZ3BISUo3M3JVaDRBMjdrRjVySnk1R1RfcERjWlRjSkFESjF5N19pekMzNEpyVl9jR2FlbE0waDhSX2VBOEV3ZmRQNHI5dXNHZi13THVPaVRsdGpUY21JVHZPUWo5VE5HX2FkUTEyRUJaQk00VzVSSEtXUDUxLWRnRnRzWmtNWUMwdnVqN0FDNXlscW5Ec01RU285UlZTSklkakdpX2VHT2FuMjhkUGt0RThQM2duemlVeWZsZWdfWTlzVkxDbi1jQzBzSzBKTGN3bW5GRmxUc0xjR01vVFFtQWtLeTZDZGNsMmhLSVFUUVhKQ2FrbjVWc19aQ0JENk9CMS1UN2U5WEhadm5QR251cVo1RUNPaHRmbEI4Xzd2aXdJZHU2SDhMaGg4WkdwVzBXalhNbGltWGpxS1cyN09kWmU1Mi01RHdzNTlybEN1Mm5nUUxBRUwwNjJ0N1A3TWZUZ0NfNmlabDVicmdGRWMtZDFhamZOM2I4V2pVaGYzb1VJWTVRRld2ZDdma0J1UXVjMDJpTlZVemRWblM2Q2lLTG9sY285b3BtNy1MQWdqYWYtVkhaMDZncExGS3BUTXlEWFBWYjFuSjhqdnRKQTNmMHJvVVJacTdrMERfMkx4SkxuQXhsMWNueVZSaWp3ZXAyeDlLc0hIbTVoaEJ2RnJidThLUEF5M3czWG0tWXBiMHR2V3FtOThxM2RyYUVoQ2RsaFZJQmxIZFdCWDVxeEN3b3Rib0dndzBUNXVxUE9ZVUk1b1NSQ1kudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Zaa09EYUxhV2xLRGVzUU54eTlfcDZvbTNLX3BHbWlCcUd4bk8wN0JDaTItN0JEUzlnZENkYXlXeF9JTEhZaTVNWUY0eVNEaTU0RWYxWVhmLTJ2MHYxS244TXpxdll4bFVVR3R6TkJlQmNrc2w3MDJZb0xpY3VJM1l0WHVfOUV6QVdibW4xdWRfUWdMdTItRlV3WXF3NG00TUdvOHh2NUhUZjBJa1JVRk9tZFVWdjNLeUllakc0aGVtQzRoeUNPd1NhRDZZUmotdFEtRm1FSnNXQTQ3ZDZNdHExeDFuV2gyTDZBYWhJOFFVVzQ4T1ozMW9WR3FjYTQxUmZnUVNoSkMzN09XQk1oS2V1OFBQX0tDQkduUzlvaDNZOVNIQWNlYWh6anRDTk9VdFhGUnRyZEtBdV84XzAtMUh6U1h0NDJzYTBIWno2aXJkc25HVmtBTnpwY3lkMDI1UENpUWxQbHNQa2FPSVA4T0FqWXJoQlUydG5rS0tPNnQyWkFnc00yZ21KS0pGVVpab2J3Zi1qYWNXY1JNeXlHVjFQaWlvWWdPRjFvZ0hwdnUzUzE5ZThtdzFEVzQ0WkVYUUtzRWc4b2h6dUZNWEc2REJHZ3RLUzNxam0zeGJjQWFSUFFsVkoxRUp2SWRhQ1VUTGEyVjRLR3BZbUpUdkk2Q0hGcEtodnFnYWhtYUtvTDdqcmZIbzNuWENqRmVwUzBudlc2X1BPWTRFZ0xjNDJiQ3BILVVBMDNXdGxMUEFqeTNZeEVaV1hEdHZlaHRDR2k2ME5CMkNOb2hVclE5UWZfdVhYTlNWT0dMeTAwTmo4Mkh3TEh6WWJwRGZNY3I5QndrS0xVZHlTekpwbmtmVGY0VjRBaEtjb3ByU2N1cXRScUJNUFFmdnRCUGFvemd5V3NHM1BvaE1QXzJOWVdmcEdaMXBfbDA3ZXlrbUhBQXVGbTNCcWs1eEZXdFdYTmhfT0ppRXZWMzlkam1KVk5wOFl6WGRhZUxRODQ2RkQ0WTR1S2NucU9UNnhleFdYSEdaSEF3THlFVW1EVUkycWJzdWR1X0VETEJDWlFJbWNUSWtMc0d1WWtaSHRWZmJ3SkVBTEtJMFFIT2s4dTdWZ0ZFcXhROFNDLXJWZmJjbWJZZjVJalpiYTdoaGdydjNoTDRFempHRWExbnQtSDk3clRURkVoQmdMRkp0RWRVX3hzRENGMlFOVEI4Z0dneWs0OEsxX0w3aTdxYUtUbTgudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZFTURGOUNTR21kYzNzTnRkRDR4d0NXV3hCN3NWNVptdV8waWZ2UF9SeE1rYVBYeVc5YndEcmdSclBqckpLMTc1TXBacVlMVzYxdzRxcEFYRUV0bUh4S1JJZzlzc3RIMnZxb2ZvSWo4TjM1RGxleE5SX3RHYV9XRmZuQ1hSNWhrYmQ3YTRxNjRHWk14cjBtMTYzOHdYdG9FWFlkbHN4N09YTjVVNWwyVG53NTlJOElaa0pvaF9wRHZrTDNRQVVic1Vsc1BPRWNrZzVsWVRVR2FHMnYybTZlNEhYQmo4Y05JcjQ1bEZ4T3J6VTVCeGlqZ3pUeEwwVWFhRXl0TWVyV2IzakVSaVFBZ2R4WkdUNzJ1YUt1XzU1TG9RN0w2czM3ZzNpVTF3SThITHRXWUVOVkpUU3dpRHJiRGdGOUgtb1FUZ0lEVDBiNDhpeS1ZVk05WldhZzZ4OEl1UVI4ZG5haFBMTDFMSDY2Y2xXd2Y2MXk4ZFZSZUdHbUN4X040Y1QyQnVJRmduclN0VEZ0R1M0NXZCM0dWN2RUbWpXZ3lqZFBINEZlUHVrOFZiejJ2bjJQQk8yNlRYOVpnSkZDYUJTQTFGcXFNTHh1bUdKWUZIMzZGUnV4T19FcVpCVDVWQ2xXa21Rb0pFNHFWbm9OQllTeWxGUTBQRHFoeGtLZUd3bUR1Sm94b2UwNW9jWl9YOEFDMUoyZDI3dllmamhYaHFYeWJVeEx4WXk4YVVOR1JsdEs2RDE4OVFGMjhWUGw5U3NZWDRwazE4QVBvel9uRVRWb3hEUFdkVlFtVVlPTTYtckRBR3dpbG1JN3JKUmRkdVE0M1JueXZFSEotWDN0OGlQZ0hzQlR0dUpZaE12UllucVJqVEhwekN1eDc2LTFUSTlyclhHam5nbXY0UXA0YjE5ZUJQVXc2eXVzdWVTeDVMNnY4eGlLUmZ0X3NXSDRjNWlscGJPSWhFbGxXcXhROUJWZ0k4bG9IR3ZoQkE3NzZIRTJ4U09SWklXSUdOSDRvc3VEaW9CX0FGQUEwMlFsdGxRMFUtWlgtU3JYNG45b3YtQjJsTmdWQV8xb3JVYnBCWkI4cmdDSjdmek9MTFRHVnFQckx3cHNVMEhQS3ZFbkgyd1gyRHJORTgwa3ZYbXhzNU9iRURMWGNMQzhUR2RCOHlDV1oweE1iSEVoQlRhSDNfSTM5XzdENzB0bFlGLVlPMEdneTcxUktEVEtlMk52MVNCdDAudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Y4cnFaTFJnRDQtNXVjQnFldW55dU01X1RHNXZOeW5qLXBwX0ZqclAtUEtHemluR1UzQjdqclFWYUhwQTBReXZYWDJ0VzQzbDZEX1hOVlVoN1BtT0l2MjVnbUF1TThheEc2RGh0WEtCR0ptUWlCbzhrZnZxeWZfbW5OYnRlYkRic1c4Zk95cEhkd3BWVHlBV2daanhqczVKaDV1ZWlkUDJOOTZxMWRuM2VRSHZ0V1R0NFE2dWlvSk1pb0FuLUNvQVMyUjR5WThScXlUYkIyMlQ2eUR0SzBHbkpnbEl1WGdKUHB3dXF6ZnRqRmxCVXhvd1VxWE5mU0FreU5zemJsMTV4eW05OExEMXBQZkFEUi1ra25fVFdzeTJtNlBjblh1LWdTQnZ0aWcwYmNGRThiSThjOC14R3REZ0dsWU93YllmandINW94NmFoV0NPSnY1NTRMT19Bc1hvcnU5cGJWLXBlSkUtbGhNMHRVQnJYU2R1eHRBWERwSmtlMzJtX3dwa3FnTDZWLVZyYmFXNS0ycFVSM19DaklCOUJYOXBqWmJKWjdDMVdNZDFPLWJ2SE1IS1psOU1aWmtEYllTVjExRS16X1VpcktxWHBSLVlnZWx5aVA2eVhuUEVlczRlblliYUNDTXFKZmpncGotNURyTkFia2xOU0Qwbm43WFR1UjN5MFpHdkVZT182QU01OTQ4NHlLSllheERKeWFfNnZhU08xc21XYzZ3R3RDdHJfLWNOLS1hczBubHlMcXktU0JnRTZhUHN3ck5qZGo4VUJYUS1iaDMtV2hJaUtnbjlXSVdiR1ZPNlVZaTVyMU9tcTcwdEVvQldoaDZzNVFhN2dBWEpaanVXZ0ZaQnlXQnhEbU00WUpWN1VBUk40MzBtX2hyUzdfTU5XQVQ3ZVpSampQTzFFOGpsMzBvZlJJZ2ZPOU5hZjJ5WUJqWlFHTmN5ZFI2YVlKN0dvYk9sa05oUEpwbk0tSTN2NEtmNmNsU1Q2Vnk5RWtmQ29PYmNBTk1NY003bE1rdmZNSElzbWV2d0FraW1jQWwzQmp5X1lhdTBWdG1pclBFc2lSQVo3LUFobnBWWkR2U0s5b1hEN21EV0hCM0s0TVVkT3RZRzZDZ2RfUHFzZEVaYWY1RXhjdnhDbU9YY3BTelp4YjRDcHlLYW9JR3p0S1RZd0VoRDQ2YWNQR3llcDhCVGRPYTVIbzc1ZUdndzdGek5XZE1DS2ZGaVBBaVkudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjAuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZzRnVJS1dnM3RCcHc5cVVyLUplTl9FdHVuSnlSbm9nbXBrUmMzVkRyUFloS3JscXp2YnB3dlFQdy1oaWxaU1dXeHIxS2xzcjU0bWU1cUJKQzZrbEVmMG9INFRrWXROQ1FWaTBmTE1IUVg2S0l0eXdSanU1QVZXS3EyQVFYVXlwcVozRktVNG5sTjgycUk1Sk90WXh3MTgyS2lMcmhtUXFEQmE3QUl3dTVLRGw0MEtjczhaYzI0dUVZUlp6ZmMwQTJGNjVGWVBURzk2MUFvdkxJc3NTb01OSEdOZkR1S21tN1YtWGxnaUxqdXl0RXlMVW94a2lKbVh2anpzSmZHeGRnNExxRExhaFk4TzVDRl81YXlrU2xuOURCZ2t1SnpjNGdBTmRzdndRdDFNUkZUX0Eta0Z0NEZEQ2dZcmNfN0FEbzNUSy00SHAtemltVm5QUHB5S1JKQ0NFb3YwblhXckI1dFBUQ3RiUDF2ZEpiVk1EOFVNcllWekVqZnZUeVU1cWNQTDkzMzJIcFdsazdhdjhuaU80TlZuSHBsV2Rabkh2Y1JhVVdfSmZldlpMVHhWXy1YSTFyU3M3a1ppdjNBSWptWmI3ZHdRYlozUmd6b1IxNmlSME9lWDZkNU5KSWU4cjZqTUFWTk82czRzTExHbXRDYXJBYWJTLWU1bnVFMGxpMVdnWEMzZ0pxanpDZlJ2NllNQ0FXWWM4ZGdydjdhMmExZWZHd3VycVAzTndMZnRZbldncFVjRGlDNXBmMGFJdl9ITG1SS3VHOUlXWG9rcTRXMVZ1RFdTV25SWWhRaWZFZlJ1NkZZWVZzZjRoNTlvcHRPa01RUUdQdC1OVW04MGxfX2R6ZjVpOTFrNVZ0Wkp1RzJSd2RwemN5cnJPXy16UXNUX1JaQ255amhtZ1RHd3FUVVRYQmNzWXdZTmRSak9Nd2p5X2VfNHljQ1p6bVd1Zm9uY1RjUEppWnpLcG05dG1hVmE4VVozRlhVSVV2dU8wRTRxSzFiU2NVWFZoN0sxZHRtMEdYYk5aLWVyM1BSWGUxVE9QNVM2amtYbE96M01ZTDJTcWNPREI1MjhETXhkQWg2RmR0RFZjNV9oLVJveU5ON3c5UFNJNVJ5MFhQQTBpWlgwZC1XTVhnQXZHRE5pTGZvMEpHbkdrTWd2cWtBN3FHYTFQU0VoQ1BCMl9UZGRwdW1QNENDbmVVLUpxREdnd3lYNFIyVEdFaUZLbXA4MnMudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZOa1JVNU9vWmNIS0FuaHNWS0lZWmgtUm1kSWtXT2REdGRFZ09kTk1qakZNVkhQOFlocE1IYXo0bjBkaTd4SEtqX0dycEFGMHJHSkhLbXdWSHJTaHFQbkFSYzNrbVlseVAwSjRSYkV1ckhuMjFTcEplZjc0NlhpRzhhRUpCWWp1Z3kyMng3YXBxMGRKaFVkNVVBYVRCMHpiYlpEbGptaVd6eThiXzljWGhEVGdmUk5ZY0FtNVVOY0ZZc09KeXgxX0ZvZGtPQkVGWU5NT2tteG5HU2tWV1JiV1ZvQzM0T3VvR200Q3NZbFE3emZ5dnB5YlF4Um9jby1KdnlnMTZLbDdaY2E3MmJEZmZ5aGttVm45ek8tbkE2TkZMZE1odW9BTUFPdnFNYW1McGtraGhkcE94UjJ0dGFtNEs2cWQyTjUzZFNrOWQxTkItb1pnZGlLTkYybHQtcGN3TTVGWTVwcl9uc3pTcm1lUE5CWHVsSVBGNzdmdExSMmhMaERYZlNDUng3TFNKR1VkXzgzejJFNUgtWWV5SHhHRy0yZlRKQ2xDUTY5QUdXX0U2UVhzcHV0Vm9HYXkxT3NuaUZCZm1tOXl2NGlGQUlfeFZza0J3Q1picHlCY2NxTXNrbS0zY0FVNGIyNTRTZWEyQWlPWVczOVFYMWYwMy0ydXRxTmRqV0VQcnpVVTNQbkpGdFd6MDMwYmdfLTBZcFNDOFp5M05aVW1kTWJNRmFEUlQ0dWdaX0hZcjA3UjJOYzV0dzVQdWlPdDMxb1dnMVdXMEpZVDBiOTl1dWdSZXVpQVc2MmFVbzJ6VU5BNThPb0pTUGM2WVVHUm9PMDVTeXp0TFJkMUdBRHNFVEZod0xQeDFtUlhpTU5mM2dnLXRMVjR6UGp0VmF5SWEwdHJDdEJSa0YwMjhDM2ExQnUxeEtDV25WRGhIRzJVaEo3U0JjZzI2YURnaHBPQkQ4LXU0STFPWTNzaWF5VlFHZ2U3NGE4WHJQZnVXaXJaMWtSbXRnMERGVzROSjBtdy1nM3lKbjh3RHpnUGVpVFZDRzVkbFRjUlo1ZEI2OUdiRWxzUHBlS1R1TE9Fd2FjSl9iT0FhSWQ3VE5RWkNWRXloc1lvM2JtcXo4WVE0TEpLRnJ3NUZSYUVDNXc1QTlNaFEycHNwV1NZUC1tWTlzN2dKRHJ6OEVoQTg5M2UxUXhfZ3hSR25Fc1YxenlCdkdnemN4ZGZZVnJyOTVIV2hFeXMudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZtcUxqcXlKbW1sMXMtMTlKTElVakZpem9qWTFSRTJGWnl1cHBlM0NEV1RUMFNGdDdIaXd0dlF2UmxyYnBVbzR0TUVOdm0tYk1iS2dYMi14OERHUFBQS2RWMmpXUk44Y3BfakExcDR6UHlMSnBXMmYxaTlxQVNJMEFvXzVRUWVZVHdqbEhTYkx6ck4yNFhsVmxaQUhiYTRUVzR1ZEdNY2Rqa2Zud2hqUW1kUmY4elZaWHUyeTNFOWNTM1Qwd3RvVTZNOHJ6bWp3VVJsOW1pT28wWC15WlhNeGhubUFXM0xrTlZ4Z3I2cUxHTndSMGFYbU5WVWsyVnlJUzdHby1MZ2RxdkcxWjl0TmE2MlJJYk1WVW01aWRZZUg4eWdjRk0wdTVnbDJMWlFMX19hQUVTYzRURG1DSm05anhsVmtyRHBWbjJYbjgtdzBWcFNGUEcyczViM0QwQTRkcHZWQVY4ZnJmeXFTUE5sQUpJWmlqZ19sZzRtWS1Qa0k3UDI4SmdsUkRTNkpaZ1JYRDNDWjhMc0tUNHRKTk1JTzRBY1JhX25wdlhMd1h1dGJMU19RZ2FBNnl3Sl9wTDdjX29yQnQyVG1UQmIxaEptX0VUMG9yMGJ0ZW8wUmRlNm9CNFFmMUlyTkd5cjhWX3d4bHc5cnFSWFVwa3BkR0lzMXZDOVpSZGRycC1MX3hCTWxBMmJlVVNBSWRqNlhLaHdHbHBKNHJiRDRvcEhnN283cHNkOEl3MklqbUlYU3FqQzNlMk5ZU2FuQ3VXQmRzYUs3QnFLby00RV9mZFVrRmVkbU9GSDV6RFAybW5teEdyUUIzRW1TVTFaR1JiSHNHQnZ5eWNLSmVHeTd4YVZ5ZDdsM2hnUy1rQzdHaG9UQ01ITW9DWm9OcnpYdWowdmRUbGpDOGdwMkFLVE9kV25PQWhsaE1aRGFXcHQ3WnN0aGlfUnB0WjNEbWdOLVdvOWJPMU4xWl9mcTNqNk52eUc2MWh4b1dVQXc0RTd2ZVBha1QzZHZJX0Y1MTUtcGQzejRwQk5vSGlmMlFpYWoyQzlPdHZOQXJpYy1mZ3VrSnJ2SGo4S29aS1o5NnVHRGc1M0pWTVVwWXlwVThHRXNZZ1BoMjJ4eUUtQmNpckNDSk40VTRkTkxYSnBTR3BsTlZ2ZXY4MnMtNWx4VTY4WDJudldSWkVoQmFla0c3b0xSbl9tenhaM3hHZTNHY0dnd0t0U3lUSDZkeHNla0tVRzgudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZNZWhXcXVjRVJVMHptQjA2Tkhvbk1CemxVbkd2MzNtN2tiNjRrQ1JKUmhxNTl6ZFVDQTNqc05IMGs3MlNDcC1oVjN0enI1OVZhMDJEUE9vdmlGQk1DcGk3aVRTUjMwSkM5eUVzUjVsNG5BdllFQ3ctVzF0QldWdlJnYTBGOUlRZ1Y4LVRwczltSEoyOXoza0d1U2ZfRDZBa0liaVJfNlRIQmRKZHhKeGpvaVBSaEJUSkpReXM0UXhGbjJpeU1DWlVVSmtVMkNYdHNYUkQ2aWwwb00yeXJ1dF9Ec3J5bmRSUUdVQVU5QlZzSURMTkxvUWtRZkFfUG5hMEJzeEFFY3JjWTZoWnRISmhOeGFPWHFkczdyb2lEWF9BQlhxQjJteWU1MVgxQXl1VEZydDRpeFFXY2ZSaTVuaHA0VjNaaDRvTTZ5aTh3R3VvUXYxbjRyMFhUd3FiX1FyVUtiNUJWRVJxek5EV04wZTZWMkFSOVI4VHlDczlmQXh0dEZ0Z3gzY3lhM0FJeEFFcUhqcEUweGY5dUFTdzVQbkV5MEUwWEgyNzRFS2ZnVW1VVTMyY1dxOW9PNWphbl9ENG9pdzQ3ekZEQXA1NTFWcllJVXRjY0FNMGFLUnF5SGo4TDhYeXV6V1B4SmZMM1lUVWl0dzRfZ01qWXAtUUNqYXRneVgyZHNNSjExUzQtc01obW5HS2hlSnpJZEJ4U0J1bzJSc09SbmR3ZUVSMEllNFc4c09oeEFIRXRZM1ZBMVVWdzlIREFhSVRWWEd2cXFFRW9lN1REY3k3d0VzdHBybWR5MllSRzlQZzltVjl0UGR0R0p3WHBabnctbFl0NFVBQUJNUjBWZ3NhUjk2UTRqYzlQb01uZ2ZfWE5kN196amxwa3RoekptY1FfZG9mOUxLRjBUdERINDAyZ1JoTzFKaUJiR1I3LUM0SW9QUGNUdy1NMW01TGF6VkdHWjlHX1VfUTVoSVg3VTJMY183N2Q5TXNTYXduR3A2MjlRU3ZTMkFjdmw0ZE5sTUpJeGl5X3Q0clJsTU50NFprejlESlJfbVBMX0J3QkZsMEd1UTByQkxsZXR2SWl1eEZ3NGNlYVVIaWFDdzZaMGJGN21oQXU3TVZoWnQyT1NMN3M1SmJCOXhPSFZJdVRxQnJJbl83SGpGYjBHNy1uQk5PcGNFMkVoRFFFQ1VJVzFXX180cXFsYUNYRTc4eEdneWNIZUNSOFlCR1VFQjJBSmcudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0YwdnJBXzJOdzBMejNWV2h5S3QwVVd5ZmtxUE1JVUh2X0FNUDRfV1lnMXNtdWJ1U1JGTlV1eFZzX2dqTnZYVi12d3lRa0lpVEl2dGluSlN4V1kxQWZYQ3E1RkpzWnhpTkV2Q3F4ekNicXpVbktzSU9oNXZmNXZuRno1ekhmN2JGNGJnbHA4WHJEQTRQNTZvV3VOY1NfNDlhZ0lHWW9sT1NCQjFkX2JJN09sT3V5SDVaN2l4N1ozXzdVeHJYQTVXUjc3ZVpfVW04SV9MbE11UWs4RWNtX2w4UzFUMzlmS3haNHkzMUZDOU9xUTBDampITndieXVkX2xVNldRa3NoUktCaHBxNjNMY2ZsMkxuQzkyMmhGVExDb0tIMlNvTnJBMHNTQ3dGWU16S1RKY0U0ck1YN1NRSEt4RDJ4X2M1VkdSWmk2Sk1hMVJ4Q2M4UURsaWVzZTlHRFNCZll3VjladzVlRWFub0lpNnZiVHlEV3FGNWEyUHpCX2NzREZCdVljaU5MS1dONElKd3ZRZUU1Mk4yY2hoUGlyYzJ3b2hyZjBwdzA0STI4MUdtd2xrR3doYmVnLWhDV3RVTWhwcFFWUHI2NWN2QmpuSzRVVm9vTmx5djdCSFpOdUJrXzJTREM2cmdTVHlDZnp1OFdTMG1Rd0NDX1BEMWxlbm5TZ0p3aGc5Ml9ObDlmb2tkV3E1em5BMEM5WGpEcXZjR0VtNWd5YXJlTkk3cEZTb2F2VW5lZkEtYU80SjJMVWs0dUVtVTVNbEtfR2FZcFhad3A1cU1ELWdHWTI5Mk93Ylpzb0x4SVpaREdSVzVsM25YY0ZFNXZzWk10RW55NTlYQVUxTkxMQVMycVFkYWpYbXFoUFY0d2lndEYxdEMwaDItZHhyb3Y2aDExLU5teHFjRmNvaElMNXN0QXdkZmdpcFB4NS00Y1BPcW9SVjZHY1M3c0RLQ1BUTDlaUEowZElHUHhzSTN5ZThVOUcxZUNlUnN3OC13TGl5aDM0dzhUMmQ3aHZ3UkhRZFBQalFEZWVHRkpMeHdxRHh3RDBQNzE4MHVkRnlJaXU1MXJzdWdfTjdfRGl2cHpMb0U5QzFrY1dVcnhra2dmTEFiLTUweUhYUm5QRl83eVZsOV9fdmNjX015NXpsUEVHYlpiMWRLZkxGWk1GX2k4ZUNlS3FtOEVoQXFSdm9fSWNWZG53V015c3pMNmNTdUdneWxyVVdQZHU4T2RfdC1tNEUudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzAuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Z5anRXVTZpcFdtMkNiZ21SNlRsdU04WG9QMllrWXNlUUNLdVdjQXljMnkzS0RneFBUN0pOTU9mdUJzS3NNcFQyRHdDbnNNTlJYTnZxTXBuRDQ4eGU4SjdZWTF6MndtSjBCTnVuNk8tZHRycEtsaF9RelRkQl9lajFaZnlJSXBHa2hNVUhhX1ozbFlzZldEanZGYm45a3BSQVVwMDlxX25uZm02QUpSeDh0NHRfWFM0TkcxZWl1MEMtSm5MX2lKanNPNS1Kb3BZU1Y3Nk5peXBEbTd0U2RiWHNMNWdIOGFwRGtjSzgtUC1pbEdnWkw2ZEF5dlpxRHBPTEpZMEVqWnhWeW9zNm1KdFZ4NXdCdHFJdDk4Z01rdFVMdThiQXpISFdNVkRzYVpzVGV1ejNEdEVLSnpNZmJpZXI2VnQ2eERHOUZnalNaVlFKLUFjR2RmUUR0bnV0RUNjVlE0ekJLMXN4U0VNQ0Z1UmVCekp0Xy1DLWhEYlpYc0hGN3NUT0I4dlpkTlN0OVNGbmNrNWEyNEJtd20yUDBsRWZRTDFFTUhubmRuN0UxVFg0OU0wTE1kaTdBanlWSHR3SkpldHQ5M1BZbC13dkNtaXVBNHVqdXoxZXBjWmhJRG9IdHhJRUdoVFNvMmxQQklBUl9wUDM0QkpBeUV5YkVzRUVBU3FlRXVacThXanVmcmRiblBISzZEXzRHS1RzSmJqTlV5ci1HOE1udlItU29EZnhzcDluMXNTeGRVUWZSR1NGc2lNN1J3dU5UMHJYZFd4Tnk0blc2TkxBcGYtQlZFMUxuMEx3VzJIenYyQWNFWDFZeEdMQmYwRHdtWnRxNDVZdmEwU1pCc29EVnJVeHoxV2xhTk14emFpUW9oVXVpazJBVldxTWcxVkI4MDVnNTU2Uy1mYmllZXR2WlFHa0dsRFZzMmxwbUZaWjF1TktPU1JMZTlYR2txQm90TmZmY0V4eThhTUJNOGE2aTFQMF9jTEFzZ1l2Wkt4WUQ3cWRFbHZ1aXdTc1duLWRwUjE1cjFuZkI4cWxUcmpXVk43NUNMa3g2MUZYelVKREhHb3FaTS1xZDQzZ3VBVGY5N2dKTEs4QnY2ai1aWU51Y05NYlVabWlQdGxZU1p0Z0ZPNmFWRzhUOC02OGJZSFh4dHRHc1NHaDhyYndWcmx2S0VHR0VoQk05TDZVQ3B6dE5QenExeTlZWDhWekdneTZReDN4LXNBVUNuQnptd3MudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZkQ1Flc0ZNYi0xQVZfdzBSSl92TjlJRXJrclYtMWprb0NQSTJMZkNJT1RrcEl6dFlaTWhvWXdxQllKUGpkcGwxVjhRalFQMVZoUE9YV0FuZF9fT2c5bXlCUW5QcjNEbEJ6Sy1GVHk1SGtoX0dQQjUta0IwNkxoZmF6bjhDT1RqY3cwbWEycEVOeVdCMWpUQ2poS1FBa0RFODlvWWlZcEt1NFhESVR1QTloQXY5MDJUdVhSMWFST2E3M3V1ZHkzNjRxZjR2Y0o0WF9FSHItZTFSSFUwUFctdXFUX2JlRXdGNEdoajZWblZxbE5USGhCcGRRMDdwZ0hiMEFoQkhMd2toRm12MTFqZ1U2cEU0Qmo4YkJEVDZ4bTRQMVhuOFhJWlg3blYyd2lYNy15anp4b0ViejRaUUtpeG9KVEhzOVFtOE82RkFSdVRQelJTaHhkZnJpbXhtNDlzYXlaNzluM1pjMnlQVXpZN1c5Y1NNYXVHR3A1b3lOdXpFbGNVWTJ1QVdqdDVyOFJBRlVIZndGVFp3ZjhCQ240ZWJuVTFTLV83b3BXakpRbngtOXhOYXRLcjZoX3dSYUt0aDlKOHZuOV9LOHktTk13aXpaQU9OS3lVMFIxMGpaLWhOazdwTlRxVEZYejBadXY2SVN4S0lDbTVLUUZLMHVScmFLWGY0RTlvZ3Z1Z25wWVFPbEp5MDdXblFsQjBxQlNIZGVKY2wzei0wSTU1R2c1Y0ExMTl4NkJpM3hVT0lRRURNbFlPWmJHanlPM1FtcGhab1JYUEVYaFRVUUFxeU5FQURfOG9sNjZuRlA1dkhpX0dRaHNwc0FjbUJiVGczZGdKLVpVZ290c2ktQmJoNHljTUpWckJpS2VkdDV2N2t3RmUxSWZKel9WS2IzVTQyc0tRd1laTEZ3MGNsUzhoS1hYX2ZIRUVabXVIRzE1TGR1c01GM1p1YTZpQm9wT2djQUtTcFRiX1A1WWpVekJfekVxQUVHdzl1Vkh3UTBqa2tSa3YtMlBnZk9EV0FDcU9fVkFZc3Uwa1RvbEt3TEdaVEE2YWxkbUxpeGtkTkdGV2h6bGhaT2NXb2tRVHE5QVNwUk5hQ21sdkdZVWtMaGpPWWk3TGt6V0tfWkxzUnpwZVk0WWxyczFvMHF3QzhtSTFZZHdtbi1iODMybUVucmNkbkVoQ0NGTzYyRWtTVDIxWVJWdE9heDg2eUdneUhPNW1HdE85NjNfMDROcGcudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZIV1FHaHZNQ2Y0QXlkRDN2QWZnWkFFTmgzRm1CWWhQa3ZIN0ktLVBCaXVUZnp1cnB6cG1NaXY3Ym9RdXpxQjJzbFFyZk9ESzNiMnVYZVA0N3YxWWpVdnBMNV9XZXdadlZlLUpoQUY3TjhDcjJ0Vi1ZVG03elRma1FSRVVGeFVMLUU0RHUyVk9tcjB5OWhINkRlSGpiME55MGJ6OFhiTWN4Uk9GMW03S1BJN2oybTE3anlmV0VkRV9oNnBPbjUyWXZvYVZIRzVrSGNDc0lxTmltZVN6Q0NCOHZLYkd4ZFFrUEF4dzlkRzQ0QklzcWYwWnVYYXJKbm5iMTNVblp6TG1pdlZxRF9hVWo3dDM1aG9seE9zSHI3QVg2VzQ5aGk4REVQYzllaVd4a016S3F2aEhMTXNBNVBCcU1VaS1VS0N0Z3M4STdWTVdkRDBlUnMyOEl4WHFQOFRqVmVzQkdrSkZ0N1M3TGc4NTNpczRlNjhaOVNRZWhUMS00V2dLejR5dEhzTElwYXdDU0lqV3RGa3o0NXRiMHowLVpHVFoxdDJUU1lWN3hQUmlwWUhKWEFHN3ZVcFd1OGRfa3BkTm5hUW1FYzZFdjBwbjBhSDdYVFdtbEpQek9MMGJCVHJ0Nm5yUko3N0h3SDVoOW14b0x4UHNQN0hRRXN4cWNDUGNvY2RjcWxYWkhMUzlYaHpobmEwSDFHclJvb0dCbWNRcENELTdYeHdCSlBpN3FicVVlR2RMMFpzanFGRmp0bVEwd2FSN3VhSTBBX0ZsdlA2eDdwdnZnWjh1c2JCZFpmNlZXXy04QkpsbWp1MURVN29UNEo0bmtxME53M0xBaDJ0b0treXlrVUlFWFlSZWlxTVp1SzV6UzZZaW9wTWJKOWdtSE45VnQtVGpfZmx5ZWFWN3diR20tNGx3TjdSY3ZMQjNISnp2X3dyYVhEWlVKdXN2UHlUc3hFcHh3RkRCTUxYcnEzVm5kS1A0SFY3Q3NtaWpobTRXYjRNTEFqajMtVnd4YzZ0Z05OVGVOTlhPNVltUHpQNV9rNGRqRFZ0MHNKNkZrT1VPbWtLcmFQamctaDF0QzJxTkQ1czBsZFhvVDNZYjJMZ3EyWWVHcE5XclRWWmpyNHpwaTYzS3NrR2NvZ1BVRUNNSi1Cd1ZtMTlGQ3E0MElIMnVvTkZKaUVoQWdQUkl4cFF2QUZGNFdndVB4WTBvakdneUpFTC1FSHYyN1czX3EtY0UudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Y0eFl2UGxCRUUxTHNwVVF6aGlWQlhGM2NfMlVkSE5CX3cxdEg1blNiTEIxWWswZmx2eVFwMDU1WWhGdFpVM3g5M3ZBU3NXTUV1WlFvRkFrWGF2NEZlNXdNWTYtYkNBOTlqRXFCOGJRcm9RUERuWXJtbGtvYkFwbGh4Z0IxQ1BIcjZDSzlxSV9Pb0hXVlM0STJ1NmdlRERrV1R2ekdWSlM0QXE2MlR5SXM0bm9xNFJ3Q0xWb2JvY3gtS3VtMVdOcmVFT2J0NHdSdUIwV0ZneFNQNjRNaG5DSGlVcnlMRnMtekJYbHRpc0EzMmFhckFYSlhadU5idGNzcHNoelhoQ1JJN1poSWZSbm14cUVuTXY5cWYtM1pqZnY3OHFjcTA1dTJ0ZzFxcVNkMG9sSnc0c1puT08zaV8ySVZjTEFKUDA5RnNPZmd6SFBBTXJRMXdrbGw4OFVsUnduSGlTMEw2SFlhZ1hOOHlSUzdZQ3pMRjRtMVVHMW1qZ3M3TmNsRm1xVGJUTTYtSGpKSGlzMmZQWUg4YTctTER1WXJFdHBGcDdxcmZDZ1NVc2p5QjM1NkEzMHc2c2l5eEctQVN1S2hiSWJjaXA3UEN0ZGxQenBjeFdQZGxCeEwzRnN5cHR3d3k0NWd1OE15dzJnQUJuTmxhUWdheE50ZUhIbkZWaDR5UjZtZG9jZzFmNTgxVWY4YUk1dGo0Sk5aZ0dIQ0Q2aERFejJGZU1LLXJwQUxDLUhwRzZiVHYyYkRfSlpkRi1XZUZhS3ZyRGh4b2hDbzJuYlY3ZXZLOGsxTkpocVhvRmtlcVRCSklWeFFTRjdqano2alhWcmZwRERmazRhOEcyYTVTelg5cGtEMmdlXzg3UG5WbWdZQWVTa1R6TGFSdVdzNjNmYTQ3OG4xUUpVQ1lvR2h4SjVRSzFxclF4YzdiNE5EZW5oZjJaSDI5blZKVmRBRFZsd1lZcWlWalJHVFFGeWxES2tUOWsyampkZlc1LTVZSGlKS1oyRHZ0Yk8zZm5qU2FHY3ozTTRKd1ppTFBQSUFibEtUaWZnOU1QaTFnYUZra1ZyZFhFeGtUNjNxNTdROHNZOW9JR3VOSVpHMGpBNmc0ODNvTDRpT1lTRDVnbGMwNUkyU3hBWUpDUG95dmdKczYzVlhkVjBfWjg2N3I0N3ZlbXFUNmNUaEVoQV96UmlhUmtJMHp3Q0E5NjF6ZXI3RkdneXNFTlhIcHlCdkVnLVpaTVEudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Z3ZlI1NDd3TWk5SXJtWmI2UjJpS21SWnExWDQ1VUZNSnNHU0Q3bmZma3FmN2ltYjAtdnUxcjJ0djdfeFY4QnNBcUxMMnRkdVpHak1HbDNzSk4wWVhWQ2ZfQkcyWWIxU2NXaUpfOWttN0FTS0pSLUhwTzUyR2NQaFRYX1NLRG9ZY243OW5QWG5wWEtIdkhsSW1BZFVLWTNjdXkzRlpDeHJ2R0lkQTM1TmwwaVhwVjFqLTRQQmN1ZVJYYkZ5Z0YwZWY0LUVKZWdyY2Q5bzhSeUN4WXRzeDJ5NWRIR3l2blJ0VGE4M01VaEpnSklFbmd1SFVKS014NDFjSzVXV2pLbW10QXhMS2tSc3VYeGxCN0lQVWdOaE9qRWFORzFjeDRGY01MT2p2b0FpSDM4cHZWOHhEMTU4cHhuVVNQWjk3VnlkX1BmaGRZNjZxS19MTjR3VU5xYW9HS1pvRG96TWprSHVzN3NSYjFLNUxXUEZxVndSOWU4eTdmdnN0M2tjNjljTS1aNkt2NGU2SnZsSjQ4OWJTZXhKUUplN0FTUjRYNkR1YjhUTHpkTGN0VVBuMEFuUjE5MzMyMUJxV0NkLXV1c2wwVVdYMUdyZ2k2ampMdFp1ZkdESHVvY0J6YVFrX1FFV3hhRnBpb2twdUJaWjlHWC1XbkVRVkl2cVFGZXlUdjRRbjlJR0RCSkgzd3hXTEZPMUl5MDU1N0xQLXEyUUJqQldyTVQ3WVlmbWZyQ0tZOGdxQ2dBY2VFb2g1V2NsUVBxc2hubFg3X0JrVUhSbzVJVDJ0OXZFd3ByNnpzT04zNl9XSjl4YmpNYy1rdTh0VlhvMmlDN0Rscy1fYkk2NHF1XzVOUGhELUtCaDlyLWJ6X3Z4LWtuSkwtTndYeHZidWJUTGplY2RkTmlib1U5a3FBVDU3R2xZTm83RnA1UVBVRkFvYW5OVXdGaGgxdUI5TDlDcURUUWFRZF9nejhuLWswMmhPNHh3Wno2LWl1aVNaMFVlb1F3WDA4MGlrdTZGVUZFVEdBUjNKVjdibDlLbl9hMmFKRmdUX292b0k0NmFoRWFwbkJpZUdqVEJpbnA1MGQ0WnZVa3VrTmg3bUJSZ093ejdyUEEwdUwzczJVbEpNTW9NV09VaDliRnZnN2tlYWVpeGZGRC1VWi1BUmlUYWlVM3N4TlBNM0VoQm5ZYmdfRGZsUDhkVnhCdnRpcl9GX0dneTl4a25RWVBzSERRUFc3STAudHMKI0VYVC1YLVRXSVRDSC1QUkVGRVRDSDpodHRwczovL3ZpZGVvLWVkZ2UtYzY4ODRjLmxocjAzLmFicy5obHMudHR2bncubmV0L3YxL3NlZ21lbnQvQ3BjRnNsaExtamd1OFo1R2RfUnpJMG1nSllLWHBLVmpXd250VEFWM1dDN0xFVldKeFFUZ1NBdVdNc0ZaRXVHb01XQmdCbFZMdUg0TlhEUzVWb1ZibWFwbkpQS2JjSTdSbThaZUFuV0lGNndDcVFuQmowMkZfcWZ1MUFEd3ZKVmw2YTYzRDhIVU93RjZ2QzdYRTBVMXpLZVdQanVIdUItU3IybDFqMl9FcnVKa2xVLUh1TGhQNTM5a19GaUFvTkRFVXVRdGtNalQtTDBUeUlHdmdTZFBldHNPdDRWT1Q3eHZTNjFCaHBHa2NrNmhZRHM3SkRwakpPYnZlYVpxNjIwcnNRcFk0X3BNRmxYLVRaVDgwZlNuSnRaZzlMYnBGd3BvVHVpOXJzSmVkalVvV3BqMFk1ekhkWDRPZm9zaUF1elZ6OTZTSWpCcXFNU3dzcWJxbzZwZ0JOQ2xZNk5LelRFdG5hV2UwdG96ODNsNXgzYVBrQ29KLVhKTm5DZEdHWjNXZ3dQa2NhaHFGOXlpSENFZEIzVVRnOTJEamx4U1lDSkNITTVFY2tGV3YtZlVoV0JSb285cGdfRXlfRlplR2xaQy15QVJYbFhBTlVuS2NpMW05T3FBQnB2c3R3NndIMFA1QnhFWlBocDJ1WDFDeEVaalNLYnlDenBrYjR4R1dwR3ZUSGJlcWxfdFBaS2Z2Uy15UnFHMkRPRGpud2ZrM3dmdVhibDdxeXl1SkRHRU1haVFPVGNJeldXcjNLaXJ4SDZGUy1hQzlMTjdZdWFoLVlsUmpOZzNUT0YxMG9EcWd4ZUhHb21YN1I2MEpnM0tnTTVtbmlCbEQwQklVcU8xdFJseWxmZlNDbm1QbVUxaHZpdkU4M2ZiVUZjUWFidDFIako5OVZ1VXIzUng2WnZuU3ViZWdZZjlvYTV1VWh6NWNRckxHZGlCeUxWUkdKWk91bXVoMXE1Tmk3MVdic0dUWmU4NFZHUmxUaWpuQzU4YXpZeGhvX1RSWl9fcDRITkZubkhBLUFUQjRBQVhMb1AwMWZiNHZMWGVvdmhQWG5QM3Nranp6RHJDS21hNGNMdkFiczlzYzdyVUJYQ1pjbmhQR09KVHhhcTJWd2wyai1hdmRzRkM1Z0J4QjFKOWE4R0R6dERGN21RVmVKQkRzNFFmVGNpdDltN3V6cTN0RWhEdGc1Y1NjTmlsWnVYOE94bE05M054R2d6SmhQczBhTmJpZXZlU3Bway50cwojRVhULVgtVFdJVENILVBSRUZFVENIOmh0dHBzOi8vdmlkZW8tZWRnZS1jNjg4NGMubGhyMDMuYWJzLmhscy50dHZudy5uZXQvdjEvc2VnbWVudC9DcGNGOTV4VjY1QnVlLTVjWDdVNWRVSjlpeVRWVGE1eTBWUmg3Vks5dG9rOHNSVnRjMk5VVU5zSlhTQjFpOFJPUGQ3ZUJuUWl0M2t6RndHVnVNeEgyZXQxM21UMTJDQmVZY2k5dXc0MU51VFVvanRfTlY1QXZKRERuOVUzRklWU3JvSGQ4NDJqSFlzVDBNV25tMnBmM0IwOEFDOGUwOE92dlNxZ05ReVQ4SGhWLVdUaDRTbUNFZnFXSU45TGpvZXJCTmZJUk5ueU1RWWpKWVVzY2dFbHZuQWxWREpOcDhqbDN1S1VqUlVTVHdoa1dIU3N4TllKenJiRnFJNjRaQW5ZNDdLZWZORG1GVzNOTk90b09henRHeU85c3Bmc2kta2gzRjk0UUNrSy1ZUjRMX0x0VWVJQ2ZJbThxb2NJZ0JzX3hTWkRQemV2QUxJaW9zNjl5cFlheXpEczMwWGkyeDRvekdpZXlfMVhOMjRvRzMwVnhEd1FsMXZOVGxuNGFQbUE0QUtjcWwtVnBSTTlEaEdEd2h0WVRWT2RPUzRFMWJISE9MUm8zZUF0dE4xVVlXeFpvd05adERIcUpnaFlVd1pJSFBmRzRrZjNIQ2J4NHRZWlJJbHJScUl0ajk3ajY2ZTBneGFMVFAza0NDQXYxa2pXMnZWMmJxcW5ZOC1tSlVkQm9Pb0lxTzdEVUUzZ0pFRzN0b2V0WHZvM1MtWlFmVktqR1hhYjFrOF9aWlM4Z29QQXZ1UV9uRGdEQXJqbktIM3RvSkNaZ2poSGFsOF9DdC1lcktXM0pta1ZaQ1Z5eGhZei04WjZBU2lseHI5SXpKaW5JZXdOUVdHU3RIMk5JZml2YUN6TWVCM1NnS3ZwQVlvckZiOXVweGN5b0c2Ry14T0FRWXVEZXJsMDJPaWlENkZ5SnB5QWdwS3dtRW9feVJjeHd0S3dvTU9LN0MyVXp1bkRYeVFaMjVNQm1zd3FiUkxsUU41cVBCSS11YzEtemRjUVRPckE3TXpibl9EcVk3V2ZKWm9RNlc5M3M1SGptMWJ0Z1RJbXBMVXM1OU11WDVLQ09ORHpiakFINDFBYUc4OUk4UWZibXNBR2NHRXNkcVlCLW92X2NrMW5SQjRFQ3ZMbjY3MVNmY3JCdzEybU5hVkNpZ1J0SkhXYUhvQ211RDMwd1k0NFRadFFFaEN1dmF3cnNzXzJnbVZqQ3F6TUNjbFBHZ3dPYXY5ZlVnamZDaVplVjVzLnRzCg=='; - postMessage({key:'UboShowAdBanner',isMidroll:IsMidroll}); - HasAd = true; - } else { - postMessage({key:'UboHideAdBanner',resetPlayer:HasAd}); - HasAd = false; - } - } - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - } - return realFetch.apply(this, arguments); - } - } - function resetTwitchPlayer(isPausePlay) { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - if (!player) { - console.log('Could not find player'); - return; - } - player.seekTo(0); - } -})(); \ No newline at end of file