diff --git a/README.md b/README.md index d25b2e7..cd8b9d1 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,17 @@ Alternatively: *These solutions generally aren't compatible with other Twitch ad blockers. e.g. `ttv-ublock` will break some of these.* -- dyn-skip-midroll-alt ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll-alt/dyn-skip-midroll-alt-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js)) - - Notifies that ads were watched. Whilst Twitch processes this, the stream will play a low resolution stream (`dyn`). -- dyn-skip-midroll ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll/dyn-skip-midroll-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll/dyn-skip-midroll.user.js)) **(not recommended)** - - Notifies that ads were watched, then reloads the player. Repeats this until no ads **(which may never happen)**. -- dyn-video-swap ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-video-swap/dyn-video-swap-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-video-swap/dyn-video-swap.user.js)) - - Ads are replaced by a low resolution stream for the duration of the ad. - - Similar to `dyn`, but skips closer to 20 seconds when switching to the live stream. - - You might see tiny bits of the ad. - - Audio controls wont work whilst the ad is playing. -- dyn ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn/dyn-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn/dyn.user.js)) +**If you want a perfect solution, please use `Twitch AdBlock`.** + +- 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)) + - Similar to `strip`, but notifies Twitch that ads were "watched" (reduces preroll ad frequency). + - The `strip` variant used here should't have looping issues on preroll ads, but may suffer more issues on midroll ads. +- notify-strip-reload + - Adds a reload step to `notify-strip` which may reduce issues transitioning away from the low resolution stream. +- notify-reload ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip-reload/notify-strip-reload-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip-reload/notify-strip-reload.user.js)) + - Notifies that ads were watched, then reloads the player. + - Repeats this until no ads **(which may never happen ~ infinite reload)**. +- strip ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.user.js)) - Ad segments are replaced by low resolution stream segments (on a m3u8 level). - Skips 2-3 seconds when switching to the live stream. - Stuttering and looping of segments often occur (during the ad segments). @@ -39,11 +40,25 @@ Alternatively: - 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. +- video-swap ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap/video-swap-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap/video-swap.user.js)) + - Ads are replaced by a low resolution stream for the duration of the ad. + - Similar to `strip`, but skips closer to 20 seconds when switching to the live stream (TODO: low latency support). + - You might see tiny bits of the ad. + - Audio controls wont work whilst the ad is playing. + - *There are Various UX/UI issues with this script which need to be addressed.* - ~~proxy-m3u8 ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8.user.js))~~ **(proxy currently points to a dead url)** - Uses a proxy server to fetch an ad-free stream. - Currently only the initial m3u8 is proxied, so there shouldn't be any additional latency. - **Assumes the proxy server acts in good faith and maintains a good uptime.** +*A number of changes were made on 27th Jan 2021, including name changes and removal of scripts. Deprecated scripts will be removed from master branch on 1st March 2021. Obtain a permalink if you want to keep using any of the following:* + +- `dyn` renamed to `strip` as this better reflects the functionality (strips ad segments). +- `dyn-skip` removed as it no longer works. +- `dyn-skip-midroll` renamed to `notify-reload` as the original name has lost its meaning. +- `dyn-skip-midroll-alt` renamed to `notify-strip` as the original name has lost its meaning. +- `dyn-video-swap` renamed to `video-swap`. + ## Applying a solution (uBlock Origin) - Navigate to the uBlock Origin Dashboard (the extension options) diff --git a/base/base.user.js b/base/base.user.js index ab3caaf..30db9c6 100644 --- a/base/base.user.js +++ b/base/base.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 +// @version 1.2 // @description Multiple solutions for blocking Twitch ads // @author pixeltris // @match *://*.twitch.tv/* @@ -16,9 +16,15 @@ 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_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; 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; @@ -28,6 +34,7 @@ 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; @@ -86,10 +93,14 @@ var newBlobStr = ` ${processM3U8.toString()} ${getSegmentTimes.toString()} + ${getSegmentUrls.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') { @@ -114,10 +125,14 @@ } else if (e.data.key == 'UboFoundAdSegment') { onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { + } + 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(); + } } function getAdDiv() { var playerRootDiv = document.querySelector('.video-player'); @@ -156,9 +171,49 @@ } return result; } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } async function processM3U8(url, textStr, realFetch) { 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 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), @@ -178,9 +233,9 @@ if (haveAdTags) { LastAdUrl = url; LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { console.log('Stripping ads (instead of skipping ads)'); - } + }*/ var streamInfo = StreamInfosByUrl[url]; if (streamInfo == null) { console.log('Unknown stream url! ' + url); @@ -226,37 +281,68 @@ } } var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; } } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } } } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } } } textStr = lines.join('\n'); @@ -337,9 +423,13 @@ // 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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { @@ -382,7 +472,7 @@ }, }]; } - function getAccessToken(channelName, playerType) { + 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 }}'; @@ -415,10 +505,11 @@ } }; } - return gqlRequest(body); + return gqlRequest(body, realFetch); } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + return fetchFunc('https://gql.twitch.tv/gql', { method: 'POST', body: JSON.stringify(body), headers: { @@ -506,10 +597,12 @@ var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8 = await streamM3u8Response.text(); var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); + if (i >= 0) { + if (res == 1) { + console.log("no ad at req " + i); + } else { + console.log('ad at req ' + i); + } } return res; } else { @@ -550,52 +643,77 @@ } if (OPT_MODE_NOTIFY_ADS_WATCHED) { var tok = null, sig = null; - if (url.includes('/access_token')) { + 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) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } } - } else { - console.log('malformed'); - console.log(responseData); - break; } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else 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_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; + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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; } - } else { - console.log('malformed'); - console.log(responseData); - break; } + resolve(response); } - resolve(response); } else { resolve(response); } diff --git a/dyn-skip/dyn-skip-ublock-origin.js b/dyn-skip/dyn-skip-ublock-origin.js index a3abffc..5d5187d 100644 --- a/dyn-skip/dyn-skip-ublock-origin.js +++ b/dyn-skip/dyn-skip-ublock-origin.js @@ -7,9 +7,15 @@ twitch-videoad.js application/javascript 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_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; 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; @@ -19,6 +25,7 @@ twitch-videoad.js application/javascript 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; @@ -77,10 +84,14 @@ twitch-videoad.js application/javascript var newBlobStr = ` ${processM3U8.toString()} ${getSegmentTimes.toString()} + ${getSegmentUrls.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') { @@ -105,10 +116,14 @@ twitch-videoad.js application/javascript } else if (e.data.key == 'UboFoundAdSegment') { onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { + } + 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(); + } } function getAdDiv() { var playerRootDiv = document.querySelector('.video-player'); @@ -147,9 +162,49 @@ twitch-videoad.js application/javascript } return result; } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } async function processM3U8(url, textStr, realFetch) { 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 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), @@ -169,9 +224,9 @@ twitch-videoad.js application/javascript if (haveAdTags) { LastAdUrl = url; LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { console.log('Stripping ads (instead of skipping ads)'); - } + }*/ var streamInfo = StreamInfosByUrl[url]; if (streamInfo == null) { console.log('Unknown stream url! ' + url); @@ -217,37 +272,68 @@ twitch-videoad.js application/javascript } } var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; } } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } } } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } } } textStr = lines.join('\n'); @@ -328,9 +414,13 @@ twitch-videoad.js application/javascript // 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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { @@ -373,7 +463,7 @@ twitch-videoad.js application/javascript }, }]; } - function getAccessToken(channelName, playerType) { + 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 }}'; @@ -406,10 +496,11 @@ twitch-videoad.js application/javascript } }; } - return gqlRequest(body); + return gqlRequest(body, realFetch); } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + return fetchFunc('https://gql.twitch.tv/gql', { method: 'POST', body: JSON.stringify(body), headers: { @@ -497,10 +588,12 @@ twitch-videoad.js application/javascript var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8 = await streamM3u8Response.text(); var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); + if (i >= 0) { + if (res == 1) { + console.log("no ad at req " + i); + } else { + console.log('ad at req ' + i); + } } return res; } else { @@ -541,52 +634,77 @@ twitch-videoad.js application/javascript } if (OPT_MODE_NOTIFY_ADS_WATCHED) { var tok = null, sig = null; - if (url.includes('/access_token')) { + 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) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } } - } else { - console.log('malformed'); - console.log(responseData); - break; } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else 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_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; + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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; } - } else { - console.log('malformed'); - console.log(responseData); - break; } + resolve(response); } - resolve(response); } else { resolve(response); } diff --git a/dyn-skip/dyn-skip.user.js b/dyn-skip/dyn-skip.user.js index 5e8098a..1b1ad6f 100644 --- a/dyn-skip/dyn-skip.user.js +++ b/dyn-skip/dyn-skip.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 +// @version 1.2 // @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip/dyn-skip.user.js // @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip/dyn-skip.user.js // @description Multiple solutions for blocking Twitch ads (dyn-skip) @@ -18,9 +18,15 @@ 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_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; 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; @@ -30,6 +36,7 @@ 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; @@ -88,10 +95,14 @@ var newBlobStr = ` ${processM3U8.toString()} ${getSegmentTimes.toString()} + ${getSegmentUrls.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') { @@ -116,10 +127,14 @@ } else if (e.data.key == 'UboFoundAdSegment') { onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { + } + 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(); + } } function getAdDiv() { var playerRootDiv = document.querySelector('.video-player'); @@ -158,9 +173,49 @@ } return result; } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } async function processM3U8(url, textStr, realFetch) { 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 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), @@ -180,9 +235,9 @@ if (haveAdTags) { LastAdUrl = url; LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { console.log('Stripping ads (instead of skipping ads)'); - } + }*/ var streamInfo = StreamInfosByUrl[url]; if (streamInfo == null) { console.log('Unknown stream url! ' + url); @@ -228,37 +283,68 @@ } } var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; } } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } } } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } } } textStr = lines.join('\n'); @@ -339,9 +425,13 @@ // 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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { @@ -384,7 +474,7 @@ }, }]; } - function getAccessToken(channelName, playerType) { + 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 }}'; @@ -417,10 +507,11 @@ } }; } - return gqlRequest(body); + return gqlRequest(body, realFetch); } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + return fetchFunc('https://gql.twitch.tv/gql', { method: 'POST', body: JSON.stringify(body), headers: { @@ -508,10 +599,12 @@ var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8 = await streamM3u8Response.text(); var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); + if (i >= 0) { + if (res == 1) { + console.log("no ad at req " + i); + } else { + console.log('ad at req ' + i); + } } return res; } else { @@ -552,52 +645,77 @@ } if (OPT_MODE_NOTIFY_ADS_WATCHED) { var tok = null, sig = null; - if (url.includes('/access_token')) { + 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) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } } - } else { - console.log('malformed'); - console.log(responseData); - break; } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else 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_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; + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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; } - } else { - console.log('malformed'); - console.log(responseData); - break; } + resolve(response); } - resolve(response); } else { resolve(response); } diff --git a/mute-black/mute-black-ublock-origin.js b/mute-black/mute-black-ublock-origin.js index a933329..f1db973 100644 --- a/mute-black/mute-black-ublock-origin.js +++ b/mute-black/mute-black-ublock-origin.js @@ -7,9 +7,15 @@ twitch-videoad.js application/javascript 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_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; 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; @@ -19,6 +25,7 @@ twitch-videoad.js application/javascript 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; @@ -77,10 +84,14 @@ twitch-videoad.js application/javascript var newBlobStr = ` ${processM3U8.toString()} ${getSegmentTimes.toString()} + ${getSegmentUrls.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') { @@ -105,10 +116,14 @@ twitch-videoad.js application/javascript } else if (e.data.key == 'UboFoundAdSegment') { onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { + } + 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(); + } } function getAdDiv() { var playerRootDiv = document.querySelector('.video-player'); @@ -147,9 +162,49 @@ twitch-videoad.js application/javascript } return result; } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } async function processM3U8(url, textStr, realFetch) { 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 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), @@ -169,9 +224,9 @@ twitch-videoad.js application/javascript if (haveAdTags) { LastAdUrl = url; LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { console.log('Stripping ads (instead of skipping ads)'); - } + }*/ var streamInfo = StreamInfosByUrl[url]; if (streamInfo == null) { console.log('Unknown stream url! ' + url); @@ -217,37 +272,68 @@ twitch-videoad.js application/javascript } } var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; } } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } } } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } } } textStr = lines.join('\n'); @@ -328,9 +414,13 @@ twitch-videoad.js application/javascript // 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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { @@ -373,7 +463,7 @@ twitch-videoad.js application/javascript }, }]; } - function getAccessToken(channelName, playerType) { + 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 }}'; @@ -406,10 +496,11 @@ twitch-videoad.js application/javascript } }; } - return gqlRequest(body); + return gqlRequest(body, realFetch); } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + return fetchFunc('https://gql.twitch.tv/gql', { method: 'POST', body: JSON.stringify(body), headers: { @@ -497,10 +588,12 @@ twitch-videoad.js application/javascript var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8 = await streamM3u8Response.text(); var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); + if (i >= 0) { + if (res == 1) { + console.log("no ad at req " + i); + } else { + console.log('ad at req ' + i); + } } return res; } else { @@ -541,52 +634,77 @@ twitch-videoad.js application/javascript } if (OPT_MODE_NOTIFY_ADS_WATCHED) { var tok = null, sig = null; - if (url.includes('/access_token')) { + 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) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } } - } else { - console.log('malformed'); - console.log(responseData); - break; } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else 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_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; + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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; } - } else { - console.log('malformed'); - console.log(responseData); - break; } + resolve(response); } - resolve(response); } else { resolve(response); } diff --git a/mute-black/mute-black.user.js b/mute-black/mute-black.user.js index ce16e39..ac60bd4 100644 --- a/mute-black/mute-black.user.js +++ b/mute-black/mute-black.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 +// @version 1.2 // @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) @@ -18,9 +18,15 @@ 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_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; 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; @@ -30,6 +36,7 @@ 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; @@ -88,10 +95,14 @@ var newBlobStr = ` ${processM3U8.toString()} ${getSegmentTimes.toString()} + ${getSegmentUrls.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') { @@ -116,10 +127,14 @@ } else if (e.data.key == 'UboFoundAdSegment') { onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { + } + 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(); + } } function getAdDiv() { var playerRootDiv = document.querySelector('.video-player'); @@ -158,9 +173,49 @@ } return result; } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } async function processM3U8(url, textStr, realFetch) { 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 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), @@ -180,9 +235,9 @@ if (haveAdTags) { LastAdUrl = url; LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { console.log('Stripping ads (instead of skipping ads)'); - } + }*/ var streamInfo = StreamInfosByUrl[url]; if (streamInfo == null) { console.log('Unknown stream url! ' + url); @@ -228,37 +283,68 @@ } } var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; } } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } } } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } } } textStr = lines.join('\n'); @@ -339,9 +425,13 @@ // 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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { @@ -384,7 +474,7 @@ }, }]; } - function getAccessToken(channelName, playerType) { + 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 }}'; @@ -417,10 +507,11 @@ } }; } - return gqlRequest(body); + return gqlRequest(body, realFetch); } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + return fetchFunc('https://gql.twitch.tv/gql', { method: 'POST', body: JSON.stringify(body), headers: { @@ -508,10 +599,12 @@ var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8 = await streamM3u8Response.text(); var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); + if (i >= 0) { + if (res == 1) { + console.log("no ad at req " + i); + } else { + console.log('ad at req ' + i); + } } return res; } else { @@ -552,52 +645,77 @@ } if (OPT_MODE_NOTIFY_ADS_WATCHED) { var tok = null, sig = null; - if (url.includes('/access_token')) { + 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) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } } - } else { - console.log('malformed'); - console.log(responseData); - break; } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else 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_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; + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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; } - } else { - console.log('malformed'); - console.log(responseData); - break; } + resolve(response); } - resolve(response); } else { resolve(response); } diff --git a/notify-reload/notify-reload-ublock-origin.js b/notify-reload/notify-reload-ublock-origin.js new file mode 100644 index 0000000..13b72bb --- /dev/null +++ b/notify-reload/notify-reload-ublock-origin.js @@ -0,0 +1,929 @@ +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;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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_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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + 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 new file mode 100644 index 0000000..a42491d --- /dev/null +++ b/notify-reload/notify-reload.cfg @@ -0,0 +1,3 @@ +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 diff --git a/notify-reload/notify-reload.user.js b/notify-reload/notify-reload.user.js new file mode 100644 index 0000000..0f1ec8b --- /dev/null +++ b/notify-reload/notify-reload.user.js @@ -0,0 +1,940 @@ +// ==UserScript== +// @name TwitchAdSolutions +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 1.2 +// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js +// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js +// @description Multiple solutions for blocking Twitch ads (notify-reload) +// @author pixeltris +// @match *://*.twitch.tv/* +// @run-at document-start +// @grant none +// ==/UserScript== +(function() { + 'use strict'; + function declareOptions(scope) { + // Options / globals + scope.OPT_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;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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_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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + 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 new file mode 100644 index 0000000..2f5a68d --- /dev/null +++ b/notify-strip-reload/notify-strip-reload-ublock-origin.js @@ -0,0 +1,929 @@ +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_STRIP_AD_SEGMENTS_NEWEST = true; + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + 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 new file mode 100644 index 0000000..cf2b615 --- /dev/null +++ b/notify-strip-reload/notify-strip-reload.cfg @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..b5ee7e5 --- /dev/null +++ b/notify-strip-reload/notify-strip-reload.user.js @@ -0,0 +1,940 @@ +// ==UserScript== +// @name TwitchAdSolutions +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 1.2 +// @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_STRIP_AD_SEGMENTS_NEWEST = true; + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + 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 new file mode 100644 index 0000000..e1eca0f --- /dev/null +++ b/notify-strip/notify-strip-ublock-origin.js @@ -0,0 +1,929 @@ +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_STRIP_AD_SEGMENTS_NEWEST = true; + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + 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 new file mode 100644 index 0000000..7664ba0 --- /dev/null +++ b/notify-strip/notify-strip.cfg @@ -0,0 +1,4 @@ +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 diff --git a/notify-strip/notify-strip.user.js b/notify-strip/notify-strip.user.js new file mode 100644 index 0000000..aa6120a --- /dev/null +++ b/notify-strip/notify-strip.user.js @@ -0,0 +1,940 @@ +// ==UserScript== +// @name TwitchAdSolutions +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 1.2 +// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip.user.js +// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip.user.js +// @description Multiple solutions for blocking Twitch ads (notify-strip) +// @author pixeltris +// @match *://*.twitch.tv/* +// @run-at document-start +// @grant none +// ==/UserScript== +(function() { + 'use strict'; + function declareOptions(scope) { + // Options / globals + scope.OPT_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_STRIP_AD_SEGMENTS_NEWEST = true; + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + 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-ublock-origin.js b/proxy-m3u8/proxy-m3u8-ublock-origin.js index ac6e054..b4d717d 100644 --- a/proxy-m3u8/proxy-m3u8-ublock-origin.js +++ b/proxy-m3u8/proxy-m3u8-ublock-origin.js @@ -7,9 +7,15 @@ twitch-videoad.js application/javascript 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_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; 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; @@ -19,6 +25,7 @@ twitch-videoad.js application/javascript scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = true; 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; @@ -77,10 +84,14 @@ twitch-videoad.js application/javascript var newBlobStr = ` ${processM3U8.toString()} ${getSegmentTimes.toString()} + ${getSegmentUrls.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') { @@ -105,10 +116,14 @@ twitch-videoad.js application/javascript } else if (e.data.key == 'UboFoundAdSegment') { onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { + } + 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(); + } } function getAdDiv() { var playerRootDiv = document.querySelector('.video-player'); @@ -147,9 +162,49 @@ twitch-videoad.js application/javascript } return result; } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } async function processM3U8(url, textStr, realFetch) { 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 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), @@ -169,9 +224,9 @@ twitch-videoad.js application/javascript if (haveAdTags) { LastAdUrl = url; LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { console.log('Stripping ads (instead of skipping ads)'); - } + }*/ var streamInfo = StreamInfosByUrl[url]; if (streamInfo == null) { console.log('Unknown stream url! ' + url); @@ -217,37 +272,68 @@ twitch-videoad.js application/javascript } } var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; } } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } } } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } } } textStr = lines.join('\n'); @@ -328,9 +414,13 @@ twitch-videoad.js application/javascript // 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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { @@ -373,7 +463,7 @@ twitch-videoad.js application/javascript }, }]; } - function getAccessToken(channelName, playerType) { + 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 }}'; @@ -406,10 +496,11 @@ twitch-videoad.js application/javascript } }; } - return gqlRequest(body); + return gqlRequest(body, realFetch); } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + return fetchFunc('https://gql.twitch.tv/gql', { method: 'POST', body: JSON.stringify(body), headers: { @@ -497,10 +588,12 @@ twitch-videoad.js application/javascript var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8 = await streamM3u8Response.text(); var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); + if (i >= 0) { + if (res == 1) { + console.log("no ad at req " + i); + } else { + console.log('ad at req ' + i); + } } return res; } else { @@ -541,52 +634,77 @@ twitch-videoad.js application/javascript } if (OPT_MODE_NOTIFY_ADS_WATCHED) { var tok = null, sig = null; - if (url.includes('/access_token')) { + 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) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } } - } else { - console.log('malformed'); - console.log(responseData); - break; } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else 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_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; + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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; } - } else { - console.log('malformed'); - console.log(responseData); - break; } + resolve(response); } - resolve(response); } else { resolve(response); } diff --git a/proxy-m3u8/proxy-m3u8.user.js b/proxy-m3u8/proxy-m3u8.user.js index a3c8760..4e1abd6 100644 --- a/proxy-m3u8/proxy-m3u8.user.js +++ b/proxy-m3u8/proxy-m3u8.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 +// @version 1.2 // @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) @@ -18,9 +18,15 @@ 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_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; 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; @@ -30,6 +36,7 @@ scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = true; 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; @@ -88,10 +95,14 @@ var newBlobStr = ` ${processM3U8.toString()} ${getSegmentTimes.toString()} + ${getSegmentUrls.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') { @@ -116,10 +127,14 @@ } else if (e.data.key == 'UboFoundAdSegment') { onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { + } + 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(); + } } function getAdDiv() { var playerRootDiv = document.querySelector('.video-player'); @@ -158,9 +173,49 @@ } return result; } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } async function processM3U8(url, textStr, realFetch) { 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 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), @@ -180,9 +235,9 @@ if (haveAdTags) { LastAdUrl = url; LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { console.log('Stripping ads (instead of skipping ads)'); - } + }*/ var streamInfo = StreamInfosByUrl[url]; if (streamInfo == null) { console.log('Unknown stream url! ' + url); @@ -228,37 +283,68 @@ } } var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; } } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } } } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } } } textStr = lines.join('\n'); @@ -339,9 +425,13 @@ // 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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { @@ -384,7 +474,7 @@ }, }]; } - function getAccessToken(channelName, playerType) { + 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 }}'; @@ -417,10 +507,11 @@ } }; } - return gqlRequest(body); + return gqlRequest(body, realFetch); } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + return fetchFunc('https://gql.twitch.tv/gql', { method: 'POST', body: JSON.stringify(body), headers: { @@ -508,10 +599,12 @@ var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8 = await streamM3u8Response.text(); var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); + if (i >= 0) { + if (res == 1) { + console.log("no ad at req " + i); + } else { + console.log('ad at req ' + i); + } } return res; } else { @@ -552,52 +645,77 @@ } if (OPT_MODE_NOTIFY_ADS_WATCHED) { var tok = null, sig = null; - if (url.includes('/access_token')) { + 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) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } } - } else { - console.log('malformed'); - console.log(responseData); - break; } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else 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_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; + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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; } - } else { - console.log('malformed'); - console.log(responseData); - break; } + resolve(response); } - resolve(response); } else { resolve(response); } diff --git a/strip/strip-ublock-origin.js b/strip/strip-ublock-origin.js new file mode 100644 index 0000000..8f3aac0 --- /dev/null +++ b/strip/strip-ublock-origin.js @@ -0,0 +1,929 @@ +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_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + 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 new file mode 100644 index 0000000..44012de --- /dev/null +++ b/strip/strip.cfg @@ -0,0 +1 @@ +OPT_MODE_STRIP_AD_SEGMENTS true \ No newline at end of file diff --git a/strip/strip.user.js b/strip/strip.user.js new file mode 100644 index 0000000..34fab66 --- /dev/null +++ b/strip/strip.user.js @@ -0,0 +1,940 @@ +// ==UserScript== +// @name TwitchAdSolutions +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 1.2 +// @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_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + hookFetch(); + if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { + onContentLoaded(); + } else { + window.addEventListener("DOMContentLoaded", function() { + onContentLoaded(); + }); + } +})(); diff --git a/utils.cs b/utils.cs index 373954a..b3f6c17 100644 --- a/utils.cs +++ b/utils.cs @@ -20,11 +20,13 @@ namespace TwitchAdUtils static string UserAgent = UserAgentChrome; static bool UseOldAccessToken = false; static bool UseAccessTokenTemplate = false; - static bool ShouldNotifyAdWatched = true; + static bool ShouldNotifyAdWatched = false; static bool ShouldNotifyAdWatchedMin = true; static bool ShouldDenyAd = false; + static bool UseFastBread = true;// fast_bread (EXT-X-TWITCH-PREFETCH) static string PlayerTypeNormal = "site";//embed squad_secondary squad_primary static string PlayerTypeMiniNoAd = "picture-by-picture";//"thunderdome"; + static string PlayerTypeEmbed = "embed"; static string Platform = "web"; static string PlayerBackend = "mediaplayer"; static string MainM3U8AdditionalParams = ""; @@ -37,7 +39,8 @@ namespace TwitchAdUtils { Normal, MiniNoAd, - Proxy + Proxy, + Embed } static void Main(string[] args) @@ -64,12 +67,14 @@ namespace TwitchAdUtils Console.Write("Enter channel name: "); string channel = Console.ReadLine().ToLower(); Console.WriteLine("Fetching channel '" + channel + "'"); - RunImpl(RunnerMode.Normal, channel); + //RunImpl(RunnerMode.Normal, channel); + RunImpl(RunnerMode.Embed, channel); //RunImpl(RunnerMode.MiniNoAd, channel); } static void BuildScripts() { + string[] deprecated = { "dyn-skip-midroll-alt", "dyn-skip-midroll", "dyn-video-swap", "dyn" }; string baseScriptName = "base"; string suffixConfg = ".cfg"; string suffixUserscript = ".user.js"; @@ -80,7 +85,7 @@ namespace TwitchAdUtils foreach (string dir in Directory.GetDirectories(Environment.CurrentDirectory)) { DirectoryInfo dirInfo = new DirectoryInfo(dir); - if (dirInfo.Name != baseScriptName) + if (dirInfo.Name != baseScriptName && !deprecated.Contains(dirInfo.Name)) { string cfgFile = Path.Combine(dir, dirInfo.Name + suffixConfg); string userscriptFile = Path.Combine(dir, dirInfo.Name + suffixUserscript); @@ -184,7 +189,16 @@ namespace TwitchAdUtils static string RunImpl(RunnerMode mode, string channel, bool isFetchingM3U8 = false, bool forceSkipAd = false) { - string playerType = mode == RunnerMode.MiniNoAd ? PlayerTypeMiniNoAd : PlayerTypeNormal; + string playerType = PlayerTypeNormal; + switch (mode) + { + case RunnerMode.MiniNoAd: + playerType = PlayerTypeMiniNoAd; + break; + case RunnerMode.Embed: + playerType = PlayerTypeEmbed; + break; + } string cookies = null; string uniqueId = null; int cycle = 0; @@ -277,7 +291,12 @@ namespace TwitchAdUtils } else { - url = "https://usher.ttvnw.net/api/channel/hls/" + channel + ".m3u8?allow_source=true&sig=" + sig + "&token=" + System.Web.HttpUtility.UrlEncode(token) + MainM3U8AdditionalParams; + string additionalParams = ""; + if (UseFastBread) + { + additionalParams += "&fast_bread=true"; + } + url = "https://usher.ttvnw.net/api/channel/hls/" + channel + ".m3u8?allow_source=true" + additionalParams + "&sig=" + sig + "&token=" + System.Web.HttpUtility.UrlEncode(token) + MainM3U8AdditionalParams; } if (isFetchingM3U8) { @@ -296,6 +315,17 @@ namespace TwitchAdUtils if (!string.IsNullOrEmpty(encodingsM3u8)) { string[] lines = encodingsM3u8.Split('\n'); + string info = lines.FirstOrDefault(x => x.Contains("EXT-X-TWITCH-INFO")); + bool isFuture = false; + if (info != null) + { + Dictionary attr = ParseAttributes(info); + string futureStr; + if (attr.TryGetValue("FUTURE", out futureStr)) + { + isFuture = bool.Parse(futureStr); + } + } string streamM3u8Url = lines.FirstOrDefault(x => x.EndsWith(".m3u8")); if (!string.IsNullOrEmpty(streamM3u8Url)) { @@ -307,7 +337,7 @@ namespace TwitchAdUtils { if (streamM3u8.Contains(AdSignifier)) { - Console.WriteLine("has ad " + DateTime.Now.TimeOfDay); + Console.WriteLine("has ad " + DateTime.Now.TimeOfDay + " - " + mode + " - future:" + isFuture); if (ShouldDenyAd) { DeclineAd(uniqueId, streamM3u8, sig, token, true); @@ -316,7 +346,7 @@ namespace TwitchAdUtils } else { - Console.WriteLine("no ad " + DateTime.Now.TimeOfDay); + Console.WriteLine("no ad " + DateTime.Now.TimeOfDay + " - " + mode + " - future:" + isFuture); } if ((streamM3u8.Contains(AdSignifier) || forceSkipAd) && (!UseOldAccessToken && (ShouldNotifyAdWatched || forceSkipAd))) @@ -794,7 +824,8 @@ namespace TwitchAdUtils if (!string.IsNullOrEmpty(mini)) { //string alt = RunImpl(RunnerMode.Proxy, channelName, true); - string alt = RunImpl(RunnerMode.Normal, channelName, true, true); + //string alt = RunImpl(RunnerMode.Normal, channelName, true, true); + string alt = RunImpl(RunnerMode.Embed, channelName, true); state.M3U8Normal = normal; state.M3U8Mini = mini; state.M3U8Alt = alt; diff --git a/video-swap/video-swap-ublock-origin.js b/video-swap/video-swap-ublock-origin.js new file mode 100644 index 0000000..f6e3087 --- /dev/null +++ b/video-swap/video-swap-ublock-origin.js @@ -0,0 +1,929 @@ +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;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + 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 new file mode 100644 index 0000000..07aab42 --- /dev/null +++ b/video-swap/video-swap.cfg @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..7c1d7ff --- /dev/null +++ b/video-swap/video-swap.user.js @@ -0,0 +1,940 @@ +// ==UserScript== +// @name TwitchAdSolutions +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 1.2 +// @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;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest + scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. + scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; + 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_ASYNC_TOK = false; + 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; + scope.LastAdUrl = null; + scope.LastAdTime = 0; + // 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()} + ${getSegmentTimes.toString()} + ${getSegmentUrls.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(); + } + } + 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 getSegmentTimes(lines) { + var result = []; + var lastDate = 0; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { + lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); + } else if (line.startsWith('http')) { + result[lastDate] = line; + } + } + return result; + } + function getSegmentUrls(lines, includePrefetch) { + var result = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.startsWith('http')) { + result.push(line); + } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { + result.push(line.substring(line.indexOf(':') + 1)); + } + } + return result; + } + async function processM3U8(url, textStr, realFetch) { + 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 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; + } + // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads + if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { + postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); + } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { + postMessage({key:'UboHideAdBanner'}); + LastAdTime = 0; + } + if (haveAdTags) { + LastAdUrl = url; + LastAdTime = Date.now(); + /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { + console.log('Stripping ads (instead of skipping ads)'); + }*/ + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url! ' + url); + 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'); + if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segUrls = getSegmentUrls(lines, false); + var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); + for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { + if (streamInfo.SegmentMap[segUrls[i]] == null) { + streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line == null) { + continue; + } + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; + //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + lines[i + 1] = newSegUrl != null ? newSegUrl : ''; + } + /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { + // TODO + lines[i] = ''; + }*/ + } + } else { + var segmentMap = []; + if (backupM3u8 != null) { + var backupLines = backupM3u8.replace('\r', '').split('\n'); + var segTimes = getSegmentTimes(lines); + var backupSegTimes = getSegmentTimes(backupLines); + for (const [segTime, segUrl] of Object.entries(segTimes)) { + //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; + var closestTime = Number.MAX_VALUE; + var matchingBackupTime = Number.MAX_VALUE; + for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { + var timeDiff = Math.abs(segTime - backupSegTime); + if (timeDiff < closestTime) { + closestTime = timeDiff; + matchingBackupTime = backupSegTime; + segmentMap[segUrl] = backupSegUrl; + } + } + if (closestTime != Number.MAX_VALUE) { + backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); + } + } + } + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (line.includes('stitched-ad')) { + lines[i] = ''; + } + if (line.startsWith('#EXTINF:') && !line.includes(',live')) { + lines[i] = line.substring(0, line.indexOf(',')) + ',live'; + var backupSegment = segmentMap[lines[i + 1]]; + lines[i + 1] = backupSegment != null ? backupSegment : '' + } + } + } + textStr = lines.join('\n'); + //console.log(textStr); + } + return textStr; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_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.SegmentMap = []; + streamInfo.NotifyFirstTime = 0; + streamInfo.NotifyObservedNoAds = false; + 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('/access_token')) { + var modifiedUrl = new URL(url); + modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); + arguments[0] = modifiedUrl.href; + } + else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + } + 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) { + 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) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { + var channelName = JSON.parse(init.body).variables.login; + // See if the first response has an ad, if it does then send requests until there is no ad + { + 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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { + console.log('No ad in main request'); + resolve(new Response(responseStr)); + return; + } else { + console.log('Ad in main request'); + } + } + } + if (!channelName) { + resolve(response); + return; + } + // Ads are being served, try skipping a bunch of ads + var resolved = false; + var skipAdTries = 0; + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { + new Promise(async (skipAdResolve, skipAdReject) => { + var noAds = false; + var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); + if (accessTokenResponse.status == 200) { + var responseStr = await accessTokenResponse.text(); + var responseData = JSON.parse(responseStr); + var hasAd = false; + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; + } + var attempt = ++skipAdTries; + console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); + if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { + resolved = true; + resolve(new Response(responseStr)); + return; + } + } else if (!resolved) { + resolved = true; + resolve(response); + return; + } + }).catch(console.log); + } + } else { + for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_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() { + //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 reloadTwitchPlayer() { + // 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; + } + 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() { + pollForAds(); + } + document.head.appendChild(script); + } else { + pollForAds(); + } + } + hookFetch(); + if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { + onContentLoaded(); + } else { + window.addEventListener("DOMContentLoaded", function() { + onContentLoaded(); + }); + } +})();