diff --git a/README.md b/README.md index a374312..6249332 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This repo aims to provide multiple solutions for blocking Twitch ads. M3U8 proxies (or full proxies / VPNs) are the most reliable way of avoiding ads. -- `TTV.LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/ttv-lol/) / [code](https://github.com/TTV-LOL/extensions) +- `TTV LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/ttv-lol/) / [code](https://github.com/TTV-LOL/extensions) - `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [code](https://github.com/arthurbolsoni/Purple-adblock/) Alternatively: @@ -17,14 +17,14 @@ Alternatively: - `ttv-tools` - [firefox (manual install)](https://github.com/Nerixyz/ttv-tools/releases) / [code](https://github.com/Nerixyz/ttv-tools) - `notify-strip` - see below -[Read this for more info.](other-solutions.md) +[Read this for a full list and descriptions.](full-list.md) -## Current solutions +## Scripts + +**There are better / easier to use methods in the above** `Recommendations`. *Don't combine these scripts with other Twitch specific ad blockers.* -**There are more suitable / easier to use methods in the above** `Recommendations`. - - notify-strip ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip.user.js)) - Ad segments are replaced by low resolution stream segments. - Notifies Twitch that ads were "watched" (reduces preroll ad frequency). @@ -36,46 +36,14 @@ Alternatively: - low-res ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res.user.js)) - No ads. - The stream is 480p for the duration of the stream. -- mute-black ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/mute-black/mute-black-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/mute-black/mute-black.user.js)) - - Ads are muted / blacked out for the duration of the ad. - - You might see tiny bits of the ad. -## Applying a solution (uBlock Origin) +## Applying a script (uBlock Origin) - Navigate to the uBlock Origin Dashboard (the extension options) - Under the `My filters` tab add `twitch.tv##+js(twitch-videoad)`. - Under the `Settings` tab, enable `I am an advanced user`, then click the cog that appears. Modify the value of `userResourcesLocation` from `unset` to the full url of the solution you wish to use (if a url is already in use, add a space after the existing url). e.g. `userResourcesLocation https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip-ublock-origin.js` - To ensure uBlock Origin loads the script I recommend that you disable/enable the uBlock Origin extension (or restart your browser). -## Applying a solution (userscript) +## Applying a script (userscript) -- Viewing one of the userscript files should prompt the given script to be added. - -## Other solutions / projects - -For a more detailed description of the following please refer to [this](other-solutions.md). - -- https://github.com/odensc/ttv-ublock (extension - purple screen may display every 10-15 mins) -- https://github.com/Nerixyz/ttv-tools (Firefox extension) -- https://github.com/LeonHeidelbach/ttv_adEraser (extension) -- https://github.com/instance01/Twitch-HLS-AdBlock (extension) -- https://github.com/Wilkolicious/twitchAdSkip (UserScript + FrankerFaceZ) -- https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707 (UserScript + FrankerFaceZ(optional)) -- https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code (UserScript + FrankerFaceZ(optional)) -- [Alternate Player for Twitch.tv](https://chrome.google.com/webstore/detail/bhplkbgoehhhddaoolmakpocnenplmhf) - [code](https://robwu.nl/crxviewer/?crx=bhplkbgoehhhddaoolmakpocnenplmhf&qf=player.js) (extension) -- https://github.com/TTV-LOL/extensions (extension) -- https://github.com/arthurbolsoni/Purple-adblock (extension) -- [Video Ad-Block, for Twitch](https://github.com/saucettv/VideoAdBlockForTwitch) - [chrome](https://chrome.google.com/webstore/detail/video-ad-block-for-twitch/kgeglempfkhalebjlogemlmeakondflc) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/video-ad-block-for-twitch) - ---- - -- https://github.com/streamlink/streamlink (desktop application) -- [multiChat for Twitch](https://play.google.com/store/apps/details?id=org.mchatty) (android app) -- https://twitchls.com/ (external site - purple screen may display every 10-15 mins) -- [Use a VPN targeting a region without ads](https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/) - -## NOTE/TODO - -NOTE: Many of these solutions could do with improvements. -TODO: Test midroll ads. -TODO: More testing in general. +- Viewing one of the userscript files should prompt the given script to be added. \ No newline at end of file diff --git a/full-list.md b/full-list.md new file mode 100644 index 0000000..cf0c44a --- /dev/null +++ b/full-list.md @@ -0,0 +1,41 @@ +## Web browser extensions + +- `TTV LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/ttv-lol/) / [code](https://github.com/TTV-LOL/extensions) + - Uses a proxy on the main m3u8 file to get a stream without ads. +- `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [code](https://github.com/arthurbolsoni/Purple-adblock/) + - Uses a proxy on the main m3u8 file to get a stream without ads. +- `Video Ad-Block, for Twitch` - [chrome](https://chrome.google.com/webstore/detail/video-ad-block-for-twitch/kgeglempfkhalebjlogemlmeakondflc) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/video-ad-block-for-twitch/) / [code](https://github.com/saucettv/VideoAdBlockForTwitch) + - Replaces ad segments with ad-free segments (at either 480p or 1080p). Afterwards it invokes a pause/play to resync the player which then continues normally. +- `Alternate Player for Twitch.tv` - [chrome](https://chrome.google.com/webstore/detail/alternate-player-for-twit/bhplkbgoehhhddaoolmakpocnenplmhf) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/twitch_5/) + - Removes ad segments (no playback until ad-free stream). +- `ttv_adEraser` - [chrome](https://chrome.google.com/webstore/detail/ttv-aderaser/pjnopimdnmhiaanhjfficogijajbhjnc) / [firefox (manual install)](https://github.com/LeonHeidelbach/ttv_adEraser#mozilla-firefox) / [code](https://github.com/LeonHeidelbach/ttv_adEraser) + - Switches to the `embed` player when there's ads. May display purple screen if both ads and purple screen show at the same time? +- `ttv-tools` - [firefox (manual install)](https://github.com/Nerixyz/ttv-tools/releases) / [code](https://github.com/Nerixyz/ttv-tools) + - Removes ad segments (no playback until ad-free stream). +- `ttv-ublock` - [chrome](https://chrome.google.com/webstore/detail/ttv-ad-block/kndhknfnihidhcfnaacnndbolonbimai) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/ttv-adblock/) / [code](https://github.com/odensc/ttv-ublock) + - Switches to the `embed` player at a network level. No ads but Twitch detects this and may display a purple screen every 10-15 mins asking the user to remove ad blockers (depends on time of day). +- `Twitch-HLS-AdBlock` - [chrome / firefox (manual install)](https://github.com/instance01/Twitch-HLS-AdBlock#installation) / [code](https://github.com/instance01/Twitch-HLS-AdBlock) + - Removes ad segments (no playback until ad-free stream). + +## Web browser scripts (uBlock Origin / userscript) + +- https://github.com/pixeltris/TwitchAdSolutions#Scripts + - A few scripts using different techniques. +- https://github.com/Wilkolicious/twitchAdSkip +- https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707/ +- https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code + - Reloads the player (or page) when it detects the ad banner in DOM. + +## Applications / third party websites +- `streamlink` - [code](https://github.com/streamlink/streamlink) / [website](https://streamlink.github.io/streamlink-twitch-gui/) + - Removes ad segments (no playback until ad-free stream). +- `multiChat for Twitch` - [android](https://play.google.com/store/apps/details?id=org.mchatty) + - Unsure how this one blocks ads, but it claims that it does. +- https://twitchls.com/ + - Uses the `embed` player. Purple screen may display every 10-15 mins. +- https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/ + - Some countries don't get ads. A simple VPN/VPS could be used to block ads by proxying the m3u8 without having to proxy all your traffic (just the initial m3u8). + +## Additional lists + +- https://github.com/saucettv/WorkingTwitchAdBlockers \ No newline at end of file diff --git a/mute-black/mute-black-ublock-origin.js b/mute-black/mute-black-ublock-origin.js deleted file mode 100644 index f045e86..0000000 --- a/mute-black/mute-black-ublock-origin.js +++ /dev/null @@ -1,1008 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = true; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/mute-black/mute-black.cfg b/mute-black/mute-black.cfg deleted file mode 100644 index b4df259..0000000 --- a/mute-black/mute-black.cfg +++ /dev/null @@ -1 +0,0 @@ -OPT_MODE_MUTE_BLACK true \ No newline at end of file diff --git a/mute-black/mute-black.user.js b/mute-black/mute-black.user.js deleted file mode 100644 index e7c12b2..0000000 --- a/mute-black/mute-black.user.js +++ /dev/null @@ -1,1019 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (mute-black) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.4 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/mute-black/mute-black.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/mute-black/mute-black.user.js -// @description Multiple solutions for blocking Twitch ads (mute-black) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = true; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/notify-reload/notify-reload-ublock-origin.js b/notify-reload/notify-reload-ublock-origin.js index 44129f8..ca94ccd 100644 --- a/notify-reload/notify-reload-ublock-origin.js +++ b/notify-reload/notify-reload-ublock-origin.js @@ -3,66 +3,36 @@ twitch-videoad.js application/javascript if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } function declareOptions(scope) { // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; + 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_INITIAL_ATTEMPTS = 0; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; + 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.OPT_ACCESS_TOKEN_TEMPLATE = true; + scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; scope.AD_SIGNIFIER = 'stitched-ad'; scope.LIVE_SIGNIFIER = ',live'; scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } // 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()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0; + for (var i = 0; i < bs.length; i++) { + scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; + } } declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; var twitchMainWorker = null; const oldWorker = window.Worker; window.Worker = class Worker extends oldWorker { @@ -78,9 +48,6 @@ twitch-videoad.js application/javascript } var newBlobStr = ` ${processM3U8.toString()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} @@ -99,28 +66,34 @@ twitch-videoad.js application/javascript ` super(URL.createObjectURL(new Blob([newBlobStr]))); twitchMainWorker = this; - var adDiv = null; this.onmessage = function(e) { + // NOTE: Removed adDiv caching as '.video-player' can change between streams? if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; + 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') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; + var adDiv = getAdDiv(); + if (adDiv != null) { + adDiv.style.display = 'none'; + } } else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); + onFoundAd(e.data.isMidroll, e.data.streamM3u8); } else if (e.data.key == 'UboChannelNameM3U8Changed') { //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(); } else if (e.data.key == 'UboPauseResumePlayer') { + reloadTwitchPlayer(false, true); + } + else if (e.data.key == 'UboSeekPlayer') { reloadTwitchPlayer(true); } } @@ -148,164 +121,33 @@ twitch-videoad.js application/javascript req.send(); return req.responseText.split("'")[1]; } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } + 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 si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? + var currentResolution = null; + for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { + if (resUrl == url) { + currentResolution = resName; + //console.log(resName); + break; + } + } + streamInfo.HadAds = 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. + if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) { var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); + var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url); if (encodingsM3u8Response.status === 200) { var encodingsM3u8 = await encodingsM3u8Response.text(); var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; @@ -315,147 +157,135 @@ twitch-videoad.js application/javascript console.log('Notify ad watched. Response has ads: ' + !noAds); } } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); + if (streamInfo.NotifyFirstTime == 0) { + streamInfo.NotifyFirstTime = Date.now(); } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; + if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { + streamInfo.NotifyObservedNoAds = true; } } postMessage({ key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), + isMidroll: streamInfo.IsMidroll, streamM3u8: textStr }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; + if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { + return ''; } - 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); + // 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 (streamM3u8) failed with ' + streamM3u8Response.status); + console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); } } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); + console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } - var backupM3u8 = null; - 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); } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); + // Backups failed. Return nothing (this will likely result in spam or player error 2000?). + console.log('Ad blocking failed. Stream might break.'); + return ''; } + if (streamInfo.HadAds) { + postMessage({key:'UboSeekPlayer'}); + streamInfo.HadAds = false; + } + postMessage({key:'UboHideAdBanner'}); return textStr; } function hookWorkerFetch() { var realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { var processAfter = async function(response) { @@ -482,26 +312,7 @@ twitch-videoad.js application/javascript }); } CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { + 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. @@ -524,13 +335,15 @@ twitch-videoad.js application/javascript } // This might potentially backfire... maybe just add the new urls streamInfo.ChannelName = channelName; - streamInfo.Urls = []; + streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) streamInfo.RootM3U8Url = url; streamInfo.RootM3U8Params = (new URL(url)).search; streamInfo.BackupUrl = null; streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; + streamInfo.BackupRegUrl = null; + streamInfo.BackupRegRes = null; streamInfo.IsMidroll = false; + streamInfo.HadAds = false; streamInfo.NotifyFirstTime = 0; streamInfo.NotifyObservedNoAds = false; streamInfo.RealSeqNumber = -1; @@ -540,7 +353,13 @@ twitch-videoad.js application/javascript 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.push(lines[i]); + 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; + } + } StreamInfosByUrl[lines[i]] = streamInfo; } } @@ -581,37 +400,18 @@ twitch-videoad.js application/javascript } function getAccessToken(channelName, playerType, realFetch) { var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } + 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) { @@ -621,7 +421,7 @@ twitch-videoad.js application/javascript body: JSON.stringify(body), headers: { 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id + 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id } }); } @@ -691,33 +491,6 @@ twitch-videoad.js application/javascript } return 0; } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } function hookFetch() { var realFetch = window.fetch; window.fetch = function(url, init, ...args) { @@ -743,32 +516,12 @@ twitch-videoad.js application/javascript value: gql_device_id }); } - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); + 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; } } } @@ -776,148 +529,16 @@ twitch-videoad.js application/javascript return realFetch.apply(this, arguments); } } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { + 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'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; if (streamM3u8) { tryNotifyAdsWatchedM3U8(streamM3u8); } reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } } } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(isPausePlay) { + 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 @@ -958,6 +579,11 @@ twitch-videoad.js application/javascript if (player.paused) { return; } + if (isSeek) { + console.log('Force seek to reset player (hopefully fixing any audio desync)'); + player.seekTo(0); + return; + } if (isPausePlay) { player.pause(); player.play(); @@ -981,28 +607,5 @@ twitch-videoad.js application/javascript playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false } window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } hookFetch(); - 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 index a42491d..d7011d4 100644 --- a/notify-reload/notify-reload.cfg +++ b/notify-reload/notify-reload.cfg @@ -1,3 +1,5 @@ +OPT_ROLLING_DEVICE_ID true +OPT_MODE_STRIP_AD_SEGMENTS true OPT_MODE_NOTIFY_ADS_WATCHED true -OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS 1 -OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT true \ No newline at end of file +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 index b03a5d5..dd1f886 100644 --- a/notify-reload/notify-reload.user.js +++ b/notify-reload/notify-reload.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions (notify-reload) // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.4 +// @version 1.5 // @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) @@ -14,66 +14,36 @@ 'use strict'; function declareOptions(scope) { // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; + 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_INITIAL_ATTEMPTS = 0; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; + 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.OPT_ACCESS_TOKEN_TEMPLATE = true; + scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; scope.AD_SIGNIFIER = 'stitched-ad'; scope.LIVE_SIGNIFIER = ',live'; scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } // 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()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0; + for (var i = 0; i < bs.length; i++) { + scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; + } } declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; var twitchMainWorker = null; const oldWorker = window.Worker; window.Worker = class Worker extends oldWorker { @@ -89,9 +59,6 @@ } var newBlobStr = ` ${processM3U8.toString()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} @@ -110,28 +77,34 @@ ` super(URL.createObjectURL(new Blob([newBlobStr]))); twitchMainWorker = this; - var adDiv = null; this.onmessage = function(e) { + // NOTE: Removed adDiv caching as '.video-player' can change between streams? if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; + 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') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; + var adDiv = getAdDiv(); + if (adDiv != null) { + adDiv.style.display = 'none'; + } } else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); + onFoundAd(e.data.isMidroll, e.data.streamM3u8); } else if (e.data.key == 'UboChannelNameM3U8Changed') { //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(); } else if (e.data.key == 'UboPauseResumePlayer') { + reloadTwitchPlayer(false, true); + } + else if (e.data.key == 'UboSeekPlayer') { reloadTwitchPlayer(true); } } @@ -159,164 +132,33 @@ req.send(); return req.responseText.split("'")[1]; } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } + 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 si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? + var currentResolution = null; + for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { + if (resUrl == url) { + currentResolution = resName; + //console.log(resName); + break; + } + } + streamInfo.HadAds = 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. + if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) { var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); + var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url); if (encodingsM3u8Response.status === 200) { var encodingsM3u8 = await encodingsM3u8Response.text(); var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; @@ -326,147 +168,135 @@ console.log('Notify ad watched. Response has ads: ' + !noAds); } } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); + if (streamInfo.NotifyFirstTime == 0) { + streamInfo.NotifyFirstTime = Date.now(); } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; + if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { + streamInfo.NotifyObservedNoAds = true; } } postMessage({ key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), + isMidroll: streamInfo.IsMidroll, streamM3u8: textStr }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; + if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { + return ''; } - 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); + // 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 (streamM3u8) failed with ' + streamM3u8Response.status); + console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); } } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); + console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } - var backupM3u8 = null; - 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); } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); + // Backups failed. Return nothing (this will likely result in spam or player error 2000?). + console.log('Ad blocking failed. Stream might break.'); + return ''; } + if (streamInfo.HadAds) { + postMessage({key:'UboSeekPlayer'}); + streamInfo.HadAds = false; + } + postMessage({key:'UboHideAdBanner'}); return textStr; } function hookWorkerFetch() { var realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { var processAfter = async function(response) { @@ -493,26 +323,7 @@ }); } CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { + 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. @@ -535,13 +346,15 @@ } // This might potentially backfire... maybe just add the new urls streamInfo.ChannelName = channelName; - streamInfo.Urls = []; + streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) streamInfo.RootM3U8Url = url; streamInfo.RootM3U8Params = (new URL(url)).search; streamInfo.BackupUrl = null; streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; + streamInfo.BackupRegUrl = null; + streamInfo.BackupRegRes = null; streamInfo.IsMidroll = false; + streamInfo.HadAds = false; streamInfo.NotifyFirstTime = 0; streamInfo.NotifyObservedNoAds = false; streamInfo.RealSeqNumber = -1; @@ -551,7 +364,13 @@ 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.push(lines[i]); + 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; + } + } StreamInfosByUrl[lines[i]] = streamInfo; } } @@ -592,37 +411,18 @@ } function getAccessToken(channelName, playerType, realFetch) { var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } + 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) { @@ -632,7 +432,7 @@ body: JSON.stringify(body), headers: { 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id + 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id } }); } @@ -702,33 +502,6 @@ } return 0; } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } function hookFetch() { var realFetch = window.fetch; window.fetch = function(url, init, ...args) { @@ -754,32 +527,12 @@ value: gql_device_id }); } - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); + 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; } } } @@ -787,148 +540,16 @@ return realFetch.apply(this, arguments); } } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { + 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'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; if (streamM3u8) { tryNotifyAdsWatchedM3U8(streamM3u8); } reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } } } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(isPausePlay) { + 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 @@ -969,6 +590,11 @@ if (player.paused) { return; } + if (isSeek) { + console.log('Force seek to reset player (hopefully fixing any audio desync)'); + player.seekTo(0); + return; + } if (isPausePlay) { player.pause(); player.play(); @@ -992,28 +618,5 @@ playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false } window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } })(); diff --git a/notify-strip-reload/notify-strip-reload-ublock-origin.js b/notify-strip-reload/notify-strip-reload-ublock-origin.js deleted file mode 100644 index db3225c..0000000 --- a/notify-strip-reload/notify-strip-reload-ublock-origin.js +++ /dev/null @@ -1,1008 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/notify-strip-reload/notify-strip-reload.cfg b/notify-strip-reload/notify-strip-reload.cfg deleted file mode 100644 index cf2b615..0000000 --- a/notify-strip-reload/notify-strip-reload.cfg +++ /dev/null @@ -1,5 +0,0 @@ -OPT_MODE_STRIP_AD_SEGMENTS true -OPT_MODE_STRIP_AD_SEGMENTS_NEWEST true -OPT_MODE_NOTIFY_ADS_WATCHED true -OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST true -OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD true \ No newline at end of file diff --git a/notify-strip-reload/notify-strip-reload.user.js b/notify-strip-reload/notify-strip-reload.user.js deleted file mode 100644 index 0bbe022..0000000 --- a/notify-strip-reload/notify-strip-reload.user.js +++ /dev/null @@ -1,1019 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (notify-strip-reload) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.4 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip-reload/notify-strip-reload.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip-reload/notify-strip-reload.user.js -// @description Multiple solutions for blocking Twitch ads (notify-strip-reload) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - 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 index 080767f..b053b7d 100644 --- a/notify-strip/notify-strip-ublock-origin.js +++ b/notify-strip/notify-strip-ublock-origin.js @@ -3,66 +3,36 @@ twitch-videoad.js application/javascript if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } function declareOptions(scope) { // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; + 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_INITIAL_ATTEMPTS = 0; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; + 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.OPT_ACCESS_TOKEN_TEMPLATE = true; + scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; scope.AD_SIGNIFIER = 'stitched-ad'; scope.LIVE_SIGNIFIER = ',live'; scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } // 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()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0; + for (var i = 0; i < bs.length; i++) { + scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; + } } declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; var twitchMainWorker = null; const oldWorker = window.Worker; window.Worker = class Worker extends oldWorker { @@ -78,9 +48,6 @@ twitch-videoad.js application/javascript } var newBlobStr = ` ${processM3U8.toString()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} @@ -99,28 +66,34 @@ twitch-videoad.js application/javascript ` super(URL.createObjectURL(new Blob([newBlobStr]))); twitchMainWorker = this; - var adDiv = null; this.onmessage = function(e) { + // NOTE: Removed adDiv caching as '.video-player' can change between streams? if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; + 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') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; + var adDiv = getAdDiv(); + if (adDiv != null) { + adDiv.style.display = 'none'; + } } else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); + onFoundAd(e.data.isMidroll, e.data.streamM3u8); } else if (e.data.key == 'UboChannelNameM3U8Changed') { //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(); } else if (e.data.key == 'UboPauseResumePlayer') { + reloadTwitchPlayer(false, true); + } + else if (e.data.key == 'UboSeekPlayer') { reloadTwitchPlayer(true); } } @@ -148,164 +121,33 @@ twitch-videoad.js application/javascript req.send(); return req.responseText.split("'")[1]; } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } + 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 si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? + var currentResolution = null; + for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { + if (resUrl == url) { + currentResolution = resName; + //console.log(resName); + break; + } + } + streamInfo.HadAds = 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. + if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) { var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); + var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url); if (encodingsM3u8Response.status === 200) { var encodingsM3u8 = await encodingsM3u8Response.text(); var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; @@ -315,147 +157,135 @@ twitch-videoad.js application/javascript console.log('Notify ad watched. Response has ads: ' + !noAds); } } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); + if (streamInfo.NotifyFirstTime == 0) { + streamInfo.NotifyFirstTime = Date.now(); } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; + if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { + streamInfo.NotifyObservedNoAds = true; } } postMessage({ key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), + isMidroll: streamInfo.IsMidroll, streamM3u8: textStr }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; + if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { + return ''; } - 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); + // 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 (streamM3u8) failed with ' + streamM3u8Response.status); + console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); } } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); + console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } - var backupM3u8 = null; - 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); } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); + // Backups failed. Return nothing (this will likely result in spam or player error 2000?). + console.log('Ad blocking failed. Stream might break.'); + return ''; } + if (streamInfo.HadAds) { + postMessage({key:'UboSeekPlayer'}); + streamInfo.HadAds = false; + } + postMessage({key:'UboHideAdBanner'}); return textStr; } function hookWorkerFetch() { var realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { var processAfter = async function(response) { @@ -482,26 +312,7 @@ twitch-videoad.js application/javascript }); } CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { + 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. @@ -524,13 +335,15 @@ twitch-videoad.js application/javascript } // This might potentially backfire... maybe just add the new urls streamInfo.ChannelName = channelName; - streamInfo.Urls = []; + streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) streamInfo.RootM3U8Url = url; streamInfo.RootM3U8Params = (new URL(url)).search; streamInfo.BackupUrl = null; streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; + streamInfo.BackupRegUrl = null; + streamInfo.BackupRegRes = null; streamInfo.IsMidroll = false; + streamInfo.HadAds = false; streamInfo.NotifyFirstTime = 0; streamInfo.NotifyObservedNoAds = false; streamInfo.RealSeqNumber = -1; @@ -540,7 +353,13 @@ twitch-videoad.js application/javascript 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.push(lines[i]); + 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; + } + } StreamInfosByUrl[lines[i]] = streamInfo; } } @@ -581,37 +400,18 @@ twitch-videoad.js application/javascript } function getAccessToken(channelName, playerType, realFetch) { var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } + 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) { @@ -621,7 +421,7 @@ twitch-videoad.js application/javascript body: JSON.stringify(body), headers: { 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id + 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id } }); } @@ -691,33 +491,6 @@ twitch-videoad.js application/javascript } return 0; } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } function hookFetch() { var realFetch = window.fetch; window.fetch = function(url, init, ...args) { @@ -743,32 +516,12 @@ twitch-videoad.js application/javascript value: gql_device_id }); } - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); + 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; } } } @@ -776,148 +529,16 @@ twitch-videoad.js application/javascript return realFetch.apply(this, arguments); } } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { + 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'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; if (streamM3u8) { tryNotifyAdsWatchedM3U8(streamM3u8); } reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } } } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(isPausePlay) { + 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 @@ -958,6 +579,11 @@ twitch-videoad.js application/javascript if (player.paused) { return; } + if (isSeek) { + console.log('Force seek to reset player (hopefully fixing any audio desync)'); + player.seekTo(0); + return; + } if (isPausePlay) { player.pause(); player.play(); @@ -981,28 +607,5 @@ twitch-videoad.js application/javascript playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false } window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } hookFetch(); - 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 index 7664ba0..76a756a 100644 --- a/notify-strip/notify-strip.cfg +++ b/notify-strip/notify-strip.cfg @@ -1,4 +1,5 @@ +OPT_ROLLING_DEVICE_ID true OPT_MODE_STRIP_AD_SEGMENTS true -OPT_MODE_STRIP_AD_SEGMENTS_NEWEST true OPT_MODE_NOTIFY_ADS_WATCHED true -OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST true \ No newline at end of file +OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST 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 index df97ca7..3481cde 100644 --- a/notify-strip/notify-strip.user.js +++ b/notify-strip/notify-strip.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions (notify-strip) // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.4 +// @version 1.5 // @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) @@ -14,66 +14,36 @@ 'use strict'; function declareOptions(scope) { // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; + 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_INITIAL_ATTEMPTS = 0; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; + 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.OPT_ACCESS_TOKEN_TEMPLATE = true; + scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; scope.AD_SIGNIFIER = 'stitched-ad'; scope.LIVE_SIGNIFIER = ',live'; scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } // 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()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0; + for (var i = 0; i < bs.length; i++) { + scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; + } } declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; var twitchMainWorker = null; const oldWorker = window.Worker; window.Worker = class Worker extends oldWorker { @@ -89,9 +59,6 @@ } var newBlobStr = ` ${processM3U8.toString()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} @@ -110,28 +77,34 @@ ` super(URL.createObjectURL(new Blob([newBlobStr]))); twitchMainWorker = this; - var adDiv = null; this.onmessage = function(e) { + // NOTE: Removed adDiv caching as '.video-player' can change between streams? if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; + 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') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; + var adDiv = getAdDiv(); + if (adDiv != null) { + adDiv.style.display = 'none'; + } } else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); + onFoundAd(e.data.isMidroll, e.data.streamM3u8); } else if (e.data.key == 'UboChannelNameM3U8Changed') { //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(); } else if (e.data.key == 'UboPauseResumePlayer') { + reloadTwitchPlayer(false, true); + } + else if (e.data.key == 'UboSeekPlayer') { reloadTwitchPlayer(true); } } @@ -159,164 +132,33 @@ req.send(); return req.responseText.split("'")[1]; } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } + 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 si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? + var currentResolution = null; + for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { + if (resUrl == url) { + currentResolution = resName; + //console.log(resName); + break; + } + } + streamInfo.HadAds = 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. + if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) { var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); + var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url); if (encodingsM3u8Response.status === 200) { var encodingsM3u8 = await encodingsM3u8Response.text(); var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; @@ -326,147 +168,135 @@ console.log('Notify ad watched. Response has ads: ' + !noAds); } } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); + if (streamInfo.NotifyFirstTime == 0) { + streamInfo.NotifyFirstTime = Date.now(); } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; + if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { + streamInfo.NotifyObservedNoAds = true; } } postMessage({ key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), + isMidroll: streamInfo.IsMidroll, streamM3u8: textStr }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; + if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { + return ''; } - 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); + // 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 (streamM3u8) failed with ' + streamM3u8Response.status); + console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); } } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); + console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } - var backupM3u8 = null; - 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); } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); + // Backups failed. Return nothing (this will likely result in spam or player error 2000?). + console.log('Ad blocking failed. Stream might break.'); + return ''; } + if (streamInfo.HadAds) { + postMessage({key:'UboSeekPlayer'}); + streamInfo.HadAds = false; + } + postMessage({key:'UboHideAdBanner'}); return textStr; } function hookWorkerFetch() { var realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { var processAfter = async function(response) { @@ -493,26 +323,7 @@ }); } CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { + 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. @@ -535,13 +346,15 @@ } // This might potentially backfire... maybe just add the new urls streamInfo.ChannelName = channelName; - streamInfo.Urls = []; + streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) streamInfo.RootM3U8Url = url; streamInfo.RootM3U8Params = (new URL(url)).search; streamInfo.BackupUrl = null; streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; + streamInfo.BackupRegUrl = null; + streamInfo.BackupRegRes = null; streamInfo.IsMidroll = false; + streamInfo.HadAds = false; streamInfo.NotifyFirstTime = 0; streamInfo.NotifyObservedNoAds = false; streamInfo.RealSeqNumber = -1; @@ -551,7 +364,13 @@ 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.push(lines[i]); + 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; + } + } StreamInfosByUrl[lines[i]] = streamInfo; } } @@ -592,37 +411,18 @@ } function getAccessToken(channelName, playerType, realFetch) { var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } + 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) { @@ -632,7 +432,7 @@ body: JSON.stringify(body), headers: { 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id + 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id } }); } @@ -702,33 +502,6 @@ } return 0; } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } function hookFetch() { var realFetch = window.fetch; window.fetch = function(url, init, ...args) { @@ -754,32 +527,12 @@ value: gql_device_id }); } - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); + 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; } } } @@ -787,148 +540,16 @@ return realFetch.apply(this, arguments); } } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { + 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'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; if (streamM3u8) { tryNotifyAdsWatchedM3U8(streamM3u8); } reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } } } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(isPausePlay) { + 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 @@ -969,6 +590,11 @@ if (player.paused) { return; } + if (isSeek) { + console.log('Force seek to reset player (hopefully fixing any audio desync)'); + player.seekTo(0); + return; + } if (isPausePlay) { player.pause(); player.play(); @@ -992,28 +618,5 @@ playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false } window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } })(); diff --git a/other-solutions.md b/other-solutions.md deleted file mode 100644 index 3732d9e..0000000 --- a/other-solutions.md +++ /dev/null @@ -1,32 +0,0 @@ -Web browser extensions / scripts: - -- https://github.com/odensc/ttv-ublock - - Simulates `embed` Twitch player at a network level (doesn't modify DOM). - - Twitch detects this and may display a purple screen every 10-15 mins asking the user to remove ad blockers. -- https://github.com/Nerixyz/ttv-tools - - Removes ad segments which cannot be skipped (loading wheel until ad-free stream). - - Can keep stream at lowest latency (speeds up stream if too far behind). -- https://github.com/LeonHeidelbach/ttv_adEraser - - Modifies DOM to switch between the `embed` player when there's ads. May display purple screen if both ads and purple screen show at the same time? -- https://github.com/instance01/Twitch-HLS-AdBlock - - Removes ad segments. May result in m3u8 being requested quickly in succession if all segments are removed. -- https://github.com/Wilkolicious/twitchAdSkip -- https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707/ -- https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code - - Reloads the player (or page) when it detects the ad banner in DOM. -- [Alternate Player for Twitch.tv](https://chrome.google.com/webstore/detail/bhplkbgoehhhddaoolmakpocnenplmhf) - [code](https://robwu.nl/crxviewer/?crx=bhplkbgoehhhddaoolmakpocnenplmhf&qf=player.js) - - Removes ad segments which cannot be skipped. The player will freeze on the last live frame until no more ads. -- https://github.com/TTV-LOL/extensions - - Uses a proxy on the main m3u8 file to get a stream without ads (no prerolls / midrolls). -- [Video Ad-Block, for Twitch](https://gist.github.com/saucettv/0f85e9051c7d25aee67fdc033609fe1d) - [chrome](https://chrome.google.com/webstore/detail/video-ad-block-for-twitch/kgeglempfkhalebjlogemlmeakondflc) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/video-ad-block-for-twitch) - - Sets the stream to an ad-free variant which is limited to 480p for the duration of the ad. Switches back to the regular stream after the ad. - -Applications / third party websites: -- https://github.com/streamlink/streamlink - - Removes ad segments (I assume this will freeze on the last live frame until no more ads). -- [multiChat for Twitch](https://play.google.com/store/apps/details?id=org.mchatty) - - Unsure how this one blocks ads, but it claims that it does. -- https://twitchls.com/ - - Uses the `embed` player. Purple screen may display every 10-15 mins. -- https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/ - - Some countries don't get ads. A simple VPN/VPS could be used to block ads by proxying the m3u8 without having to proxy all your traffic (just the initial m3u8). diff --git a/proxy-m3u8/proxy-m3u8-ublock-origin.js b/proxy-m3u8/proxy-m3u8-ublock-origin.js deleted file mode 100644 index 1c283f9..0000000 --- a/proxy-m3u8/proxy-m3u8-ublock-origin.js +++ /dev/null @@ -1,1008 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = 'http://127.0.0.1/twitch-m3u8/'; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/proxy-m3u8/proxy-m3u8.cfg b/proxy-m3u8/proxy-m3u8.cfg deleted file mode 100644 index 73a6f8d..0000000 --- a/proxy-m3u8/proxy-m3u8.cfg +++ /dev/null @@ -1 +0,0 @@ -OPT_MODE_PROXY_M3U8 'http://127.0.0.1/twitch-m3u8/' \ No newline at end of file diff --git a/proxy-m3u8/proxy-m3u8.user.js b/proxy-m3u8/proxy-m3u8.user.js deleted file mode 100644 index 9a8e997..0000000 --- a/proxy-m3u8/proxy-m3u8.user.js +++ /dev/null @@ -1,1019 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (proxy-m3u8) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.4 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8.user.js -// @description Multiple solutions for blocking Twitch ads (proxy-m3u8) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = 'http://127.0.0.1/twitch-m3u8/'; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - 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 index c22a072..648372d 100644 --- a/strip-alt/strip-alt-ublock-origin.js +++ b/strip-alt/strip-alt-ublock-origin.js @@ -17,25 +17,34 @@ twitch-videoad.js application/javascript var newBlobStr = ` ${processM3U8.toString()} ${hookWorkerFetch.toString()} + ${pushSegUrlInfo.toString()} AD_SIGNIFIER = 'stitched-ad'; LIVE_SIGNIFIER = ',live'; - StreamInfos = []; - StreamInfosByUrl = []; + IsMidroll = false; + HasAd = false; + StreamUrlCache = []; hookWorkerFetch(); importScripts('${jsURL}'); ` super(URL.createObjectURL(new Blob([newBlobStr]))); twitchMainWorker = this; - var adDiv = null; this.onmessage = function(e) { if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; + 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() { @@ -62,21 +71,33 @@ twitch-videoad.js application/javascript 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 streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; + var dateNow = new Date(); + for (const [segUrl, segUrlInfo] of Object.entries(StreamUrlCache)) { + if (segUrlInfo.expireDate < dateNow) { + delete StreamUrlCache[segUrl]; + } } - streamInfo.IsMidroll = textStr.includes('MIDROLL'); + // 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')) { - // Someone should make this a regex... 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] + '="'; @@ -85,9 +106,14 @@ twitch-videoad.js application/javascript line = line.substring(0, adTagIndex) + adTag + 'http://twitch.tv' + line.substring(adTagEndIndex); } lines[i] = line; - } - else if (line.startsWith('#EXTINF') && !line.includes(LIVE_SIGNIFIER) && lines.length > i + 1) { - streamInfo.AdUrlCache[lines[i + 1]] = 1; + } 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'); @@ -99,18 +125,14 @@ twitch-videoad.js application/javascript fetch = async function(url, options) { if (typeof url === 'string') { if (url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.AdUrlCache[url]; - if (seg) { - url = 'data:image/png;base64,I0VYVE0zVQojRVhULVgtVkVSU0lPTjozCiNFWFQtWC1UQVJHRVREVVJBVElPTjo2CiNFWFQtWC1NRURJQS1TRVFVRU5DRToxNzY0NwojRVhULVgtVFdJVENILUVMQVBTRUQtU0VDUzozNDY2Ni4xMTcKI0VYVC1YLVRXSVRDSC1UT1RBTC1TRUNTOjM0Njk4LjExNwojRVhULVgtREFURVJBTkdFOklEPSJzb3VyY2UtMTYxMzMzODM4NiIsQ0xBU1M9InR3aXRjaC1zdHJlYW0tc291cmNlIixTVEFSVC1EQVRFPSIyMDIxLTAyLTE0VDIxOjMzOjA2LjUzNloiLEVORC1PTi1ORVhUPVlFUyxYLVRWLVRXSVRDSC1TVFJFQU0tU09VUkNFPSJsaXZlIgojRVhULVgtREFURVJBTkdFOklEPSJ0cmlnZ2VyLTE2MTMzMzgzODIiLENMQVNTPSJ0d2l0Y2gtdHJpZ2dlciIsU1RBUlQtREFURT0iMjAyMS0wMi0xNFQyMTozMzowMi43MzZaIixFTkQtT04tTkVYVD1ZRVMsWC1UVi1UV0lUQ0gtVFJJR0dFUi1VUkw9Imh0dHBzOi8vdmlkZW8td2VhdmVyLmxocjAzLmhscy50dHZudy5uZXQvdHJpZ2dlci9DdjhFZmtQT29POHBCRkxpeEhpVzVzQkh0ajF3VWR0SnhMc2RRZFlxaHZIakpvY05HaTlxeExQbEowNDNrRS03UmtaQWxUZUVWbi1mVVE1ZHJ5RVFFVVhDbTJYZWFtZk1XbHY4aDAxcDlVam1wbEpQWXNqbzRjRzRlaWJRakhBQkJTbkdfMWtCS25YdEUtc0ljZTlsZXdKSlZYdmRsN19FM2gyYmpCMVpWVU5KT29DNzFvLXpFZFRvNUszX2RQcVhKWDE5Y2lpMEZ5VnQ3dVZEaklKYzNVMGhrYmM0cGVOaDRZbEVkUVlkSWE3OTFQWDdfTGJDZmJkdWdTUXFrNXhLX2NUNlpHTE8yWDNVUU9lTDhSTWRlVkpIVllWUDVxYmQyNWZ4MzlqcWRsTTBLeEJRS1lVVk9iWmprTEtQd3RWMEpQeFFzZ0dFSVRZb2hKMm1KV29UTUktQ01rQTRPTDhpSTZZTHB1WmVneUVBeGRaUERzMUlucWFhSVpTUUlxUl9HOGZJYXZvWUVoa3BwRDNpN1NnaDhKaThaQ253d3MtZ0ZHUnRvRVhWSFZPZlZjZHEtQThmZURMNGZJNDlrS0xtSy12Tkc4VTNvU3ZQbFN2LWx5eHlxYnZNMk83blBPZDhSUFpoNUgxSmVmWDZDbENpUmNXYm95Qk9NcXZ2RGw4OERqbG1faTdXdmNHTmlXdjdKMl9tallKdlM3b0d2bUFBVlFaVFNPbUZDVDItaWtsdjZQVDdKZkpTV2h6ajY0SEV2QTVlazdvNWFweExOTy13OWpHbkR1SjJrRTBTblZoVHhXV1dVZm5GVERTYjJJcFgyWmlZMUFhUkt6NDNkYmRnbl9CYUtYR3dlRkdUVHNJOHByVzZQdjJCNE9uakZ0YVh2M1Zzd1VVVDRaVnNGM0E3VHZWM25nSDJrVVAtdFVRcTBJSzZ2YWFKeHFEaXR1c3JDeHk3bUh3ZTJYZWc0X2pPaTdfaTRMVWhKY3VJdU04VzA0WGRXWnhmb0lFVlotdnZBQUU0bDVZbTNKSm12SXhXTG1vZUd2ckVMOEs1R1Q3azhJanZSbG90OXlNeUNYTDNReVYzTWlwa0FvbVNfY2pCS0V2UjMwRGFneHFMWXl1bDZ5NHlrOGdTRWhCemRHcXkwT1ZZM0pvT1pkb2lhT3JuR2d5XzV0RFpCUDdaMjNGZF9LWSIKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZTenBUOGtSckxTbVh6YU5fWTN4dnJnWlF3eUlyYU5WZUxJTk41X2JHNXAyTVdSTU9uOGJsWTQwV2RjT3FMeVZOMHllUFQ2WWNjcENBZEEyRjd3NGVXb081dTRlUHRqM3ZOdjJsZS1QQ0ZrNHhIOEJ6X3VKWVRkZXN4M2tITUQtVkJwc0NDdlQwdUlVc1ZPQTdJd21CNGNWSHUxY2tiRUwzU2pxaGpUS3BqYjRfME1nd3pnZ2hkYzlHRkJ3UTdJN3V0MUlPaVhNbko5RnlLUHd6bGpBV3BsSjlRS0xLeVFHalhmT1dwRG5ReGJFdk5fc3BVakhjNTRHa0t4b3p1bDJGeXVZTEwtZE1PWURUTXl2cGJua3U3NFhJZzBZcFYwLXpDMDd5OGpPTjFJT2RsZTdtbDVpbFFXTFV0MS1QZ2pmOWpBN1RlZEtPX0RsaHYwNUlJdlJueGltU3ktVTQwT0JjZ3BISUo3M3JVaDRBMjdrRjVySnk1R1RfcERjWlRjSkFESjF5N19pekMzNEpyVl9jR2FlbE0waDhSX2VBOEV3ZmRQNHI5dXNHZi13THVPaVRsdGpUY21JVHZPUWo5VE5HX2FkUTEyRUJaQk00VzVSSEtXUDUxLWRnRnRzWmtNWUMwdnVqN0FDNXlscW5Ec01RU285UlZTSklkakdpX2VHT2FuMjhkUGt0RThQM2duemlVeWZsZWdfWTlzVkxDbi1jQzBzSzBKTGN3bW5GRmxUc0xjR01vVFFtQWtLeTZDZGNsMmhLSVFUUVhKQ2FrbjVWc19aQ0JENk9CMS1UN2U5WEhadm5QR251cVo1RUNPaHRmbEI4Xzd2aXdJZHU2SDhMaGg4WkdwVzBXalhNbGltWGpxS1cyN09kWmU1Mi01RHdzNTlybEN1Mm5nUUxBRUwwNjJ0N1A3TWZUZ0NfNmlabDVicmdGRWMtZDFhamZOM2I4V2pVaGYzb1VJWTVRRld2ZDdma0J1UXVjMDJpTlZVemRWblM2Q2lLTG9sY285b3BtNy1MQWdqYWYtVkhaMDZncExGS3BUTXlEWFBWYjFuSjhqdnRKQTNmMHJvVVJacTdrMERfMkx4SkxuQXhsMWNueVZSaWp3ZXAyeDlLc0hIbTVoaEJ2RnJidThLUEF5M3czWG0tWXBiMHR2V3FtOThxM2RyYUVoQ2RsaFZJQmxIZFdCWDVxeEN3b3Rib0dndzBUNXVxUE9ZVUk1b1NSQ1kudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Zaa09EYUxhV2xLRGVzUU54eTlfcDZvbTNLX3BHbWlCcUd4bk8wN0JDaTItN0JEUzlnZENkYXlXeF9JTEhZaTVNWUY0eVNEaTU0RWYxWVhmLTJ2MHYxS244TXpxdll4bFVVR3R6TkJlQmNrc2w3MDJZb0xpY3VJM1l0WHVfOUV6QVdibW4xdWRfUWdMdTItRlV3WXF3NG00TUdvOHh2NUhUZjBJa1JVRk9tZFVWdjNLeUllakc0aGVtQzRoeUNPd1NhRDZZUmotdFEtRm1FSnNXQTQ3ZDZNdHExeDFuV2gyTDZBYWhJOFFVVzQ4T1ozMW9WR3FjYTQxUmZnUVNoSkMzN09XQk1oS2V1OFBQX0tDQkduUzlvaDNZOVNIQWNlYWh6anRDTk9VdFhGUnRyZEtBdV84XzAtMUh6U1h0NDJzYTBIWno2aXJkc25HVmtBTnpwY3lkMDI1UENpUWxQbHNQa2FPSVA4T0FqWXJoQlUydG5rS0tPNnQyWkFnc00yZ21KS0pGVVpab2J3Zi1qYWNXY1JNeXlHVjFQaWlvWWdPRjFvZ0hwdnUzUzE5ZThtdzFEVzQ0WkVYUUtzRWc4b2h6dUZNWEc2REJHZ3RLUzNxam0zeGJjQWFSUFFsVkoxRUp2SWRhQ1VUTGEyVjRLR3BZbUpUdkk2Q0hGcEtodnFnYWhtYUtvTDdqcmZIbzNuWENqRmVwUzBudlc2X1BPWTRFZ0xjNDJiQ3BILVVBMDNXdGxMUEFqeTNZeEVaV1hEdHZlaHRDR2k2ME5CMkNOb2hVclE5UWZfdVhYTlNWT0dMeTAwTmo4Mkh3TEh6WWJwRGZNY3I5QndrS0xVZHlTekpwbmtmVGY0VjRBaEtjb3ByU2N1cXRScUJNUFFmdnRCUGFvemd5V3NHM1BvaE1QXzJOWVdmcEdaMXBfbDA3ZXlrbUhBQXVGbTNCcWs1eEZXdFdYTmhfT0ppRXZWMzlkam1KVk5wOFl6WGRhZUxRODQ2RkQ0WTR1S2NucU9UNnhleFdYSEdaSEF3THlFVW1EVUkycWJzdWR1X0VETEJDWlFJbWNUSWtMc0d1WWtaSHRWZmJ3SkVBTEtJMFFIT2s4dTdWZ0ZFcXhROFNDLXJWZmJjbWJZZjVJalpiYTdoaGdydjNoTDRFempHRWExbnQtSDk3clRURkVoQmdMRkp0RWRVX3hzRENGMlFOVEI4Z0dneWs0OEsxX0w3aTdxYUtUbTgudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZFTURGOUNTR21kYzNzTnRkRDR4d0NXV3hCN3NWNVptdV8waWZ2UF9SeE1rYVBYeVc5YndEcmdSclBqckpLMTc1TXBacVlMVzYxdzRxcEFYRUV0bUh4S1JJZzlzc3RIMnZxb2ZvSWo4TjM1RGxleE5SX3RHYV9XRmZuQ1hSNWhrYmQ3YTRxNjRHWk14cjBtMTYzOHdYdG9FWFlkbHN4N09YTjVVNWwyVG53NTlJOElaa0pvaF9wRHZrTDNRQVVic1Vsc1BPRWNrZzVsWVRVR2FHMnYybTZlNEhYQmo4Y05JcjQ1bEZ4T3J6VTVCeGlqZ3pUeEwwVWFhRXl0TWVyV2IzakVSaVFBZ2R4WkdUNzJ1YUt1XzU1TG9RN0w2czM3ZzNpVTF3SThITHRXWUVOVkpUU3dpRHJiRGdGOUgtb1FUZ0lEVDBiNDhpeS1ZVk05WldhZzZ4OEl1UVI4ZG5haFBMTDFMSDY2Y2xXd2Y2MXk4ZFZSZUdHbUN4X040Y1QyQnVJRmduclN0VEZ0R1M0NXZCM0dWN2RUbWpXZ3lqZFBINEZlUHVrOFZiejJ2bjJQQk8yNlRYOVpnSkZDYUJTQTFGcXFNTHh1bUdKWUZIMzZGUnV4T19FcVpCVDVWQ2xXa21Rb0pFNHFWbm9OQllTeWxGUTBQRHFoeGtLZUd3bUR1Sm94b2UwNW9jWl9YOEFDMUoyZDI3dllmamhYaHFYeWJVeEx4WXk4YVVOR1JsdEs2RDE4OVFGMjhWUGw5U3NZWDRwazE4QVBvel9uRVRWb3hEUFdkVlFtVVlPTTYtckRBR3dpbG1JN3JKUmRkdVE0M1JueXZFSEotWDN0OGlQZ0hzQlR0dUpZaE12UllucVJqVEhwekN1eDc2LTFUSTlyclhHam5nbXY0UXA0YjE5ZUJQVXc2eXVzdWVTeDVMNnY4eGlLUmZ0X3NXSDRjNWlscGJPSWhFbGxXcXhROUJWZ0k4bG9IR3ZoQkE3NzZIRTJ4U09SWklXSUdOSDRvc3VEaW9CX0FGQUEwMlFsdGxRMFUtWlgtU3JYNG45b3YtQjJsTmdWQV8xb3JVYnBCWkI4cmdDSjdmek9MTFRHVnFQckx3cHNVMEhQS3ZFbkgyd1gyRHJORTgwa3ZYbXhzNU9iRURMWGNMQzhUR2RCOHlDV1oweE1iSEVoQlRhSDNfSTM5XzdENzB0bFlGLVlPMEdneTcxUktEVEtlMk52MVNCdDAudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Y4cnFaTFJnRDQtNXVjQnFldW55dU01X1RHNXZOeW5qLXBwX0ZqclAtUEtHemluR1UzQjdqclFWYUhwQTBReXZYWDJ0VzQzbDZEX1hOVlVoN1BtT0l2MjVnbUF1TThheEc2RGh0WEtCR0ptUWlCbzhrZnZxeWZfbW5OYnRlYkRic1c4Zk95cEhkd3BWVHlBV2daanhqczVKaDV1ZWlkUDJOOTZxMWRuM2VRSHZ0V1R0NFE2dWlvSk1pb0FuLUNvQVMyUjR5WThScXlUYkIyMlQ2eUR0SzBHbkpnbEl1WGdKUHB3dXF6ZnRqRmxCVXhvd1VxWE5mU0FreU5zemJsMTV4eW05OExEMXBQZkFEUi1ra25fVFdzeTJtNlBjblh1LWdTQnZ0aWcwYmNGRThiSThjOC14R3REZ0dsWU93YllmandINW94NmFoV0NPSnY1NTRMT19Bc1hvcnU5cGJWLXBlSkUtbGhNMHRVQnJYU2R1eHRBWERwSmtlMzJtX3dwa3FnTDZWLVZyYmFXNS0ycFVSM19DaklCOUJYOXBqWmJKWjdDMVdNZDFPLWJ2SE1IS1psOU1aWmtEYllTVjExRS16X1VpcktxWHBSLVlnZWx5aVA2eVhuUEVlczRlblliYUNDTXFKZmpncGotNURyTkFia2xOU0Qwbm43WFR1UjN5MFpHdkVZT182QU01OTQ4NHlLSllheERKeWFfNnZhU08xc21XYzZ3R3RDdHJfLWNOLS1hczBubHlMcXktU0JnRTZhUHN3ck5qZGo4VUJYUS1iaDMtV2hJaUtnbjlXSVdiR1ZPNlVZaTVyMU9tcTcwdEVvQldoaDZzNVFhN2dBWEpaanVXZ0ZaQnlXQnhEbU00WUpWN1VBUk40MzBtX2hyUzdfTU5XQVQ3ZVpSampQTzFFOGpsMzBvZlJJZ2ZPOU5hZjJ5WUJqWlFHTmN5ZFI2YVlKN0dvYk9sa05oUEpwbk0tSTN2NEtmNmNsU1Q2Vnk5RWtmQ29PYmNBTk1NY003bE1rdmZNSElzbWV2d0FraW1jQWwzQmp5X1lhdTBWdG1pclBFc2lSQVo3LUFobnBWWkR2U0s5b1hEN21EV0hCM0s0TVVkT3RZRzZDZ2RfUHFzZEVaYWY1RXhjdnhDbU9YY3BTelp4YjRDcHlLYW9JR3p0S1RZd0VoRDQ2YWNQR3llcDhCVGRPYTVIbzc1ZUdndzdGek5XZE1DS2ZGaVBBaVkudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjAuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZzRnVJS1dnM3RCcHc5cVVyLUplTl9FdHVuSnlSbm9nbXBrUmMzVkRyUFloS3JscXp2YnB3dlFQdy1oaWxaU1dXeHIxS2xzcjU0bWU1cUJKQzZrbEVmMG9INFRrWXROQ1FWaTBmTE1IUVg2S0l0eXdSanU1QVZXS3EyQVFYVXlwcVozRktVNG5sTjgycUk1Sk90WXh3MTgyS2lMcmhtUXFEQmE3QUl3dTVLRGw0MEtjczhaYzI0dUVZUlp6ZmMwQTJGNjVGWVBURzk2MUFvdkxJc3NTb01OSEdOZkR1S21tN1YtWGxnaUxqdXl0RXlMVW94a2lKbVh2anpzSmZHeGRnNExxRExhaFk4TzVDRl81YXlrU2xuOURCZ2t1SnpjNGdBTmRzdndRdDFNUkZUX0Eta0Z0NEZEQ2dZcmNfN0FEbzNUSy00SHAtemltVm5QUHB5S1JKQ0NFb3YwblhXckI1dFBUQ3RiUDF2ZEpiVk1EOFVNcllWekVqZnZUeVU1cWNQTDkzMzJIcFdsazdhdjhuaU80TlZuSHBsV2Rabkh2Y1JhVVdfSmZldlpMVHhWXy1YSTFyU3M3a1ppdjNBSWptWmI3ZHdRYlozUmd6b1IxNmlSME9lWDZkNU5KSWU4cjZqTUFWTk82czRzTExHbXRDYXJBYWJTLWU1bnVFMGxpMVdnWEMzZ0pxanpDZlJ2NllNQ0FXWWM4ZGdydjdhMmExZWZHd3VycVAzTndMZnRZbldncFVjRGlDNXBmMGFJdl9ITG1SS3VHOUlXWG9rcTRXMVZ1RFdTV25SWWhRaWZFZlJ1NkZZWVZzZjRoNTlvcHRPa01RUUdQdC1OVW04MGxfX2R6ZjVpOTFrNVZ0Wkp1RzJSd2RwemN5cnJPXy16UXNUX1JaQ255amhtZ1RHd3FUVVRYQmNzWXdZTmRSak9Nd2p5X2VfNHljQ1p6bVd1Zm9uY1RjUEppWnpLcG05dG1hVmE4VVozRlhVSVV2dU8wRTRxSzFiU2NVWFZoN0sxZHRtMEdYYk5aLWVyM1BSWGUxVE9QNVM2amtYbE96M01ZTDJTcWNPREI1MjhETXhkQWg2RmR0RFZjNV9oLVJveU5ON3c5UFNJNVJ5MFhQQTBpWlgwZC1XTVhnQXZHRE5pTGZvMEpHbkdrTWd2cWtBN3FHYTFQU0VoQ1BCMl9UZGRwdW1QNENDbmVVLUpxREdnd3lYNFIyVEdFaUZLbXA4MnMudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZOa1JVNU9vWmNIS0FuaHNWS0lZWmgtUm1kSWtXT2REdGRFZ09kTk1qakZNVkhQOFlocE1IYXo0bjBkaTd4SEtqX0dycEFGMHJHSkhLbXdWSHJTaHFQbkFSYzNrbVlseVAwSjRSYkV1ckhuMjFTcEplZjc0NlhpRzhhRUpCWWp1Z3kyMng3YXBxMGRKaFVkNVVBYVRCMHpiYlpEbGptaVd6eThiXzljWGhEVGdmUk5ZY0FtNVVOY0ZZc09KeXgxX0ZvZGtPQkVGWU5NT2tteG5HU2tWV1JiV1ZvQzM0T3VvR200Q3NZbFE3emZ5dnB5YlF4Um9jby1KdnlnMTZLbDdaY2E3MmJEZmZ5aGttVm45ek8tbkE2TkZMZE1odW9BTUFPdnFNYW1McGtraGhkcE94UjJ0dGFtNEs2cWQyTjUzZFNrOWQxTkItb1pnZGlLTkYybHQtcGN3TTVGWTVwcl9uc3pTcm1lUE5CWHVsSVBGNzdmdExSMmhMaERYZlNDUng3TFNKR1VkXzgzejJFNUgtWWV5SHhHRy0yZlRKQ2xDUTY5QUdXX0U2UVhzcHV0Vm9HYXkxT3NuaUZCZm1tOXl2NGlGQUlfeFZza0J3Q1picHlCY2NxTXNrbS0zY0FVNGIyNTRTZWEyQWlPWVczOVFYMWYwMy0ydXRxTmRqV0VQcnpVVTNQbkpGdFd6MDMwYmdfLTBZcFNDOFp5M05aVW1kTWJNRmFEUlQ0dWdaX0hZcjA3UjJOYzV0dzVQdWlPdDMxb1dnMVdXMEpZVDBiOTl1dWdSZXVpQVc2MmFVbzJ6VU5BNThPb0pTUGM2WVVHUm9PMDVTeXp0TFJkMUdBRHNFVEZod0xQeDFtUlhpTU5mM2dnLXRMVjR6UGp0VmF5SWEwdHJDdEJSa0YwMjhDM2ExQnUxeEtDV25WRGhIRzJVaEo3U0JjZzI2YURnaHBPQkQ4LXU0STFPWTNzaWF5VlFHZ2U3NGE4WHJQZnVXaXJaMWtSbXRnMERGVzROSjBtdy1nM3lKbjh3RHpnUGVpVFZDRzVkbFRjUlo1ZEI2OUdiRWxzUHBlS1R1TE9Fd2FjSl9iT0FhSWQ3VE5RWkNWRXloc1lvM2JtcXo4WVE0TEpLRnJ3NUZSYUVDNXc1QTlNaFEycHNwV1NZUC1tWTlzN2dKRHJ6OEVoQTg5M2UxUXhfZ3hSR25Fc1YxenlCdkdnemN4ZGZZVnJyOTVIV2hFeXMudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZtcUxqcXlKbW1sMXMtMTlKTElVakZpem9qWTFSRTJGWnl1cHBlM0NEV1RUMFNGdDdIaXd0dlF2UmxyYnBVbzR0TUVOdm0tYk1iS2dYMi14OERHUFBQS2RWMmpXUk44Y3BfakExcDR6UHlMSnBXMmYxaTlxQVNJMEFvXzVRUWVZVHdqbEhTYkx6ck4yNFhsVmxaQUhiYTRUVzR1ZEdNY2Rqa2Zud2hqUW1kUmY4elZaWHUyeTNFOWNTM1Qwd3RvVTZNOHJ6bWp3VVJsOW1pT28wWC15WlhNeGhubUFXM0xrTlZ4Z3I2cUxHTndSMGFYbU5WVWsyVnlJUzdHby1MZ2RxdkcxWjl0TmE2MlJJYk1WVW01aWRZZUg4eWdjRk0wdTVnbDJMWlFMX19hQUVTYzRURG1DSm05anhsVmtyRHBWbjJYbjgtdzBWcFNGUEcyczViM0QwQTRkcHZWQVY4ZnJmeXFTUE5sQUpJWmlqZ19sZzRtWS1Qa0k3UDI4SmdsUkRTNkpaZ1JYRDNDWjhMc0tUNHRKTk1JTzRBY1JhX25wdlhMd1h1dGJMU19RZ2FBNnl3Sl9wTDdjX29yQnQyVG1UQmIxaEptX0VUMG9yMGJ0ZW8wUmRlNm9CNFFmMUlyTkd5cjhWX3d4bHc5cnFSWFVwa3BkR0lzMXZDOVpSZGRycC1MX3hCTWxBMmJlVVNBSWRqNlhLaHdHbHBKNHJiRDRvcEhnN283cHNkOEl3MklqbUlYU3FqQzNlMk5ZU2FuQ3VXQmRzYUs3QnFLby00RV9mZFVrRmVkbU9GSDV6RFAybW5teEdyUUIzRW1TVTFaR1JiSHNHQnZ5eWNLSmVHeTd4YVZ5ZDdsM2hnUy1rQzdHaG9UQ01ITW9DWm9OcnpYdWowdmRUbGpDOGdwMkFLVE9kV25PQWhsaE1aRGFXcHQ3WnN0aGlfUnB0WjNEbWdOLVdvOWJPMU4xWl9mcTNqNk52eUc2MWh4b1dVQXc0RTd2ZVBha1QzZHZJX0Y1MTUtcGQzejRwQk5vSGlmMlFpYWoyQzlPdHZOQXJpYy1mZ3VrSnJ2SGo4S29aS1o5NnVHRGc1M0pWTVVwWXlwVThHRXNZZ1BoMjJ4eUUtQmNpckNDSk40VTRkTkxYSnBTR3BsTlZ2ZXY4MnMtNWx4VTY4WDJudldSWkVoQmFla0c3b0xSbl9tenhaM3hHZTNHY0dnd0t0U3lUSDZkeHNla0tVRzgudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZNZWhXcXVjRVJVMHptQjA2Tkhvbk1CemxVbkd2MzNtN2tiNjRrQ1JKUmhxNTl6ZFVDQTNqc05IMGs3MlNDcC1oVjN0enI1OVZhMDJEUE9vdmlGQk1DcGk3aVRTUjMwSkM5eUVzUjVsNG5BdllFQ3ctVzF0QldWdlJnYTBGOUlRZ1Y4LVRwczltSEoyOXoza0d1U2ZfRDZBa0liaVJfNlRIQmRKZHhKeGpvaVBSaEJUSkpReXM0UXhGbjJpeU1DWlVVSmtVMkNYdHNYUkQ2aWwwb00yeXJ1dF9Ec3J5bmRSUUdVQVU5QlZzSURMTkxvUWtRZkFfUG5hMEJzeEFFY3JjWTZoWnRISmhOeGFPWHFkczdyb2lEWF9BQlhxQjJteWU1MVgxQXl1VEZydDRpeFFXY2ZSaTVuaHA0VjNaaDRvTTZ5aTh3R3VvUXYxbjRyMFhUd3FiX1FyVUtiNUJWRVJxek5EV04wZTZWMkFSOVI4VHlDczlmQXh0dEZ0Z3gzY3lhM0FJeEFFcUhqcEUweGY5dUFTdzVQbkV5MEUwWEgyNzRFS2ZnVW1VVTMyY1dxOW9PNWphbl9ENG9pdzQ3ekZEQXA1NTFWcllJVXRjY0FNMGFLUnF5SGo4TDhYeXV6V1B4SmZMM1lUVWl0dzRfZ01qWXAtUUNqYXRneVgyZHNNSjExUzQtc01obW5HS2hlSnpJZEJ4U0J1bzJSc09SbmR3ZUVSMEllNFc4c09oeEFIRXRZM1ZBMVVWdzlIREFhSVRWWEd2cXFFRW9lN1REY3k3d0VzdHBybWR5MllSRzlQZzltVjl0UGR0R0p3WHBabnctbFl0NFVBQUJNUjBWZ3NhUjk2UTRqYzlQb01uZ2ZfWE5kN196amxwa3RoekptY1FfZG9mOUxLRjBUdERINDAyZ1JoTzFKaUJiR1I3LUM0SW9QUGNUdy1NMW01TGF6VkdHWjlHX1VfUTVoSVg3VTJMY183N2Q5TXNTYXduR3A2MjlRU3ZTMkFjdmw0ZE5sTUpJeGl5X3Q0clJsTU50NFprejlESlJfbVBMX0J3QkZsMEd1UTByQkxsZXR2SWl1eEZ3NGNlYVVIaWFDdzZaMGJGN21oQXU3TVZoWnQyT1NMN3M1SmJCOXhPSFZJdVRxQnJJbl83SGpGYjBHNy1uQk5PcGNFMkVoRFFFQ1VJVzFXX180cXFsYUNYRTc4eEdneWNIZUNSOFlCR1VFQjJBSmcudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0YwdnJBXzJOdzBMejNWV2h5S3QwVVd5ZmtxUE1JVUh2X0FNUDRfV1lnMXNtdWJ1U1JGTlV1eFZzX2dqTnZYVi12d3lRa0lpVEl2dGluSlN4V1kxQWZYQ3E1RkpzWnhpTkV2Q3F4ekNicXpVbktzSU9oNXZmNXZuRno1ekhmN2JGNGJnbHA4WHJEQTRQNTZvV3VOY1NfNDlhZ0lHWW9sT1NCQjFkX2JJN09sT3V5SDVaN2l4N1ozXzdVeHJYQTVXUjc3ZVpfVW04SV9MbE11UWs4RWNtX2w4UzFUMzlmS3haNHkzMUZDOU9xUTBDampITndieXVkX2xVNldRa3NoUktCaHBxNjNMY2ZsMkxuQzkyMmhGVExDb0tIMlNvTnJBMHNTQ3dGWU16S1RKY0U0ck1YN1NRSEt4RDJ4X2M1VkdSWmk2Sk1hMVJ4Q2M4UURsaWVzZTlHRFNCZll3VjladzVlRWFub0lpNnZiVHlEV3FGNWEyUHpCX2NzREZCdVljaU5MS1dONElKd3ZRZUU1Mk4yY2hoUGlyYzJ3b2hyZjBwdzA0STI4MUdtd2xrR3doYmVnLWhDV3RVTWhwcFFWUHI2NWN2QmpuSzRVVm9vTmx5djdCSFpOdUJrXzJTREM2cmdTVHlDZnp1OFdTMG1Rd0NDX1BEMWxlbm5TZ0p3aGc5Ml9ObDlmb2tkV3E1em5BMEM5WGpEcXZjR0VtNWd5YXJlTkk3cEZTb2F2VW5lZkEtYU80SjJMVWs0dUVtVTVNbEtfR2FZcFhad3A1cU1ELWdHWTI5Mk93Ylpzb0x4SVpaREdSVzVsM25YY0ZFNXZzWk10RW55NTlYQVUxTkxMQVMycVFkYWpYbXFoUFY0d2lndEYxdEMwaDItZHhyb3Y2aDExLU5teHFjRmNvaElMNXN0QXdkZmdpcFB4NS00Y1BPcW9SVjZHY1M3c0RLQ1BUTDlaUEowZElHUHhzSTN5ZThVOUcxZUNlUnN3OC13TGl5aDM0dzhUMmQ3aHZ3UkhRZFBQalFEZWVHRkpMeHdxRHh3RDBQNzE4MHVkRnlJaXU1MXJzdWdfTjdfRGl2cHpMb0U5QzFrY1dVcnhra2dmTEFiLTUweUhYUm5QRl83eVZsOV9fdmNjX015NXpsUEVHYlpiMWRLZkxGWk1GX2k4ZUNlS3FtOEVoQXFSdm9fSWNWZG53V015c3pMNmNTdUdneWxyVVdQZHU4T2RfdC1tNEUudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzAuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Z5anRXVTZpcFdtMkNiZ21SNlRsdU04WG9QMllrWXNlUUNLdVdjQXljMnkzS0RneFBUN0pOTU9mdUJzS3NNcFQyRHdDbnNNTlJYTnZxTXBuRDQ4eGU4SjdZWTF6MndtSjBCTnVuNk8tZHRycEtsaF9RelRkQl9lajFaZnlJSXBHa2hNVUhhX1ozbFlzZldEanZGYm45a3BSQVVwMDlxX25uZm02QUpSeDh0NHRfWFM0TkcxZWl1MEMtSm5MX2lKanNPNS1Kb3BZU1Y3Nk5peXBEbTd0U2RiWHNMNWdIOGFwRGtjSzgtUC1pbEdnWkw2ZEF5dlpxRHBPTEpZMEVqWnhWeW9zNm1KdFZ4NXdCdHFJdDk4Z01rdFVMdThiQXpISFdNVkRzYVpzVGV1ejNEdEVLSnpNZmJpZXI2VnQ2eERHOUZnalNaVlFKLUFjR2RmUUR0bnV0RUNjVlE0ekJLMXN4U0VNQ0Z1UmVCekp0Xy1DLWhEYlpYc0hGN3NUT0I4dlpkTlN0OVNGbmNrNWEyNEJtd20yUDBsRWZRTDFFTUhubmRuN0UxVFg0OU0wTE1kaTdBanlWSHR3SkpldHQ5M1BZbC13dkNtaXVBNHVqdXoxZXBjWmhJRG9IdHhJRUdoVFNvMmxQQklBUl9wUDM0QkpBeUV5YkVzRUVBU3FlRXVacThXanVmcmRiblBISzZEXzRHS1RzSmJqTlV5ci1HOE1udlItU29EZnhzcDluMXNTeGRVUWZSR1NGc2lNN1J3dU5UMHJYZFd4Tnk0blc2TkxBcGYtQlZFMUxuMEx3VzJIenYyQWNFWDFZeEdMQmYwRHdtWnRxNDVZdmEwU1pCc29EVnJVeHoxV2xhTk14emFpUW9oVXVpazJBVldxTWcxVkI4MDVnNTU2Uy1mYmllZXR2WlFHa0dsRFZzMmxwbUZaWjF1TktPU1JMZTlYR2txQm90TmZmY0V4eThhTUJNOGE2aTFQMF9jTEFzZ1l2Wkt4WUQ3cWRFbHZ1aXdTc1duLWRwUjE1cjFuZkI4cWxUcmpXVk43NUNMa3g2MUZYelVKREhHb3FaTS1xZDQzZ3VBVGY5N2dKTEs4QnY2ai1aWU51Y05NYlVabWlQdGxZU1p0Z0ZPNmFWRzhUOC02OGJZSFh4dHRHc1NHaDhyYndWcmx2S0VHR0VoQk05TDZVQ3B6dE5QenExeTlZWDhWekdneTZReDN4LXNBVUNuQnptd3MudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZkQ1Flc0ZNYi0xQVZfdzBSSl92TjlJRXJrclYtMWprb0NQSTJMZkNJT1RrcEl6dFlaTWhvWXdxQllKUGpkcGwxVjhRalFQMVZoUE9YV0FuZF9fT2c5bXlCUW5QcjNEbEJ6Sy1GVHk1SGtoX0dQQjUta0IwNkxoZmF6bjhDT1RqY3cwbWEycEVOeVdCMWpUQ2poS1FBa0RFODlvWWlZcEt1NFhESVR1QTloQXY5MDJUdVhSMWFST2E3M3V1ZHkzNjRxZjR2Y0o0WF9FSHItZTFSSFUwUFctdXFUX2JlRXdGNEdoajZWblZxbE5USGhCcGRRMDdwZ0hiMEFoQkhMd2toRm12MTFqZ1U2cEU0Qmo4YkJEVDZ4bTRQMVhuOFhJWlg3blYyd2lYNy15anp4b0ViejRaUUtpeG9KVEhzOVFtOE82RkFSdVRQelJTaHhkZnJpbXhtNDlzYXlaNzluM1pjMnlQVXpZN1c5Y1NNYXVHR3A1b3lOdXpFbGNVWTJ1QVdqdDVyOFJBRlVIZndGVFp3ZjhCQ240ZWJuVTFTLV83b3BXakpRbngtOXhOYXRLcjZoX3dSYUt0aDlKOHZuOV9LOHktTk13aXpaQU9OS3lVMFIxMGpaLWhOazdwTlRxVEZYejBadXY2SVN4S0lDbTVLUUZLMHVScmFLWGY0RTlvZ3Z1Z25wWVFPbEp5MDdXblFsQjBxQlNIZGVKY2wzei0wSTU1R2c1Y0ExMTl4NkJpM3hVT0lRRURNbFlPWmJHanlPM1FtcGhab1JYUEVYaFRVUUFxeU5FQURfOG9sNjZuRlA1dkhpX0dRaHNwc0FjbUJiVGczZGdKLVpVZ290c2ktQmJoNHljTUpWckJpS2VkdDV2N2t3RmUxSWZKel9WS2IzVTQyc0tRd1laTEZ3MGNsUzhoS1hYX2ZIRUVabXVIRzE1TGR1c01GM1p1YTZpQm9wT2djQUtTcFRiX1A1WWpVekJfekVxQUVHdzl1Vkh3UTBqa2tSa3YtMlBnZk9EV0FDcU9fVkFZc3Uwa1RvbEt3TEdaVEE2YWxkbUxpeGtkTkdGV2h6bGhaT2NXb2tRVHE5QVNwUk5hQ21sdkdZVWtMaGpPWWk3TGt6V0tfWkxzUnpwZVk0WWxyczFvMHF3QzhtSTFZZHdtbi1iODMybUVucmNkbkVoQ0NGTzYyRWtTVDIxWVJWdE9heDg2eUdneUhPNW1HdE85NjNfMDROcGcudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZIV1FHaHZNQ2Y0QXlkRDN2QWZnWkFFTmgzRm1CWWhQa3ZIN0ktLVBCaXVUZnp1cnB6cG1NaXY3Ym9RdXpxQjJzbFFyZk9ESzNiMnVYZVA0N3YxWWpVdnBMNV9XZXdadlZlLUpoQUY3TjhDcjJ0Vi1ZVG03elRma1FSRVVGeFVMLUU0RHUyVk9tcjB5OWhINkRlSGpiME55MGJ6OFhiTWN4Uk9GMW03S1BJN2oybTE3anlmV0VkRV9oNnBPbjUyWXZvYVZIRzVrSGNDc0lxTmltZVN6Q0NCOHZLYkd4ZFFrUEF4dzlkRzQ0QklzcWYwWnVYYXJKbm5iMTNVblp6TG1pdlZxRF9hVWo3dDM1aG9seE9zSHI3QVg2VzQ5aGk4REVQYzllaVd4a016S3F2aEhMTXNBNVBCcU1VaS1VS0N0Z3M4STdWTVdkRDBlUnMyOEl4WHFQOFRqVmVzQkdrSkZ0N1M3TGc4NTNpczRlNjhaOVNRZWhUMS00V2dLejR5dEhzTElwYXdDU0lqV3RGa3o0NXRiMHowLVpHVFoxdDJUU1lWN3hQUmlwWUhKWEFHN3ZVcFd1OGRfa3BkTm5hUW1FYzZFdjBwbjBhSDdYVFdtbEpQek9MMGJCVHJ0Nm5yUko3N0h3SDVoOW14b0x4UHNQN0hRRXN4cWNDUGNvY2RjcWxYWkhMUzlYaHpobmEwSDFHclJvb0dCbWNRcENELTdYeHdCSlBpN3FicVVlR2RMMFpzanFGRmp0bVEwd2FSN3VhSTBBX0ZsdlA2eDdwdnZnWjh1c2JCZFpmNlZXXy04QkpsbWp1MURVN29UNEo0bmtxME53M0xBaDJ0b0treXlrVUlFWFlSZWlxTVp1SzV6UzZZaW9wTWJKOWdtSE45VnQtVGpfZmx5ZWFWN3diR20tNGx3TjdSY3ZMQjNISnp2X3dyYVhEWlVKdXN2UHlUc3hFcHh3RkRCTUxYcnEzVm5kS1A0SFY3Q3NtaWpobTRXYjRNTEFqajMtVnd4YzZ0Z05OVGVOTlhPNVltUHpQNV9rNGRqRFZ0MHNKNkZrT1VPbWtLcmFQamctaDF0QzJxTkQ1czBsZFhvVDNZYjJMZ3EyWWVHcE5XclRWWmpyNHpwaTYzS3NrR2NvZ1BVRUNNSi1Cd1ZtMTlGQ3E0MElIMnVvTkZKaUVoQWdQUkl4cFF2QUZGNFdndVB4WTBvakdneUpFTC1FSHYyN1czX3EtY0UudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Y0eFl2UGxCRUUxTHNwVVF6aGlWQlhGM2NfMlVkSE5CX3cxdEg1blNiTEIxWWswZmx2eVFwMDU1WWhGdFpVM3g5M3ZBU3NXTUV1WlFvRkFrWGF2NEZlNXdNWTYtYkNBOTlqRXFCOGJRcm9RUERuWXJtbGtvYkFwbGh4Z0IxQ1BIcjZDSzlxSV9Pb0hXVlM0STJ1NmdlRERrV1R2ekdWSlM0QXE2MlR5SXM0bm9xNFJ3Q0xWb2JvY3gtS3VtMVdOcmVFT2J0NHdSdUIwV0ZneFNQNjRNaG5DSGlVcnlMRnMtekJYbHRpc0EzMmFhckFYSlhadU5idGNzcHNoelhoQ1JJN1poSWZSbm14cUVuTXY5cWYtM1pqZnY3OHFjcTA1dTJ0ZzFxcVNkMG9sSnc0c1puT08zaV8ySVZjTEFKUDA5RnNPZmd6SFBBTXJRMXdrbGw4OFVsUnduSGlTMEw2SFlhZ1hOOHlSUzdZQ3pMRjRtMVVHMW1qZ3M3TmNsRm1xVGJUTTYtSGpKSGlzMmZQWUg4YTctTER1WXJFdHBGcDdxcmZDZ1NVc2p5QjM1NkEzMHc2c2l5eEctQVN1S2hiSWJjaXA3UEN0ZGxQenBjeFdQZGxCeEwzRnN5cHR3d3k0NWd1OE15dzJnQUJuTmxhUWdheE50ZUhIbkZWaDR5UjZtZG9jZzFmNTgxVWY4YUk1dGo0Sk5aZ0dIQ0Q2aERFejJGZU1LLXJwQUxDLUhwRzZiVHYyYkRfSlpkRi1XZUZhS3ZyRGh4b2hDbzJuYlY3ZXZLOGsxTkpocVhvRmtlcVRCSklWeFFTRjdqano2alhWcmZwRERmazRhOEcyYTVTelg5cGtEMmdlXzg3UG5WbWdZQWVTa1R6TGFSdVdzNjNmYTQ3OG4xUUpVQ1lvR2h4SjVRSzFxclF4YzdiNE5EZW5oZjJaSDI5blZKVmRBRFZsd1lZcWlWalJHVFFGeWxES2tUOWsyampkZlc1LTVZSGlKS1oyRHZ0Yk8zZm5qU2FHY3ozTTRKd1ppTFBQSUFibEtUaWZnOU1QaTFnYUZra1ZyZFhFeGtUNjNxNTdROHNZOW9JR3VOSVpHMGpBNmc0ODNvTDRpT1lTRDVnbGMwNUkyU3hBWUpDUG95dmdKczYzVlhkVjBfWjg2N3I0N3ZlbXFUNmNUaEVoQV96UmlhUmtJMHp3Q0E5NjF6ZXI3RkdneXNFTlhIcHlCdkVnLVpaTVEudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Z3ZlI1NDd3TWk5SXJtWmI2UjJpS21SWnExWDQ1VUZNSnNHU0Q3bmZma3FmN2ltYjAtdnUxcjJ0djdfeFY4QnNBcUxMMnRkdVpHak1HbDNzSk4wWVhWQ2ZfQkcyWWIxU2NXaUpfOWttN0FTS0pSLUhwTzUyR2NQaFRYX1NLRG9ZY243OW5QWG5wWEtIdkhsSW1BZFVLWTNjdXkzRlpDeHJ2R0lkQTM1TmwwaVhwVjFqLTRQQmN1ZVJYYkZ5Z0YwZWY0LUVKZWdyY2Q5bzhSeUN4WXRzeDJ5NWRIR3l2blJ0VGE4M01VaEpnSklFbmd1SFVKS014NDFjSzVXV2pLbW10QXhMS2tSc3VYeGxCN0lQVWdOaE9qRWFORzFjeDRGY01MT2p2b0FpSDM4cHZWOHhEMTU4cHhuVVNQWjk3VnlkX1BmaGRZNjZxS19MTjR3VU5xYW9HS1pvRG96TWprSHVzN3NSYjFLNUxXUEZxVndSOWU4eTdmdnN0M2tjNjljTS1aNkt2NGU2SnZsSjQ4OWJTZXhKUUplN0FTUjRYNkR1YjhUTHpkTGN0VVBuMEFuUjE5MzMyMUJxV0NkLXV1c2wwVVdYMUdyZ2k2ampMdFp1ZkdESHVvY0J6YVFrX1FFV3hhRnBpb2twdUJaWjlHWC1XbkVRVkl2cVFGZXlUdjRRbjlJR0RCSkgzd3hXTEZPMUl5MDU1N0xQLXEyUUJqQldyTVQ3WVlmbWZyQ0tZOGdxQ2dBY2VFb2g1V2NsUVBxc2hubFg3X0JrVUhSbzVJVDJ0OXZFd3ByNnpzT04zNl9XSjl4YmpNYy1rdTh0VlhvMmlDN0Rscy1fYkk2NHF1XzVOUGhELUtCaDlyLWJ6X3Z4LWtuSkwtTndYeHZidWJUTGplY2RkTmlib1U5a3FBVDU3R2xZTm83RnA1UVBVRkFvYW5OVXdGaGgxdUI5TDlDcURUUWFRZF9nejhuLWswMmhPNHh3Wno2LWl1aVNaMFVlb1F3WDA4MGlrdTZGVUZFVEdBUjNKVjdibDlLbl9hMmFKRmdUX292b0k0NmFoRWFwbkJpZUdqVEJpbnA1MGQ0WnZVa3VrTmg3bUJSZ093ejdyUEEwdUwzczJVbEpNTW9NV09VaDliRnZnN2tlYWVpeGZGRC1VWi1BUmlUYWlVM3N4TlBNM0VoQm5ZYmdfRGZsUDhkVnhCdnRpcl9GX0dneTl4a25RWVBzSERRUFc3STAudHMKI0VYVC1YLVRXSVRDSC1QUkVGRVRDSDpodHRwczovL3ZpZGVvLWVkZ2UtYzY4ODRjLmxocjAzLmFicy5obHMudHR2bncubmV0L3YxL3NlZ21lbnQvQ3BjRnNsaExtamd1OFo1R2RfUnpJMG1nSllLWHBLVmpXd250VEFWM1dDN0xFVldKeFFUZ1NBdVdNc0ZaRXVHb01XQmdCbFZMdUg0TlhEUzVWb1ZibWFwbkpQS2JjSTdSbThaZUFuV0lGNndDcVFuQmowMkZfcWZ1MUFEd3ZKVmw2YTYzRDhIVU93RjZ2QzdYRTBVMXpLZVdQanVIdUItU3IybDFqMl9FcnVKa2xVLUh1TGhQNTM5a19GaUFvTkRFVXVRdGtNalQtTDBUeUlHdmdTZFBldHNPdDRWT1Q3eHZTNjFCaHBHa2NrNmhZRHM3SkRwakpPYnZlYVpxNjIwcnNRcFk0X3BNRmxYLVRaVDgwZlNuSnRaZzlMYnBGd3BvVHVpOXJzSmVkalVvV3BqMFk1ekhkWDRPZm9zaUF1elZ6OTZTSWpCcXFNU3dzcWJxbzZwZ0JOQ2xZNk5LelRFdG5hV2UwdG96ODNsNXgzYVBrQ29KLVhKTm5DZEdHWjNXZ3dQa2NhaHFGOXlpSENFZEIzVVRnOTJEamx4U1lDSkNITTVFY2tGV3YtZlVoV0JSb285cGdfRXlfRlplR2xaQy15QVJYbFhBTlVuS2NpMW05T3FBQnB2c3R3NndIMFA1QnhFWlBocDJ1WDFDeEVaalNLYnlDenBrYjR4R1dwR3ZUSGJlcWxfdFBaS2Z2Uy15UnFHMkRPRGpud2ZrM3dmdVhibDdxeXl1SkRHRU1haVFPVGNJeldXcjNLaXJ4SDZGUy1hQzlMTjdZdWFoLVlsUmpOZzNUT0YxMG9EcWd4ZUhHb21YN1I2MEpnM0tnTTVtbmlCbEQwQklVcU8xdFJseWxmZlNDbm1QbVUxaHZpdkU4M2ZiVUZjUWFidDFIako5OVZ1VXIzUng2WnZuU3ViZWdZZjlvYTV1VWh6NWNRckxHZGlCeUxWUkdKWk91bXVoMXE1Tmk3MVdic0dUWmU4NFZHUmxUaWpuQzU4YXpZeGhvX1RSWl9fcDRITkZubkhBLUFUQjRBQVhMb1AwMWZiNHZMWGVvdmhQWG5QM3Nranp6RHJDS21hNGNMdkFiczlzYzdyVUJYQ1pjbmhQR09KVHhhcTJWd2wyai1hdmRzRkM1Z0J4QjFKOWE4R0R6dERGN21RVmVKQkRzNFFmVGNpdDltN3V6cTN0RWhEdGc1Y1NjTmlsWnVYOE94bE05M054R2d6SmhQczBhTmJpZXZlU3Bway50cwojRVhULVgtVFdJVENILVBSRUZFVENIOmh0dHBzOi8vdmlkZW8tZWRnZS1jNjg4NGMubGhyMDMuYWJzLmhscy50dHZudy5uZXQvdjEvc2VnbWVudC9DcGNGOTV4VjY1QnVlLTVjWDdVNWRVSjlpeVRWVGE1eTBWUmg3Vks5dG9rOHNSVnRjMk5VVU5zSlhTQjFpOFJPUGQ3ZUJuUWl0M2t6RndHVnVNeEgyZXQxM21UMTJDQmVZY2k5dXc0MU51VFVvanRfTlY1QXZKRERuOVUzRklWU3JvSGQ4NDJqSFlzVDBNV25tMnBmM0IwOEFDOGUwOE92dlNxZ05ReVQ4SGhWLVdUaDRTbUNFZnFXSU45TGpvZXJCTmZJUk5ueU1RWWpKWVVzY2dFbHZuQWxWREpOcDhqbDN1S1VqUlVTVHdoa1dIU3N4TllKenJiRnFJNjRaQW5ZNDdLZWZORG1GVzNOTk90b09henRHeU85c3Bmc2kta2gzRjk0UUNrSy1ZUjRMX0x0VWVJQ2ZJbThxb2NJZ0JzX3hTWkRQemV2QUxJaW9zNjl5cFlheXpEczMwWGkyeDRvekdpZXlfMVhOMjRvRzMwVnhEd1FsMXZOVGxuNGFQbUE0QUtjcWwtVnBSTTlEaEdEd2h0WVRWT2RPUzRFMWJISE9MUm8zZUF0dE4xVVlXeFpvd05adERIcUpnaFlVd1pJSFBmRzRrZjNIQ2J4NHRZWlJJbHJScUl0ajk3ajY2ZTBneGFMVFAza0NDQXYxa2pXMnZWMmJxcW5ZOC1tSlVkQm9Pb0lxTzdEVUUzZ0pFRzN0b2V0WHZvM1MtWlFmVktqR1hhYjFrOF9aWlM4Z29QQXZ1UV9uRGdEQXJqbktIM3RvSkNaZ2poSGFsOF9DdC1lcktXM0pta1ZaQ1Z5eGhZei04WjZBU2lseHI5SXpKaW5JZXdOUVdHU3RIMk5JZml2YUN6TWVCM1NnS3ZwQVlvckZiOXVweGN5b0c2Ry14T0FRWXVEZXJsMDJPaWlENkZ5SnB5QWdwS3dtRW9feVJjeHd0S3dvTU9LN0MyVXp1bkRYeVFaMjVNQm1zd3FiUkxsUU41cVBCSS11YzEtemRjUVRPckE3TXpibl9EcVk3V2ZKWm9RNlc5M3M1SGptMWJ0Z1RJbXBMVXM1OU11WDVLQ09ORHpiakFINDFBYUc4OUk4UWZibXNBR2NHRXNkcVlCLW92X2NrMW5SQjRFQ3ZMbjY3MVNmY3JCdzEybU5hVkNpZ1J0SkhXYUhvQ211RDMwd1k0NFRadFFFaEN1dmF3cnNzXzJnbVZqQ3F6TUNjbFBHZ3dPYXY5ZlVnamZDaVplVjVzLnRzCg=='; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - shownAdBanner = true; - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); + 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')) { @@ -130,33 +152,43 @@ twitch-videoad.js application/javascript send(); }); } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - return new Promise(async function(resolve, reject) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - streamInfo.ChannelName = channelName; - streamInfo.AdUrlCache = []; - streamInfo.IsMidroll = false; - 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]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - } else { - resolve(encodingsM3u8Response); - } - }); - } } 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 index 6f8aa6d..a61f787 100644 --- a/strip-alt/strip-alt.user.js +++ b/strip-alt/strip-alt.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions (strip-alt) // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.0 +// @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 @@ -28,25 +28,34 @@ var newBlobStr = ` ${processM3U8.toString()} ${hookWorkerFetch.toString()} + ${pushSegUrlInfo.toString()} AD_SIGNIFIER = 'stitched-ad'; LIVE_SIGNIFIER = ',live'; - StreamInfos = []; - StreamInfosByUrl = []; + IsMidroll = false; + HasAd = false; + StreamUrlCache = []; hookWorkerFetch(); importScripts('${jsURL}'); ` super(URL.createObjectURL(new Blob([newBlobStr]))); twitchMainWorker = this; - var adDiv = null; this.onmessage = function(e) { if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; + 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() { @@ -73,21 +82,33 @@ 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 streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; + var dateNow = new Date(); + for (const [segUrl, segUrlInfo] of Object.entries(StreamUrlCache)) { + if (segUrlInfo.expireDate < dateNow) { + delete StreamUrlCache[segUrl]; + } } - streamInfo.IsMidroll = textStr.includes('MIDROLL'); + // 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')) { - // Someone should make this a regex... 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] + '="'; @@ -96,9 +117,14 @@ line = line.substring(0, adTagIndex) + adTag + 'http://twitch.tv' + line.substring(adTagEndIndex); } lines[i] = line; - } - else if (line.startsWith('#EXTINF') && !line.includes(LIVE_SIGNIFIER) && lines.length > i + 1) { - streamInfo.AdUrlCache[lines[i + 1]] = 1; + } 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'); @@ -110,18 +136,14 @@ fetch = async function(url, options) { if (typeof url === 'string') { if (url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.AdUrlCache[url]; - if (seg) { - url = 'data:image/png;base64,I0VYVE0zVQojRVhULVgtVkVSU0lPTjozCiNFWFQtWC1UQVJHRVREVVJBVElPTjo2CiNFWFQtWC1NRURJQS1TRVFVRU5DRToxNzY0NwojRVhULVgtVFdJVENILUVMQVBTRUQtU0VDUzozNDY2Ni4xMTcKI0VYVC1YLVRXSVRDSC1UT1RBTC1TRUNTOjM0Njk4LjExNwojRVhULVgtREFURVJBTkdFOklEPSJzb3VyY2UtMTYxMzMzODM4NiIsQ0xBU1M9InR3aXRjaC1zdHJlYW0tc291cmNlIixTVEFSVC1EQVRFPSIyMDIxLTAyLTE0VDIxOjMzOjA2LjUzNloiLEVORC1PTi1ORVhUPVlFUyxYLVRWLVRXSVRDSC1TVFJFQU0tU09VUkNFPSJsaXZlIgojRVhULVgtREFURVJBTkdFOklEPSJ0cmlnZ2VyLTE2MTMzMzgzODIiLENMQVNTPSJ0d2l0Y2gtdHJpZ2dlciIsU1RBUlQtREFURT0iMjAyMS0wMi0xNFQyMTozMzowMi43MzZaIixFTkQtT04tTkVYVD1ZRVMsWC1UVi1UV0lUQ0gtVFJJR0dFUi1VUkw9Imh0dHBzOi8vdmlkZW8td2VhdmVyLmxocjAzLmhscy50dHZudy5uZXQvdHJpZ2dlci9DdjhFZmtQT29POHBCRkxpeEhpVzVzQkh0ajF3VWR0SnhMc2RRZFlxaHZIakpvY05HaTlxeExQbEowNDNrRS03UmtaQWxUZUVWbi1mVVE1ZHJ5RVFFVVhDbTJYZWFtZk1XbHY4aDAxcDlVam1wbEpQWXNqbzRjRzRlaWJRakhBQkJTbkdfMWtCS25YdEUtc0ljZTlsZXdKSlZYdmRsN19FM2gyYmpCMVpWVU5KT29DNzFvLXpFZFRvNUszX2RQcVhKWDE5Y2lpMEZ5VnQ3dVZEaklKYzNVMGhrYmM0cGVOaDRZbEVkUVlkSWE3OTFQWDdfTGJDZmJkdWdTUXFrNXhLX2NUNlpHTE8yWDNVUU9lTDhSTWRlVkpIVllWUDVxYmQyNWZ4MzlqcWRsTTBLeEJRS1lVVk9iWmprTEtQd3RWMEpQeFFzZ0dFSVRZb2hKMm1KV29UTUktQ01rQTRPTDhpSTZZTHB1WmVneUVBeGRaUERzMUlucWFhSVpTUUlxUl9HOGZJYXZvWUVoa3BwRDNpN1NnaDhKaThaQ253d3MtZ0ZHUnRvRVhWSFZPZlZjZHEtQThmZURMNGZJNDlrS0xtSy12Tkc4VTNvU3ZQbFN2LWx5eHlxYnZNMk83blBPZDhSUFpoNUgxSmVmWDZDbENpUmNXYm95Qk9NcXZ2RGw4OERqbG1faTdXdmNHTmlXdjdKMl9tallKdlM3b0d2bUFBVlFaVFNPbUZDVDItaWtsdjZQVDdKZkpTV2h6ajY0SEV2QTVlazdvNWFweExOTy13OWpHbkR1SjJrRTBTblZoVHhXV1dVZm5GVERTYjJJcFgyWmlZMUFhUkt6NDNkYmRnbl9CYUtYR3dlRkdUVHNJOHByVzZQdjJCNE9uakZ0YVh2M1Zzd1VVVDRaVnNGM0E3VHZWM25nSDJrVVAtdFVRcTBJSzZ2YWFKeHFEaXR1c3JDeHk3bUh3ZTJYZWc0X2pPaTdfaTRMVWhKY3VJdU04VzA0WGRXWnhmb0lFVlotdnZBQUU0bDVZbTNKSm12SXhXTG1vZUd2ckVMOEs1R1Q3azhJanZSbG90OXlNeUNYTDNReVYzTWlwa0FvbVNfY2pCS0V2UjMwRGFneHFMWXl1bDZ5NHlrOGdTRWhCemRHcXkwT1ZZM0pvT1pkb2lhT3JuR2d5XzV0RFpCUDdaMjNGZF9LWSIKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZTenBUOGtSckxTbVh6YU5fWTN4dnJnWlF3eUlyYU5WZUxJTk41X2JHNXAyTVdSTU9uOGJsWTQwV2RjT3FMeVZOMHllUFQ2WWNjcENBZEEyRjd3NGVXb081dTRlUHRqM3ZOdjJsZS1QQ0ZrNHhIOEJ6X3VKWVRkZXN4M2tITUQtVkJwc0NDdlQwdUlVc1ZPQTdJd21CNGNWSHUxY2tiRUwzU2pxaGpUS3BqYjRfME1nd3pnZ2hkYzlHRkJ3UTdJN3V0MUlPaVhNbko5RnlLUHd6bGpBV3BsSjlRS0xLeVFHalhmT1dwRG5ReGJFdk5fc3BVakhjNTRHa0t4b3p1bDJGeXVZTEwtZE1PWURUTXl2cGJua3U3NFhJZzBZcFYwLXpDMDd5OGpPTjFJT2RsZTdtbDVpbFFXTFV0MS1QZ2pmOWpBN1RlZEtPX0RsaHYwNUlJdlJueGltU3ktVTQwT0JjZ3BISUo3M3JVaDRBMjdrRjVySnk1R1RfcERjWlRjSkFESjF5N19pekMzNEpyVl9jR2FlbE0waDhSX2VBOEV3ZmRQNHI5dXNHZi13THVPaVRsdGpUY21JVHZPUWo5VE5HX2FkUTEyRUJaQk00VzVSSEtXUDUxLWRnRnRzWmtNWUMwdnVqN0FDNXlscW5Ec01RU285UlZTSklkakdpX2VHT2FuMjhkUGt0RThQM2duemlVeWZsZWdfWTlzVkxDbi1jQzBzSzBKTGN3bW5GRmxUc0xjR01vVFFtQWtLeTZDZGNsMmhLSVFUUVhKQ2FrbjVWc19aQ0JENk9CMS1UN2U5WEhadm5QR251cVo1RUNPaHRmbEI4Xzd2aXdJZHU2SDhMaGg4WkdwVzBXalhNbGltWGpxS1cyN09kWmU1Mi01RHdzNTlybEN1Mm5nUUxBRUwwNjJ0N1A3TWZUZ0NfNmlabDVicmdGRWMtZDFhamZOM2I4V2pVaGYzb1VJWTVRRld2ZDdma0J1UXVjMDJpTlZVemRWblM2Q2lLTG9sY285b3BtNy1MQWdqYWYtVkhaMDZncExGS3BUTXlEWFBWYjFuSjhqdnRKQTNmMHJvVVJacTdrMERfMkx4SkxuQXhsMWNueVZSaWp3ZXAyeDlLc0hIbTVoaEJ2RnJidThLUEF5M3czWG0tWXBiMHR2V3FtOThxM2RyYUVoQ2RsaFZJQmxIZFdCWDVxeEN3b3Rib0dndzBUNXVxUE9ZVUk1b1NSQ1kudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Zaa09EYUxhV2xLRGVzUU54eTlfcDZvbTNLX3BHbWlCcUd4bk8wN0JDaTItN0JEUzlnZENkYXlXeF9JTEhZaTVNWUY0eVNEaTU0RWYxWVhmLTJ2MHYxS244TXpxdll4bFVVR3R6TkJlQmNrc2w3MDJZb0xpY3VJM1l0WHVfOUV6QVdibW4xdWRfUWdMdTItRlV3WXF3NG00TUdvOHh2NUhUZjBJa1JVRk9tZFVWdjNLeUllakc0aGVtQzRoeUNPd1NhRDZZUmotdFEtRm1FSnNXQTQ3ZDZNdHExeDFuV2gyTDZBYWhJOFFVVzQ4T1ozMW9WR3FjYTQxUmZnUVNoSkMzN09XQk1oS2V1OFBQX0tDQkduUzlvaDNZOVNIQWNlYWh6anRDTk9VdFhGUnRyZEtBdV84XzAtMUh6U1h0NDJzYTBIWno2aXJkc25HVmtBTnpwY3lkMDI1UENpUWxQbHNQa2FPSVA4T0FqWXJoQlUydG5rS0tPNnQyWkFnc00yZ21KS0pGVVpab2J3Zi1qYWNXY1JNeXlHVjFQaWlvWWdPRjFvZ0hwdnUzUzE5ZThtdzFEVzQ0WkVYUUtzRWc4b2h6dUZNWEc2REJHZ3RLUzNxam0zeGJjQWFSUFFsVkoxRUp2SWRhQ1VUTGEyVjRLR3BZbUpUdkk2Q0hGcEtodnFnYWhtYUtvTDdqcmZIbzNuWENqRmVwUzBudlc2X1BPWTRFZ0xjNDJiQ3BILVVBMDNXdGxMUEFqeTNZeEVaV1hEdHZlaHRDR2k2ME5CMkNOb2hVclE5UWZfdVhYTlNWT0dMeTAwTmo4Mkh3TEh6WWJwRGZNY3I5QndrS0xVZHlTekpwbmtmVGY0VjRBaEtjb3ByU2N1cXRScUJNUFFmdnRCUGFvemd5V3NHM1BvaE1QXzJOWVdmcEdaMXBfbDA3ZXlrbUhBQXVGbTNCcWs1eEZXdFdYTmhfT0ppRXZWMzlkam1KVk5wOFl6WGRhZUxRODQ2RkQ0WTR1S2NucU9UNnhleFdYSEdaSEF3THlFVW1EVUkycWJzdWR1X0VETEJDWlFJbWNUSWtMc0d1WWtaSHRWZmJ3SkVBTEtJMFFIT2s4dTdWZ0ZFcXhROFNDLXJWZmJjbWJZZjVJalpiYTdoaGdydjNoTDRFempHRWExbnQtSDk3clRURkVoQmdMRkp0RWRVX3hzRENGMlFOVEI4Z0dneWs0OEsxX0w3aTdxYUtUbTgudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZFTURGOUNTR21kYzNzTnRkRDR4d0NXV3hCN3NWNVptdV8waWZ2UF9SeE1rYVBYeVc5YndEcmdSclBqckpLMTc1TXBacVlMVzYxdzRxcEFYRUV0bUh4S1JJZzlzc3RIMnZxb2ZvSWo4TjM1RGxleE5SX3RHYV9XRmZuQ1hSNWhrYmQ3YTRxNjRHWk14cjBtMTYzOHdYdG9FWFlkbHN4N09YTjVVNWwyVG53NTlJOElaa0pvaF9wRHZrTDNRQVVic1Vsc1BPRWNrZzVsWVRVR2FHMnYybTZlNEhYQmo4Y05JcjQ1bEZ4T3J6VTVCeGlqZ3pUeEwwVWFhRXl0TWVyV2IzakVSaVFBZ2R4WkdUNzJ1YUt1XzU1TG9RN0w2czM3ZzNpVTF3SThITHRXWUVOVkpUU3dpRHJiRGdGOUgtb1FUZ0lEVDBiNDhpeS1ZVk05WldhZzZ4OEl1UVI4ZG5haFBMTDFMSDY2Y2xXd2Y2MXk4ZFZSZUdHbUN4X040Y1QyQnVJRmduclN0VEZ0R1M0NXZCM0dWN2RUbWpXZ3lqZFBINEZlUHVrOFZiejJ2bjJQQk8yNlRYOVpnSkZDYUJTQTFGcXFNTHh1bUdKWUZIMzZGUnV4T19FcVpCVDVWQ2xXa21Rb0pFNHFWbm9OQllTeWxGUTBQRHFoeGtLZUd3bUR1Sm94b2UwNW9jWl9YOEFDMUoyZDI3dllmamhYaHFYeWJVeEx4WXk4YVVOR1JsdEs2RDE4OVFGMjhWUGw5U3NZWDRwazE4QVBvel9uRVRWb3hEUFdkVlFtVVlPTTYtckRBR3dpbG1JN3JKUmRkdVE0M1JueXZFSEotWDN0OGlQZ0hzQlR0dUpZaE12UllucVJqVEhwekN1eDc2LTFUSTlyclhHam5nbXY0UXA0YjE5ZUJQVXc2eXVzdWVTeDVMNnY4eGlLUmZ0X3NXSDRjNWlscGJPSWhFbGxXcXhROUJWZ0k4bG9IR3ZoQkE3NzZIRTJ4U09SWklXSUdOSDRvc3VEaW9CX0FGQUEwMlFsdGxRMFUtWlgtU3JYNG45b3YtQjJsTmdWQV8xb3JVYnBCWkI4cmdDSjdmek9MTFRHVnFQckx3cHNVMEhQS3ZFbkgyd1gyRHJORTgwa3ZYbXhzNU9iRURMWGNMQzhUR2RCOHlDV1oweE1iSEVoQlRhSDNfSTM5XzdENzB0bFlGLVlPMEdneTcxUktEVEtlMk52MVNCdDAudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MTguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Y4cnFaTFJnRDQtNXVjQnFldW55dU01X1RHNXZOeW5qLXBwX0ZqclAtUEtHemluR1UzQjdqclFWYUhwQTBReXZYWDJ0VzQzbDZEX1hOVlVoN1BtT0l2MjVnbUF1TThheEc2RGh0WEtCR0ptUWlCbzhrZnZxeWZfbW5OYnRlYkRic1c4Zk95cEhkd3BWVHlBV2daanhqczVKaDV1ZWlkUDJOOTZxMWRuM2VRSHZ0V1R0NFE2dWlvSk1pb0FuLUNvQVMyUjR5WThScXlUYkIyMlQ2eUR0SzBHbkpnbEl1WGdKUHB3dXF6ZnRqRmxCVXhvd1VxWE5mU0FreU5zemJsMTV4eW05OExEMXBQZkFEUi1ra25fVFdzeTJtNlBjblh1LWdTQnZ0aWcwYmNGRThiSThjOC14R3REZ0dsWU93YllmandINW94NmFoV0NPSnY1NTRMT19Bc1hvcnU5cGJWLXBlSkUtbGhNMHRVQnJYU2R1eHRBWERwSmtlMzJtX3dwa3FnTDZWLVZyYmFXNS0ycFVSM19DaklCOUJYOXBqWmJKWjdDMVdNZDFPLWJ2SE1IS1psOU1aWmtEYllTVjExRS16X1VpcktxWHBSLVlnZWx5aVA2eVhuUEVlczRlblliYUNDTXFKZmpncGotNURyTkFia2xOU0Qwbm43WFR1UjN5MFpHdkVZT182QU01OTQ4NHlLSllheERKeWFfNnZhU08xc21XYzZ3R3RDdHJfLWNOLS1hczBubHlMcXktU0JnRTZhUHN3ck5qZGo4VUJYUS1iaDMtV2hJaUtnbjlXSVdiR1ZPNlVZaTVyMU9tcTcwdEVvQldoaDZzNVFhN2dBWEpaanVXZ0ZaQnlXQnhEbU00WUpWN1VBUk40MzBtX2hyUzdfTU5XQVQ3ZVpSampQTzFFOGpsMzBvZlJJZ2ZPOU5hZjJ5WUJqWlFHTmN5ZFI2YVlKN0dvYk9sa05oUEpwbk0tSTN2NEtmNmNsU1Q2Vnk5RWtmQ29PYmNBTk1NY003bE1rdmZNSElzbWV2d0FraW1jQWwzQmp5X1lhdTBWdG1pclBFc2lSQVo3LUFobnBWWkR2U0s5b1hEN21EV0hCM0s0TVVkT3RZRzZDZ2RfUHFzZEVaYWY1RXhjdnhDbU9YY3BTelp4YjRDcHlLYW9JR3p0S1RZd0VoRDQ2YWNQR3llcDhCVGRPYTVIbzc1ZUdndzdGek5XZE1DS2ZGaVBBaVkudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjAuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZzRnVJS1dnM3RCcHc5cVVyLUplTl9FdHVuSnlSbm9nbXBrUmMzVkRyUFloS3JscXp2YnB3dlFQdy1oaWxaU1dXeHIxS2xzcjU0bWU1cUJKQzZrbEVmMG9INFRrWXROQ1FWaTBmTE1IUVg2S0l0eXdSanU1QVZXS3EyQVFYVXlwcVozRktVNG5sTjgycUk1Sk90WXh3MTgyS2lMcmhtUXFEQmE3QUl3dTVLRGw0MEtjczhaYzI0dUVZUlp6ZmMwQTJGNjVGWVBURzk2MUFvdkxJc3NTb01OSEdOZkR1S21tN1YtWGxnaUxqdXl0RXlMVW94a2lKbVh2anpzSmZHeGRnNExxRExhaFk4TzVDRl81YXlrU2xuOURCZ2t1SnpjNGdBTmRzdndRdDFNUkZUX0Eta0Z0NEZEQ2dZcmNfN0FEbzNUSy00SHAtemltVm5QUHB5S1JKQ0NFb3YwblhXckI1dFBUQ3RiUDF2ZEpiVk1EOFVNcllWekVqZnZUeVU1cWNQTDkzMzJIcFdsazdhdjhuaU80TlZuSHBsV2Rabkh2Y1JhVVdfSmZldlpMVHhWXy1YSTFyU3M3a1ppdjNBSWptWmI3ZHdRYlozUmd6b1IxNmlSME9lWDZkNU5KSWU4cjZqTUFWTk82czRzTExHbXRDYXJBYWJTLWU1bnVFMGxpMVdnWEMzZ0pxanpDZlJ2NllNQ0FXWWM4ZGdydjdhMmExZWZHd3VycVAzTndMZnRZbldncFVjRGlDNXBmMGFJdl9ITG1SS3VHOUlXWG9rcTRXMVZ1RFdTV25SWWhRaWZFZlJ1NkZZWVZzZjRoNTlvcHRPa01RUUdQdC1OVW04MGxfX2R6ZjVpOTFrNVZ0Wkp1RzJSd2RwemN5cnJPXy16UXNUX1JaQ255amhtZ1RHd3FUVVRYQmNzWXdZTmRSak9Nd2p5X2VfNHljQ1p6bVd1Zm9uY1RjUEppWnpLcG05dG1hVmE4VVozRlhVSVV2dU8wRTRxSzFiU2NVWFZoN0sxZHRtMEdYYk5aLWVyM1BSWGUxVE9QNVM2amtYbE96M01ZTDJTcWNPREI1MjhETXhkQWg2RmR0RFZjNV9oLVJveU5ON3c5UFNJNVJ5MFhQQTBpWlgwZC1XTVhnQXZHRE5pTGZvMEpHbkdrTWd2cWtBN3FHYTFQU0VoQ1BCMl9UZGRwdW1QNENDbmVVLUpxREdnd3lYNFIyVEdFaUZLbXA4MnMudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZOa1JVNU9vWmNIS0FuaHNWS0lZWmgtUm1kSWtXT2REdGRFZ09kTk1qakZNVkhQOFlocE1IYXo0bjBkaTd4SEtqX0dycEFGMHJHSkhLbXdWSHJTaHFQbkFSYzNrbVlseVAwSjRSYkV1ckhuMjFTcEplZjc0NlhpRzhhRUpCWWp1Z3kyMng3YXBxMGRKaFVkNVVBYVRCMHpiYlpEbGptaVd6eThiXzljWGhEVGdmUk5ZY0FtNVVOY0ZZc09KeXgxX0ZvZGtPQkVGWU5NT2tteG5HU2tWV1JiV1ZvQzM0T3VvR200Q3NZbFE3emZ5dnB5YlF4Um9jby1KdnlnMTZLbDdaY2E3MmJEZmZ5aGttVm45ek8tbkE2TkZMZE1odW9BTUFPdnFNYW1McGtraGhkcE94UjJ0dGFtNEs2cWQyTjUzZFNrOWQxTkItb1pnZGlLTkYybHQtcGN3TTVGWTVwcl9uc3pTcm1lUE5CWHVsSVBGNzdmdExSMmhMaERYZlNDUng3TFNKR1VkXzgzejJFNUgtWWV5SHhHRy0yZlRKQ2xDUTY5QUdXX0U2UVhzcHV0Vm9HYXkxT3NuaUZCZm1tOXl2NGlGQUlfeFZza0J3Q1picHlCY2NxTXNrbS0zY0FVNGIyNTRTZWEyQWlPWVczOVFYMWYwMy0ydXRxTmRqV0VQcnpVVTNQbkpGdFd6MDMwYmdfLTBZcFNDOFp5M05aVW1kTWJNRmFEUlQ0dWdaX0hZcjA3UjJOYzV0dzVQdWlPdDMxb1dnMVdXMEpZVDBiOTl1dWdSZXVpQVc2MmFVbzJ6VU5BNThPb0pTUGM2WVVHUm9PMDVTeXp0TFJkMUdBRHNFVEZod0xQeDFtUlhpTU5mM2dnLXRMVjR6UGp0VmF5SWEwdHJDdEJSa0YwMjhDM2ExQnUxeEtDV25WRGhIRzJVaEo3U0JjZzI2YURnaHBPQkQ4LXU0STFPWTNzaWF5VlFHZ2U3NGE4WHJQZnVXaXJaMWtSbXRnMERGVzROSjBtdy1nM3lKbjh3RHpnUGVpVFZDRzVkbFRjUlo1ZEI2OUdiRWxzUHBlS1R1TE9Fd2FjSl9iT0FhSWQ3VE5RWkNWRXloc1lvM2JtcXo4WVE0TEpLRnJ3NUZSYUVDNXc1QTlNaFEycHNwV1NZUC1tWTlzN2dKRHJ6OEVoQTg5M2UxUXhfZ3hSR25Fc1YxenlCdkdnemN4ZGZZVnJyOTVIV2hFeXMudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZtcUxqcXlKbW1sMXMtMTlKTElVakZpem9qWTFSRTJGWnl1cHBlM0NEV1RUMFNGdDdIaXd0dlF2UmxyYnBVbzR0TUVOdm0tYk1iS2dYMi14OERHUFBQS2RWMmpXUk44Y3BfakExcDR6UHlMSnBXMmYxaTlxQVNJMEFvXzVRUWVZVHdqbEhTYkx6ck4yNFhsVmxaQUhiYTRUVzR1ZEdNY2Rqa2Zud2hqUW1kUmY4elZaWHUyeTNFOWNTM1Qwd3RvVTZNOHJ6bWp3VVJsOW1pT28wWC15WlhNeGhubUFXM0xrTlZ4Z3I2cUxHTndSMGFYbU5WVWsyVnlJUzdHby1MZ2RxdkcxWjl0TmE2MlJJYk1WVW01aWRZZUg4eWdjRk0wdTVnbDJMWlFMX19hQUVTYzRURG1DSm05anhsVmtyRHBWbjJYbjgtdzBWcFNGUEcyczViM0QwQTRkcHZWQVY4ZnJmeXFTUE5sQUpJWmlqZ19sZzRtWS1Qa0k3UDI4SmdsUkRTNkpaZ1JYRDNDWjhMc0tUNHRKTk1JTzRBY1JhX25wdlhMd1h1dGJMU19RZ2FBNnl3Sl9wTDdjX29yQnQyVG1UQmIxaEptX0VUMG9yMGJ0ZW8wUmRlNm9CNFFmMUlyTkd5cjhWX3d4bHc5cnFSWFVwa3BkR0lzMXZDOVpSZGRycC1MX3hCTWxBMmJlVVNBSWRqNlhLaHdHbHBKNHJiRDRvcEhnN283cHNkOEl3MklqbUlYU3FqQzNlMk5ZU2FuQ3VXQmRzYUs3QnFLby00RV9mZFVrRmVkbU9GSDV6RFAybW5teEdyUUIzRW1TVTFaR1JiSHNHQnZ5eWNLSmVHeTd4YVZ5ZDdsM2hnUy1rQzdHaG9UQ01ITW9DWm9OcnpYdWowdmRUbGpDOGdwMkFLVE9kV25PQWhsaE1aRGFXcHQ3WnN0aGlfUnB0WjNEbWdOLVdvOWJPMU4xWl9mcTNqNk52eUc2MWh4b1dVQXc0RTd2ZVBha1QzZHZJX0Y1MTUtcGQzejRwQk5vSGlmMlFpYWoyQzlPdHZOQXJpYy1mZ3VrSnJ2SGo4S29aS1o5NnVHRGc1M0pWTVVwWXlwVThHRXNZZ1BoMjJ4eUUtQmNpckNDSk40VTRkTkxYSnBTR3BsTlZ2ZXY4MnMtNWx4VTY4WDJudldSWkVoQmFla0c3b0xSbl9tenhaM3hHZTNHY0dnd0t0U3lUSDZkeHNla0tVRzgudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZNZWhXcXVjRVJVMHptQjA2Tkhvbk1CemxVbkd2MzNtN2tiNjRrQ1JKUmhxNTl6ZFVDQTNqc05IMGs3MlNDcC1oVjN0enI1OVZhMDJEUE9vdmlGQk1DcGk3aVRTUjMwSkM5eUVzUjVsNG5BdllFQ3ctVzF0QldWdlJnYTBGOUlRZ1Y4LVRwczltSEoyOXoza0d1U2ZfRDZBa0liaVJfNlRIQmRKZHhKeGpvaVBSaEJUSkpReXM0UXhGbjJpeU1DWlVVSmtVMkNYdHNYUkQ2aWwwb00yeXJ1dF9Ec3J5bmRSUUdVQVU5QlZzSURMTkxvUWtRZkFfUG5hMEJzeEFFY3JjWTZoWnRISmhOeGFPWHFkczdyb2lEWF9BQlhxQjJteWU1MVgxQXl1VEZydDRpeFFXY2ZSaTVuaHA0VjNaaDRvTTZ5aTh3R3VvUXYxbjRyMFhUd3FiX1FyVUtiNUJWRVJxek5EV04wZTZWMkFSOVI4VHlDczlmQXh0dEZ0Z3gzY3lhM0FJeEFFcUhqcEUweGY5dUFTdzVQbkV5MEUwWEgyNzRFS2ZnVW1VVTMyY1dxOW9PNWphbl9ENG9pdzQ3ekZEQXA1NTFWcllJVXRjY0FNMGFLUnF5SGo4TDhYeXV6V1B4SmZMM1lUVWl0dzRfZ01qWXAtUUNqYXRneVgyZHNNSjExUzQtc01obW5HS2hlSnpJZEJ4U0J1bzJSc09SbmR3ZUVSMEllNFc4c09oeEFIRXRZM1ZBMVVWdzlIREFhSVRWWEd2cXFFRW9lN1REY3k3d0VzdHBybWR5MllSRzlQZzltVjl0UGR0R0p3WHBabnctbFl0NFVBQUJNUjBWZ3NhUjk2UTRqYzlQb01uZ2ZfWE5kN196amxwa3RoekptY1FfZG9mOUxLRjBUdERINDAyZ1JoTzFKaUJiR1I3LUM0SW9QUGNUdy1NMW01TGF6VkdHWjlHX1VfUTVoSVg3VTJMY183N2Q5TXNTYXduR3A2MjlRU3ZTMkFjdmw0ZE5sTUpJeGl5X3Q0clJsTU50NFprejlESlJfbVBMX0J3QkZsMEd1UTByQkxsZXR2SWl1eEZ3NGNlYVVIaWFDdzZaMGJGN21oQXU3TVZoWnQyT1NMN3M1SmJCOXhPSFZJdVRxQnJJbl83SGpGYjBHNy1uQk5PcGNFMkVoRFFFQ1VJVzFXX180cXFsYUNYRTc4eEdneWNIZUNSOFlCR1VFQjJBSmcudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MjguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0YwdnJBXzJOdzBMejNWV2h5S3QwVVd5ZmtxUE1JVUh2X0FNUDRfV1lnMXNtdWJ1U1JGTlV1eFZzX2dqTnZYVi12d3lRa0lpVEl2dGluSlN4V1kxQWZYQ3E1RkpzWnhpTkV2Q3F4ekNicXpVbktzSU9oNXZmNXZuRno1ekhmN2JGNGJnbHA4WHJEQTRQNTZvV3VOY1NfNDlhZ0lHWW9sT1NCQjFkX2JJN09sT3V5SDVaN2l4N1ozXzdVeHJYQTVXUjc3ZVpfVW04SV9MbE11UWs4RWNtX2w4UzFUMzlmS3haNHkzMUZDOU9xUTBDampITndieXVkX2xVNldRa3NoUktCaHBxNjNMY2ZsMkxuQzkyMmhGVExDb0tIMlNvTnJBMHNTQ3dGWU16S1RKY0U0ck1YN1NRSEt4RDJ4X2M1VkdSWmk2Sk1hMVJ4Q2M4UURsaWVzZTlHRFNCZll3VjladzVlRWFub0lpNnZiVHlEV3FGNWEyUHpCX2NzREZCdVljaU5MS1dONElKd3ZRZUU1Mk4yY2hoUGlyYzJ3b2hyZjBwdzA0STI4MUdtd2xrR3doYmVnLWhDV3RVTWhwcFFWUHI2NWN2QmpuSzRVVm9vTmx5djdCSFpOdUJrXzJTREM2cmdTVHlDZnp1OFdTMG1Rd0NDX1BEMWxlbm5TZ0p3aGc5Ml9ObDlmb2tkV3E1em5BMEM5WGpEcXZjR0VtNWd5YXJlTkk3cEZTb2F2VW5lZkEtYU80SjJMVWs0dUVtVTVNbEtfR2FZcFhad3A1cU1ELWdHWTI5Mk93Ylpzb0x4SVpaREdSVzVsM25YY0ZFNXZzWk10RW55NTlYQVUxTkxMQVMycVFkYWpYbXFoUFY0d2lndEYxdEMwaDItZHhyb3Y2aDExLU5teHFjRmNvaElMNXN0QXdkZmdpcFB4NS00Y1BPcW9SVjZHY1M3c0RLQ1BUTDlaUEowZElHUHhzSTN5ZThVOUcxZUNlUnN3OC13TGl5aDM0dzhUMmQ3aHZ3UkhRZFBQalFEZWVHRkpMeHdxRHh3RDBQNzE4MHVkRnlJaXU1MXJzdWdfTjdfRGl2cHpMb0U5QzFrY1dVcnhra2dmTEFiLTUweUhYUm5QRl83eVZsOV9fdmNjX015NXpsUEVHYlpiMWRLZkxGWk1GX2k4ZUNlS3FtOEVoQXFSdm9fSWNWZG53V015c3pMNmNTdUdneWxyVVdQZHU4T2RfdC1tNEUudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzAuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Z5anRXVTZpcFdtMkNiZ21SNlRsdU04WG9QMllrWXNlUUNLdVdjQXljMnkzS0RneFBUN0pOTU9mdUJzS3NNcFQyRHdDbnNNTlJYTnZxTXBuRDQ4eGU4SjdZWTF6MndtSjBCTnVuNk8tZHRycEtsaF9RelRkQl9lajFaZnlJSXBHa2hNVUhhX1ozbFlzZldEanZGYm45a3BSQVVwMDlxX25uZm02QUpSeDh0NHRfWFM0TkcxZWl1MEMtSm5MX2lKanNPNS1Kb3BZU1Y3Nk5peXBEbTd0U2RiWHNMNWdIOGFwRGtjSzgtUC1pbEdnWkw2ZEF5dlpxRHBPTEpZMEVqWnhWeW9zNm1KdFZ4NXdCdHFJdDk4Z01rdFVMdThiQXpISFdNVkRzYVpzVGV1ejNEdEVLSnpNZmJpZXI2VnQ2eERHOUZnalNaVlFKLUFjR2RmUUR0bnV0RUNjVlE0ekJLMXN4U0VNQ0Z1UmVCekp0Xy1DLWhEYlpYc0hGN3NUT0I4dlpkTlN0OVNGbmNrNWEyNEJtd20yUDBsRWZRTDFFTUhubmRuN0UxVFg0OU0wTE1kaTdBanlWSHR3SkpldHQ5M1BZbC13dkNtaXVBNHVqdXoxZXBjWmhJRG9IdHhJRUdoVFNvMmxQQklBUl9wUDM0QkpBeUV5YkVzRUVBU3FlRXVacThXanVmcmRiblBISzZEXzRHS1RzSmJqTlV5ci1HOE1udlItU29EZnhzcDluMXNTeGRVUWZSR1NGc2lNN1J3dU5UMHJYZFd4Tnk0blc2TkxBcGYtQlZFMUxuMEx3VzJIenYyQWNFWDFZeEdMQmYwRHdtWnRxNDVZdmEwU1pCc29EVnJVeHoxV2xhTk14emFpUW9oVXVpazJBVldxTWcxVkI4MDVnNTU2Uy1mYmllZXR2WlFHa0dsRFZzMmxwbUZaWjF1TktPU1JMZTlYR2txQm90TmZmY0V4eThhTUJNOGE2aTFQMF9jTEFzZ1l2Wkt4WUQ3cWRFbHZ1aXdTc1duLWRwUjE1cjFuZkI4cWxUcmpXVk43NUNMa3g2MUZYelVKREhHb3FaTS1xZDQzZ3VBVGY5N2dKTEs4QnY2ai1aWU51Y05NYlVabWlQdGxZU1p0Z0ZPNmFWRzhUOC02OGJZSFh4dHRHc1NHaDhyYndWcmx2S0VHR0VoQk05TDZVQ3B6dE5QenExeTlZWDhWekdneTZReDN4LXNBVUNuQnptd3MudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzIuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZkQ1Flc0ZNYi0xQVZfdzBSSl92TjlJRXJrclYtMWprb0NQSTJMZkNJT1RrcEl6dFlaTWhvWXdxQllKUGpkcGwxVjhRalFQMVZoUE9YV0FuZF9fT2c5bXlCUW5QcjNEbEJ6Sy1GVHk1SGtoX0dQQjUta0IwNkxoZmF6bjhDT1RqY3cwbWEycEVOeVdCMWpUQ2poS1FBa0RFODlvWWlZcEt1NFhESVR1QTloQXY5MDJUdVhSMWFST2E3M3V1ZHkzNjRxZjR2Y0o0WF9FSHItZTFSSFUwUFctdXFUX2JlRXdGNEdoajZWblZxbE5USGhCcGRRMDdwZ0hiMEFoQkhMd2toRm12MTFqZ1U2cEU0Qmo4YkJEVDZ4bTRQMVhuOFhJWlg3blYyd2lYNy15anp4b0ViejRaUUtpeG9KVEhzOVFtOE82RkFSdVRQelJTaHhkZnJpbXhtNDlzYXlaNzluM1pjMnlQVXpZN1c5Y1NNYXVHR3A1b3lOdXpFbGNVWTJ1QVdqdDVyOFJBRlVIZndGVFp3ZjhCQ240ZWJuVTFTLV83b3BXakpRbngtOXhOYXRLcjZoX3dSYUt0aDlKOHZuOV9LOHktTk13aXpaQU9OS3lVMFIxMGpaLWhOazdwTlRxVEZYejBadXY2SVN4S0lDbTVLUUZLMHVScmFLWGY0RTlvZ3Z1Z25wWVFPbEp5MDdXblFsQjBxQlNIZGVKY2wzei0wSTU1R2c1Y0ExMTl4NkJpM3hVT0lRRURNbFlPWmJHanlPM1FtcGhab1JYUEVYaFRVUUFxeU5FQURfOG9sNjZuRlA1dkhpX0dRaHNwc0FjbUJiVGczZGdKLVpVZ290c2ktQmJoNHljTUpWckJpS2VkdDV2N2t3RmUxSWZKel9WS2IzVTQyc0tRd1laTEZ3MGNsUzhoS1hYX2ZIRUVabXVIRzE1TGR1c01GM1p1YTZpQm9wT2djQUtTcFRiX1A1WWpVekJfekVxQUVHdzl1Vkh3UTBqa2tSa3YtMlBnZk9EV0FDcU9fVkFZc3Uwa1RvbEt3TEdaVEE2YWxkbUxpeGtkTkdGV2h6bGhaT2NXb2tRVHE5QVNwUk5hQ21sdkdZVWtMaGpPWWk3TGt6V0tfWkxzUnpwZVk0WWxyczFvMHF3QzhtSTFZZHdtbi1iODMybUVucmNkbkVoQ0NGTzYyRWtTVDIxWVJWdE9heDg2eUdneUhPNW1HdE85NjNfMDROcGcudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzQuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0ZIV1FHaHZNQ2Y0QXlkRDN2QWZnWkFFTmgzRm1CWWhQa3ZIN0ktLVBCaXVUZnp1cnB6cG1NaXY3Ym9RdXpxQjJzbFFyZk9ESzNiMnVYZVA0N3YxWWpVdnBMNV9XZXdadlZlLUpoQUY3TjhDcjJ0Vi1ZVG03elRma1FSRVVGeFVMLUU0RHUyVk9tcjB5OWhINkRlSGpiME55MGJ6OFhiTWN4Uk9GMW03S1BJN2oybTE3anlmV0VkRV9oNnBPbjUyWXZvYVZIRzVrSGNDc0lxTmltZVN6Q0NCOHZLYkd4ZFFrUEF4dzlkRzQ0QklzcWYwWnVYYXJKbm5iMTNVblp6TG1pdlZxRF9hVWo3dDM1aG9seE9zSHI3QVg2VzQ5aGk4REVQYzllaVd4a016S3F2aEhMTXNBNVBCcU1VaS1VS0N0Z3M4STdWTVdkRDBlUnMyOEl4WHFQOFRqVmVzQkdrSkZ0N1M3TGc4NTNpczRlNjhaOVNRZWhUMS00V2dLejR5dEhzTElwYXdDU0lqV3RGa3o0NXRiMHowLVpHVFoxdDJUU1lWN3hQUmlwWUhKWEFHN3ZVcFd1OGRfa3BkTm5hUW1FYzZFdjBwbjBhSDdYVFdtbEpQek9MMGJCVHJ0Nm5yUko3N0h3SDVoOW14b0x4UHNQN0hRRXN4cWNDUGNvY2RjcWxYWkhMUzlYaHpobmEwSDFHclJvb0dCbWNRcENELTdYeHdCSlBpN3FicVVlR2RMMFpzanFGRmp0bVEwd2FSN3VhSTBBX0ZsdlA2eDdwdnZnWjh1c2JCZFpmNlZXXy04QkpsbWp1MURVN29UNEo0bmtxME53M0xBaDJ0b0treXlrVUlFWFlSZWlxTVp1SzV6UzZZaW9wTWJKOWdtSE45VnQtVGpfZmx5ZWFWN3diR20tNGx3TjdSY3ZMQjNISnp2X3dyYVhEWlVKdXN2UHlUc3hFcHh3RkRCTUxYcnEzVm5kS1A0SFY3Q3NtaWpobTRXYjRNTEFqajMtVnd4YzZ0Z05OVGVOTlhPNVltUHpQNV9rNGRqRFZ0MHNKNkZrT1VPbWtLcmFQamctaDF0QzJxTkQ1czBsZFhvVDNZYjJMZ3EyWWVHcE5XclRWWmpyNHpwaTYzS3NrR2NvZ1BVRUNNSi1Cd1ZtMTlGQ3E0MElIMnVvTkZKaUVoQWdQUkl4cFF2QUZGNFdndVB4WTBvakdneUpFTC1FSHYyN1czX3EtY0UudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzYuNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Y0eFl2UGxCRUUxTHNwVVF6aGlWQlhGM2NfMlVkSE5CX3cxdEg1blNiTEIxWWswZmx2eVFwMDU1WWhGdFpVM3g5M3ZBU3NXTUV1WlFvRkFrWGF2NEZlNXdNWTYtYkNBOTlqRXFCOGJRcm9RUERuWXJtbGtvYkFwbGh4Z0IxQ1BIcjZDSzlxSV9Pb0hXVlM0STJ1NmdlRERrV1R2ekdWSlM0QXE2MlR5SXM0bm9xNFJ3Q0xWb2JvY3gtS3VtMVdOcmVFT2J0NHdSdUIwV0ZneFNQNjRNaG5DSGlVcnlMRnMtekJYbHRpc0EzMmFhckFYSlhadU5idGNzcHNoelhoQ1JJN1poSWZSbm14cUVuTXY5cWYtM1pqZnY3OHFjcTA1dTJ0ZzFxcVNkMG9sSnc0c1puT08zaV8ySVZjTEFKUDA5RnNPZmd6SFBBTXJRMXdrbGw4OFVsUnduSGlTMEw2SFlhZ1hOOHlSUzdZQ3pMRjRtMVVHMW1qZ3M3TmNsRm1xVGJUTTYtSGpKSGlzMmZQWUg4YTctTER1WXJFdHBGcDdxcmZDZ1NVc2p5QjM1NkEzMHc2c2l5eEctQVN1S2hiSWJjaXA3UEN0ZGxQenBjeFdQZGxCeEwzRnN5cHR3d3k0NWd1OE15dzJnQUJuTmxhUWdheE50ZUhIbkZWaDR5UjZtZG9jZzFmNTgxVWY4YUk1dGo0Sk5aZ0dIQ0Q2aERFejJGZU1LLXJwQUxDLUhwRzZiVHYyYkRfSlpkRi1XZUZhS3ZyRGh4b2hDbzJuYlY3ZXZLOGsxTkpocVhvRmtlcVRCSklWeFFTRjdqano2alhWcmZwRERmazRhOEcyYTVTelg5cGtEMmdlXzg3UG5WbWdZQWVTa1R6TGFSdVdzNjNmYTQ3OG4xUUpVQ1lvR2h4SjVRSzFxclF4YzdiNE5EZW5oZjJaSDI5blZKVmRBRFZsd1lZcWlWalJHVFFGeWxES2tUOWsyampkZlc1LTVZSGlKS1oyRHZ0Yk8zZm5qU2FHY3ozTTRKd1ppTFBQSUFibEtUaWZnOU1QaTFnYUZra1ZyZFhFeGtUNjNxNTdROHNZOW9JR3VOSVpHMGpBNmc0ODNvTDRpT1lTRDVnbGMwNUkyU3hBWUpDUG95dmdKczYzVlhkVjBfWjg2N3I0N3ZlbXFUNmNUaEVoQV96UmlhUmtJMHp3Q0E5NjF6ZXI3RkdneXNFTlhIcHlCdkVnLVpaTVEudHMKI0VYVC1YLVBST0dSQU0tREFURS1USU1FOjIwMjEtMDItMTRUMjE6MzM6MzguNTM2WgojRVhUSU5GOjIuMDAwLGxpdmUKaHR0cHM6Ly92aWRlby1lZGdlLWM2ODg0Yy5saHIwMy5hYnMuaGxzLnR0dm53Lm5ldC92MS9zZWdtZW50L0NwY0Z3ZlI1NDd3TWk5SXJtWmI2UjJpS21SWnExWDQ1VUZNSnNHU0Q3bmZma3FmN2ltYjAtdnUxcjJ0djdfeFY4QnNBcUxMMnRkdVpHak1HbDNzSk4wWVhWQ2ZfQkcyWWIxU2NXaUpfOWttN0FTS0pSLUhwTzUyR2NQaFRYX1NLRG9ZY243OW5QWG5wWEtIdkhsSW1BZFVLWTNjdXkzRlpDeHJ2R0lkQTM1TmwwaVhwVjFqLTRQQmN1ZVJYYkZ5Z0YwZWY0LUVKZWdyY2Q5bzhSeUN4WXRzeDJ5NWRIR3l2blJ0VGE4M01VaEpnSklFbmd1SFVKS014NDFjSzVXV2pLbW10QXhMS2tSc3VYeGxCN0lQVWdOaE9qRWFORzFjeDRGY01MT2p2b0FpSDM4cHZWOHhEMTU4cHhuVVNQWjk3VnlkX1BmaGRZNjZxS19MTjR3VU5xYW9HS1pvRG96TWprSHVzN3NSYjFLNUxXUEZxVndSOWU4eTdmdnN0M2tjNjljTS1aNkt2NGU2SnZsSjQ4OWJTZXhKUUplN0FTUjRYNkR1YjhUTHpkTGN0VVBuMEFuUjE5MzMyMUJxV0NkLXV1c2wwVVdYMUdyZ2k2ampMdFp1ZkdESHVvY0J6YVFrX1FFV3hhRnBpb2twdUJaWjlHWC1XbkVRVkl2cVFGZXlUdjRRbjlJR0RCSkgzd3hXTEZPMUl5MDU1N0xQLXEyUUJqQldyTVQ3WVlmbWZyQ0tZOGdxQ2dBY2VFb2g1V2NsUVBxc2hubFg3X0JrVUhSbzVJVDJ0OXZFd3ByNnpzT04zNl9XSjl4YmpNYy1rdTh0VlhvMmlDN0Rscy1fYkk2NHF1XzVOUGhELUtCaDlyLWJ6X3Z4LWtuSkwtTndYeHZidWJUTGplY2RkTmlib1U5a3FBVDU3R2xZTm83RnA1UVBVRkFvYW5OVXdGaGgxdUI5TDlDcURUUWFRZF9nejhuLWswMmhPNHh3Wno2LWl1aVNaMFVlb1F3WDA4MGlrdTZGVUZFVEdBUjNKVjdibDlLbl9hMmFKRmdUX292b0k0NmFoRWFwbkJpZUdqVEJpbnA1MGQ0WnZVa3VrTmg3bUJSZ093ejdyUEEwdUwzczJVbEpNTW9NV09VaDliRnZnN2tlYWVpeGZGRC1VWi1BUmlUYWlVM3N4TlBNM0VoQm5ZYmdfRGZsUDhkVnhCdnRpcl9GX0dneTl4a25RWVBzSERRUFc3STAudHMKI0VYVC1YLVRXSVRDSC1QUkVGRVRDSDpodHRwczovL3ZpZGVvLWVkZ2UtYzY4ODRjLmxocjAzLmFicy5obHMudHR2bncubmV0L3YxL3NlZ21lbnQvQ3BjRnNsaExtamd1OFo1R2RfUnpJMG1nSllLWHBLVmpXd250VEFWM1dDN0xFVldKeFFUZ1NBdVdNc0ZaRXVHb01XQmdCbFZMdUg0TlhEUzVWb1ZibWFwbkpQS2JjSTdSbThaZUFuV0lGNndDcVFuQmowMkZfcWZ1MUFEd3ZKVmw2YTYzRDhIVU93RjZ2QzdYRTBVMXpLZVdQanVIdUItU3IybDFqMl9FcnVKa2xVLUh1TGhQNTM5a19GaUFvTkRFVXVRdGtNalQtTDBUeUlHdmdTZFBldHNPdDRWT1Q3eHZTNjFCaHBHa2NrNmhZRHM3SkRwakpPYnZlYVpxNjIwcnNRcFk0X3BNRmxYLVRaVDgwZlNuSnRaZzlMYnBGd3BvVHVpOXJzSmVkalVvV3BqMFk1ekhkWDRPZm9zaUF1elZ6OTZTSWpCcXFNU3dzcWJxbzZwZ0JOQ2xZNk5LelRFdG5hV2UwdG96ODNsNXgzYVBrQ29KLVhKTm5DZEdHWjNXZ3dQa2NhaHFGOXlpSENFZEIzVVRnOTJEamx4U1lDSkNITTVFY2tGV3YtZlVoV0JSb285cGdfRXlfRlplR2xaQy15QVJYbFhBTlVuS2NpMW05T3FBQnB2c3R3NndIMFA1QnhFWlBocDJ1WDFDeEVaalNLYnlDenBrYjR4R1dwR3ZUSGJlcWxfdFBaS2Z2Uy15UnFHMkRPRGpud2ZrM3dmdVhibDdxeXl1SkRHRU1haVFPVGNJeldXcjNLaXJ4SDZGUy1hQzlMTjdZdWFoLVlsUmpOZzNUT0YxMG9EcWd4ZUhHb21YN1I2MEpnM0tnTTVtbmlCbEQwQklVcU8xdFJseWxmZlNDbm1QbVUxaHZpdkU4M2ZiVUZjUWFidDFIako5OVZ1VXIzUng2WnZuU3ViZWdZZjlvYTV1VWh6NWNRckxHZGlCeUxWUkdKWk91bXVoMXE1Tmk3MVdic0dUWmU4NFZHUmxUaWpuQzU4YXpZeGhvX1RSWl9fcDRITkZubkhBLUFUQjRBQVhMb1AwMWZiNHZMWGVvdmhQWG5QM3Nranp6RHJDS21hNGNMdkFiczlzYzdyVUJYQ1pjbmhQR09KVHhhcTJWd2wyai1hdmRzRkM1Z0J4QjFKOWE4R0R6dERGN21RVmVKQkRzNFFmVGNpdDltN3V6cTN0RWhEdGc1Y1NjTmlsWnVYOE94bE05M054R2d6SmhQczBhTmJpZXZlU3Bway50cwojRVhULVgtVFdJVENILVBSRUZFVENIOmh0dHBzOi8vdmlkZW8tZWRnZS1jNjg4NGMubGhyMDMuYWJzLmhscy50dHZudy5uZXQvdjEvc2VnbWVudC9DcGNGOTV4VjY1QnVlLTVjWDdVNWRVSjlpeVRWVGE1eTBWUmg3Vks5dG9rOHNSVnRjMk5VVU5zSlhTQjFpOFJPUGQ3ZUJuUWl0M2t6RndHVnVNeEgyZXQxM21UMTJDQmVZY2k5dXc0MU51VFVvanRfTlY1QXZKRERuOVUzRklWU3JvSGQ4NDJqSFlzVDBNV25tMnBmM0IwOEFDOGUwOE92dlNxZ05ReVQ4SGhWLVdUaDRTbUNFZnFXSU45TGpvZXJCTmZJUk5ueU1RWWpKWVVzY2dFbHZuQWxWREpOcDhqbDN1S1VqUlVTVHdoa1dIU3N4TllKenJiRnFJNjRaQW5ZNDdLZWZORG1GVzNOTk90b09henRHeU85c3Bmc2kta2gzRjk0UUNrSy1ZUjRMX0x0VWVJQ2ZJbThxb2NJZ0JzX3hTWkRQemV2QUxJaW9zNjl5cFlheXpEczMwWGkyeDRvekdpZXlfMVhOMjRvRzMwVnhEd1FsMXZOVGxuNGFQbUE0QUtjcWwtVnBSTTlEaEdEd2h0WVRWT2RPUzRFMWJISE9MUm8zZUF0dE4xVVlXeFpvd05adERIcUpnaFlVd1pJSFBmRzRrZjNIQ2J4NHRZWlJJbHJScUl0ajk3ajY2ZTBneGFMVFAza0NDQXYxa2pXMnZWMmJxcW5ZOC1tSlVkQm9Pb0lxTzdEVUUzZ0pFRzN0b2V0WHZvM1MtWlFmVktqR1hhYjFrOF9aWlM4Z29QQXZ1UV9uRGdEQXJqbktIM3RvSkNaZ2poSGFsOF9DdC1lcktXM0pta1ZaQ1Z5eGhZei04WjZBU2lseHI5SXpKaW5JZXdOUVdHU3RIMk5JZml2YUN6TWVCM1NnS3ZwQVlvckZiOXVweGN5b0c2Ry14T0FRWXVEZXJsMDJPaWlENkZ5SnB5QWdwS3dtRW9feVJjeHd0S3dvTU9LN0MyVXp1bkRYeVFaMjVNQm1zd3FiUkxsUU41cVBCSS11YzEtemRjUVRPckE3TXpibl9EcVk3V2ZKWm9RNlc5M3M1SGptMWJ0Z1RJbXBMVXM1OU11WDVLQ09ORHpiakFINDFBYUc4OUk4UWZibXNBR2NHRXNkcVlCLW92X2NrMW5SQjRFQ3ZMbjY3MVNmY3JCdzEybU5hVkNpZ1J0SkhXYUhvQ211RDMwd1k0NFRadFFFaEN1dmF3cnNzXzJnbVZqQ3F6TUNjbFBHZ3dPYXY5ZlVnamZDaVplVjVzLnRzCg=='; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - shownAdBanner = true; - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); + 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')) { @@ -141,33 +163,43 @@ send(); }); } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - return new Promise(async function(resolve, reject) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - streamInfo.ChannelName = channelName; - streamInfo.AdUrlCache = []; - streamInfo.IsMidroll = false; - 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]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - } else { - resolve(encodingsM3u8Response); - } - }); - } } 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/strip-ublock-origin.js b/strip/strip-ublock-origin.js deleted file mode 100644 index d5f71b3..0000000 --- a/strip/strip-ublock-origin.js +++ /dev/null @@ -1,1008 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/strip/strip.cfg b/strip/strip.cfg deleted file mode 100644 index 44012de..0000000 --- a/strip/strip.cfg +++ /dev/null @@ -1 +0,0 @@ -OPT_MODE_STRIP_AD_SEGMENTS true \ No newline at end of file diff --git a/strip/strip.user.js b/strip/strip.user.js deleted file mode 100644 index 09316de..0000000 --- a/strip/strip.user.js +++ /dev/null @@ -1,1019 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (strip) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.4 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.user.js -// @description Multiple solutions for blocking Twitch ads (strip) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/video-swap/video-swap-ublock-origin.js b/video-swap/video-swap-ublock-origin.js deleted file mode 100644 index 061fc22..0000000 --- a/video-swap/video-swap-ublock-origin.js +++ /dev/null @@ -1,1008 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = true; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/video-swap/video-swap.cfg b/video-swap/video-swap.cfg deleted file mode 100644 index 07aab42..0000000 --- a/video-swap/video-swap.cfg +++ /dev/null @@ -1 +0,0 @@ -OPT_MODE_VIDEO_SWAP true \ No newline at end of file diff --git a/video-swap/video-swap.user.js b/video-swap/video-swap.user.js deleted file mode 100644 index 934d432..0000000 --- a/video-swap/video-swap.user.js +++ /dev/null @@ -1,1019 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions (video-swap) -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.4 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap/video-swap.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap/video-swap.user.js -// @description Multiple solutions for blocking Twitch ads (video-swap) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = true; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // 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; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - 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()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.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; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - else if (e.data.key == 'UboPauseResumePlayer') { - 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]; - } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } - } - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; - } - 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); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } - 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_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else 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 = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; - streamInfo.IsMidroll = false; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - streamInfo.RealSeqNumber = -1; - streamInfo.BackupSeqNumber = -1; - streamInfo.FakeSeqNumber = 0; - streamInfo.FinalSegUrl = null; - 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.push(lines[i]); - 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; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - 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 - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - 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': 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) { - //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; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - 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(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - 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); - } - } - 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 (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(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 fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } 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 (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(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 (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; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})();