From 9ba777406bb17814eeccd86c27a96e51333cb48b Mon Sep 17 00:00:00 2001 From: pixeltris <6952411+pixeltris@users.noreply.github.com> Date: Sun, 11 Apr 2021 23:46:26 +0100 Subject: [PATCH] Remove deprecated and add proxy server example --- README.md | 14 +- .../dyn-skip-midroll-alt-ublock-origin.js | 811 -------- dyn-skip-midroll-alt/dyn-skip-midroll-alt.cfg | 2 - .../dyn-skip-midroll-alt.user.js | 822 -------- .../dyn-skip-midroll-ublock-origin.js | 811 -------- dyn-skip-midroll/dyn-skip-midroll.cfg | 3 - dyn-skip-midroll/dyn-skip-midroll.user.js | 822 -------- dyn-skip/dyn-skip-ublock-origin.js | 929 ---------- dyn-skip/dyn-skip.cfg | 2 - dyn-skip/dyn-skip.user.js | 940 ---------- .../dyn-video-swap-ublock-origin.js | 811 -------- dyn-video-swap/dyn-video-swap.cfg | 1 - dyn-video-swap/dyn-video-swap.user.js | 822 -------- dyn/dyn-ublock-origin.js | 811 -------- dyn/dyn.cfg | 1 - dyn/dyn.user.js | 822 -------- other-solutions.md | 9 +- proxy-m3u8/proxy-m3u8-ublock-origin.js | 6 +- proxy-m3u8/proxy-m3u8.cfg | 4 +- proxy-m3u8/proxy-m3u8.user.js | 6 +- proxy/README.md | 37 + proxy/extension/README.md | 3 + proxy/extension/background.js | 20 + proxy/extension/manifest.json | 16 + proxy/proxy-server-build.bat | 1 + proxy/proxy-server-info.txt | 31 + proxy/proxy-server.cs | 1651 +++++++++++++++++ proxy/proxy-server.exe | Bin 0 -> 34816 bytes strip-alt/strip-alt-ublock-origin.js | 2 - strip-alt/strip-alt.user.js | 2 - 30 files changed, 1774 insertions(+), 8438 deletions(-) delete mode 100644 dyn-skip-midroll-alt/dyn-skip-midroll-alt-ublock-origin.js delete mode 100644 dyn-skip-midroll-alt/dyn-skip-midroll-alt.cfg delete mode 100644 dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js delete mode 100644 dyn-skip-midroll/dyn-skip-midroll-ublock-origin.js delete mode 100644 dyn-skip-midroll/dyn-skip-midroll.cfg delete mode 100644 dyn-skip-midroll/dyn-skip-midroll.user.js delete mode 100644 dyn-skip/dyn-skip-ublock-origin.js delete mode 100644 dyn-skip/dyn-skip.cfg delete mode 100644 dyn-skip/dyn-skip.user.js delete mode 100644 dyn-video-swap/dyn-video-swap-ublock-origin.js delete mode 100644 dyn-video-swap/dyn-video-swap.cfg delete mode 100644 dyn-video-swap/dyn-video-swap.user.js delete mode 100644 dyn/dyn-ublock-origin.js delete mode 100644 dyn/dyn.cfg delete mode 100644 dyn/dyn.user.js create mode 100644 proxy/README.md create mode 100644 proxy/extension/README.md create mode 100644 proxy/extension/background.js create mode 100644 proxy/extension/manifest.json create mode 100644 proxy/proxy-server-build.bat create mode 100644 proxy/proxy-server-info.txt create mode 100644 proxy/proxy-server.cs create mode 100644 proxy/proxy-server.exe diff --git a/README.md b/README.md index bc75957..a2cba88 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ This repo aims to provide multiple solutions for blocking Twitch ads. M3U8 proxies (or full proxies / VPNs) are currently the most reliable way of avoiding ads. More proxy hosts would be ideal (see [#8](https://github.com/pixeltris/TwitchAdSolutions/issues/8)). -- *There currently aren't any public proxies. `Twitch AdBlock` was taken down on March 31 (see [#22](https://github.com/pixeltris/TwitchAdSolutions/issues/22)).* +- `TTV.LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [code](https://github.com/TTV-LOL/extensions) +- *`Twitch AdBlock` was taken down on March 31 (see [#22](https://github.com/pixeltris/TwitchAdSolutions/issues/22)).* Alternatively: @@ -48,14 +49,6 @@ Alternatively: - 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.** -*The following renamed/deprecated scripts will be removed from the master branch on `1st March 2021`:* - -- `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) @@ -79,11 +72,12 @@ For a more detailed description of the following please refer to [this](other-so - https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707 (UserScript + FrankerFaceZ(optional)) - https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code (UserScript + FrankerFaceZ(optional)) - [Alternate Player for Twitch.tv](https://chrome.google.com/webstore/detail/bhplkbgoehhhddaoolmakpocnenplmhf) - [code](https://robwu.nl/crxviewer/?crx=bhplkbgoehhhddaoolmakpocnenplmhf&qf=player.js) (extension) +- https://github.com/TTV-LOL/extensions (extension) --- - https://github.com/streamlink/streamlink (desktop application) -- https://github.com/nopbreak/TwitchMod (android app) +- [multiChat for Twitch](https://play.google.com/store/apps/details?id=org.mchatty) (android app) - https://twitchls.com/ (external site - purple screen may display every 10-15 mins) - [Use a VPN targeting a region without ads](https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/) diff --git a/dyn-skip-midroll-alt/dyn-skip-midroll-alt-ublock-origin.js b/dyn-skip-midroll-alt/dyn-skip-midroll-alt-ublock-origin.js deleted file mode 100644 index e41e3e3..0000000 --- a/dyn-skip-midroll-alt/dyn-skip-midroll-alt-ublock-origin.js +++ /dev/null @@ -1,811 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - 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_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()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.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; - } - } - 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; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - 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'); - 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.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = 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) { - 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); - } - function gqlRequest(body) { - return fetch('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 (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('/access_token')) { - 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; - } - } 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; - } - } 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/dyn-skip-midroll-alt/dyn-skip-midroll-alt.cfg b/dyn-skip-midroll-alt/dyn-skip-midroll-alt.cfg deleted file mode 100644 index c8dba84..0000000 --- a/dyn-skip-midroll-alt/dyn-skip-midroll-alt.cfg +++ /dev/null @@ -1,2 +0,0 @@ -OPT_MODE_STRIP_AD_SEGMENTS true -OPT_MODE_NOTIFY_ADS_WATCHED true \ No newline at end of file diff --git a/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js b/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js deleted file mode 100644 index e8203d9..0000000 --- a/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js +++ /dev/null @@ -1,822 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js -// @description Multiple solutions for blocking Twitch ads (dyn-skip-midroll-alt) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - 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_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()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.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; - } - } - 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; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - 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'); - 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.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = 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) { - 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); - } - function gqlRequest(body) { - return fetch('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 (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('/access_token')) { - 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; - } - } 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; - } - } 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/dyn-skip-midroll/dyn-skip-midroll-ublock-origin.js b/dyn-skip-midroll/dyn-skip-midroll-ublock-origin.js deleted file mode 100644 index 2972014..0000000 --- a/dyn-skip-midroll/dyn-skip-midroll-ublock-origin.js +++ /dev/null @@ -1,811 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; - 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_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()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.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; - } - } - 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; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - 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'); - 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.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = 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) { - 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); - } - function gqlRequest(body) { - return fetch('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 (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('/access_token')) { - 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; - } - } 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; - } - } 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/dyn-skip-midroll/dyn-skip-midroll.cfg b/dyn-skip-midroll/dyn-skip-midroll.cfg deleted file mode 100644 index a42491d..0000000 --- a/dyn-skip-midroll/dyn-skip-midroll.cfg +++ /dev/null @@ -1,3 +0,0 @@ -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/dyn-skip-midroll/dyn-skip-midroll.user.js b/dyn-skip-midroll/dyn-skip-midroll.user.js deleted file mode 100644 index 134aa4b..0000000 --- a/dyn-skip-midroll/dyn-skip-midroll.user.js +++ /dev/null @@ -1,822 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll/dyn-skip-midroll.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll/dyn-skip-midroll.user.js -// @description Multiple solutions for blocking Twitch ads (dyn-skip-midroll) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; - 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_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()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.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; - } - } - 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; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - 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'); - 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.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = 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) { - 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); - } - function gqlRequest(body) { - return fetch('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 (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('/access_token')) { - 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; - } - } 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; - } - } 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/dyn-skip/dyn-skip-ublock-origin.js b/dyn-skip/dyn-skip-ublock-origin.js deleted file mode 100644 index 5d5187d..0000000 --- a/dyn-skip/dyn-skip-ublock-origin.js +++ /dev/null @@ -1,929 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = true; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// 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 = 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/dyn-skip/dyn-skip.cfg b/dyn-skip/dyn-skip.cfg deleted file mode 100644 index 8d820a1..0000000 --- a/dyn-skip/dyn-skip.cfg +++ /dev/null @@ -1,2 +0,0 @@ -OPT_MODE_MUTE_BLACK true -OPT_MODE_NOTIFY_ADS_WATCHED true \ No newline at end of file diff --git a/dyn-skip/dyn-skip.user.js b/dyn-skip/dyn-skip.user.js deleted file mode 100644 index 1b1ad6f..0000000 --- a/dyn-skip/dyn-skip.user.js +++ /dev/null @@ -1,940 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @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) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = true; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// 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 = 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/dyn-video-swap/dyn-video-swap-ublock-origin.js b/dyn-video-swap/dyn-video-swap-ublock-origin.js deleted file mode 100644 index 724b955..0000000 --- a/dyn-video-swap/dyn-video-swap-ublock-origin.js +++ /dev/null @@ -1,811 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = true; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - 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_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()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.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; - } - } - 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; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - 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'); - 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.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = 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) { - 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); - } - function gqlRequest(body) { - return fetch('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 (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('/access_token')) { - 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; - } - } 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; - } - } 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/dyn-video-swap/dyn-video-swap.cfg b/dyn-video-swap/dyn-video-swap.cfg deleted file mode 100644 index 07aab42..0000000 --- a/dyn-video-swap/dyn-video-swap.cfg +++ /dev/null @@ -1 +0,0 @@ -OPT_MODE_VIDEO_SWAP true \ No newline at end of file diff --git a/dyn-video-swap/dyn-video-swap.user.js b/dyn-video-swap/dyn-video-swap.user.js deleted file mode 100644 index d7b383e..0000000 --- a/dyn-video-swap/dyn-video-swap.user.js +++ /dev/null @@ -1,822 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-video-swap/dyn-video-swap.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-video-swap/dyn-video-swap.user.js -// @description Multiple solutions for blocking Twitch ads (dyn-video-swap) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = true; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - 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_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()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.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; - } - } - 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; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - 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'); - 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.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = 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) { - 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); - } - function gqlRequest(body) { - return fetch('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 (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('/access_token')) { - 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; - } - } 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; - } - } 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/dyn/dyn-ublock-origin.js b/dyn/dyn-ublock-origin.js deleted file mode 100644 index ec18cf9..0000000 --- a/dyn/dyn-ublock-origin.js +++ /dev/null @@ -1,811 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - 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_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()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.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; - } - } - 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; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - 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'); - 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.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = 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) { - 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); - } - function gqlRequest(body) { - return fetch('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 (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('/access_token')) { - 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; - } - } 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; - } - } 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/dyn/dyn.cfg b/dyn/dyn.cfg deleted file mode 100644 index 44012de..0000000 --- a/dyn/dyn.cfg +++ /dev/null @@ -1 +0,0 @@ -OPT_MODE_STRIP_AD_SEGMENTS true \ No newline at end of file diff --git a/dyn/dyn.user.js b/dyn/dyn.user.js deleted file mode 100644 index d7c75d5..0000000 --- a/dyn/dyn.user.js +++ /dev/null @@ -1,822 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn/dyn.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn/dyn.user.js -// @description Multiple solutions for blocking Twitch ads (dyn) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - 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_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()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.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; - } - } - 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; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - 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'); - 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.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = 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) { - 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); - } - function gqlRequest(body) { - return fetch('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 (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('/access_token')) { - 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; - } - } 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; - } - } 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/other-solutions.md b/other-solutions.md index f3352a4..15e2e36 100644 --- a/other-solutions.md +++ b/other-solutions.md @@ -16,16 +16,15 @@ Web browser extensions / scripts: - https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code - Reloads the player (or page) when it detects the ad banner in DOM. - [Alternate Player for Twitch.tv](https://chrome.google.com/webstore/detail/bhplkbgoehhhddaoolmakpocnenplmhf) - [code](https://robwu.nl/crxviewer/?crx=bhplkbgoehhhddaoolmakpocnenplmhf&qf=player.js) - - Notifies Twitch that ads were watched before loading the main stream (prerolls). - - Removes ad segments which cannot be skipped (midrolls). The player will freeze on the last live frame until no more ads. -- [Twitch AdBlock](https://addons.mozilla.org/en-GB/firefox/addon/twitch-adblock/) - [code](https://robwu.nl/crxviewer/?crx=https%3A%2F%2Faddons.mozilla.org%2Fen-GB%2Ffirefox%2Faddon%2Ftwitch-adblock%2F&qf=js/background.js) + - Removes ad segments which cannot be skipped. The player will freeze on the last live frame until no more ads. +- https://github.com/TTV-LOL/extensions - Uses a proxy on the main m3u8 file to get a stream without ads (no prerolls / midrolls). Applications / third party websites: - https://github.com/streamlink/streamlink - Removes ad segments (I assume this will freeze on the last live frame until no more ads). -- https://github.com/nopbreak/TwitchMod - - Unsure how this one blocks ads, but it claims that it can. As this is a mod of the official Twitch app (which isn't obfuscated) they might have insight into better ad blocking methods. +- [multiChat for Twitch](https://play.google.com/store/apps/details?id=org.mchatty) + - Unsure how this one blocks ads, but it claims that it does. - https://twitchls.com/ - Uses the `embed` player. Purple screen may display every 10-15 mins. - https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/ diff --git a/proxy-m3u8/proxy-m3u8-ublock-origin.js b/proxy-m3u8/proxy-m3u8-ublock-origin.js index 8e9257b..1c283f9 100644 --- a/proxy-m3u8/proxy-m3u8-ublock-origin.js +++ b/proxy-m3u8/proxy-m3u8-ublock-origin.js @@ -16,10 +16,10 @@ twitch-videoad.js application/javascript 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 = 'Ax0ZHhYNF0QLXg8PFBsRBgIfR0MVWQUJBVwFVBJBHAYfBQBFS0UcVhhM'; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = true; + scope.OPT_MODE_PROXY_M3U8 = 'http://127.0.0.1/twitch-m3u8/'; + scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = true; + 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'; diff --git a/proxy-m3u8/proxy-m3u8.cfg b/proxy-m3u8/proxy-m3u8.cfg index c04bed7..73a6f8d 100644 --- a/proxy-m3u8/proxy-m3u8.cfg +++ b/proxy-m3u8/proxy-m3u8.cfg @@ -1,3 +1 @@ -OPT_MODE_PROXY_M3U8 'Ax0ZHhYNF0QLXg8PFBsRBgIfR0MVWQUJBVwFVBJBHAYfBQBFS0UcVhhM' -OPT_MODE_PROXY_M3U8_OBFUSCATED true -OPT_MODE_PROXY_M3U8_PARTIAL_URL true \ No newline at end of file +OPT_MODE_PROXY_M3U8 'http://127.0.0.1/twitch-m3u8/' \ No newline at end of file diff --git a/proxy-m3u8/proxy-m3u8.user.js b/proxy-m3u8/proxy-m3u8.user.js index 6bed9a7..9a8e997 100644 --- a/proxy-m3u8/proxy-m3u8.user.js +++ b/proxy-m3u8/proxy-m3u8.user.js @@ -27,10 +27,10 @@ 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 = 'Ax0ZHhYNF0QLXg8PFBsRBgIfR0MVWQUJBVwFVBJBHAYfBQBFS0UcVhhM'; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = true; + scope.OPT_MODE_PROXY_M3U8 = 'http://127.0.0.1/twitch-m3u8/'; + scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = true; + 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'; diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 0000000..2b3c304 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,37 @@ +This gives an overview of using proxies to avoid Twitch ads (without having to proxy all of your traffic ~ just the initial m3u8 per-stream). + +`proxy-server` fetches the m3u8 (hopefully ad-free). `extension` contains a Chrome / Firefox compatible extension for sending the m3u8 request. `proxy-m3u8` (uBlock Origin / userscript) scripts also work as an alternative to the extension. + +## Socks5 + +- Put your socks5 proxy info into `proxy-server-info.txt` and run `proxy-server.exe` (install `Mono` if using Linux/Mac and run via `mono proxy-server.exe`). +- Load the `extension` folder as an unpacked extension in your browser. Alternatively use `proxy-m3u8` `uBlock Origin` / `userscript` scripts ([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)). (TODO: more helpful info). + +## VPN + VMWare + +- Set up `VMWare Workstation` with `Windows` and your desired VPN. +- In your VM `Settings` under the `Hardware` tab select `Network Adapter` and change the `Network connection` to `Bridged`. This is to simplify connecting to `proxy-server` from your host. You can do it without bridged but it requires additional VMWare network configuration. +- Add `proxy-server.exe` to your Windows VM Firewall (or disable your Windows Firewall in the VM) and then run `proxy-server.exe`. +- Modify `extension/background.js` and change the IP to your VM IP (obtained via `ipconfig` inside your VM). If you're using `proxy-m3u8` change the IP there. + +NOTE: See "mixed-content" below. + +## VPS + +- Run `proxy-server.exe` on your VPS which is hosted in an ad-free country (install `Mono` if using Linux and run via `mono proxy-server.exe`). +- Modify the url in `extension/background.js` to point to your VPS and load the `extension` folder as an unpacked extension. If using `proxy-m3u8` scripts find the equivalent urls there and modify them where applicable (you'll likely need to fork to do this). + +NOTE: See "mixed-content" below. + +## Notes + +- Running the `HttpListener` on https has many convoluted steps. On localhost (127.0.0.1) Chrome / Firefox allow mixed content requests so there aren't any issues there. For other IPs (i.e. in the VPS/VPN example) you'll need to enable "mixed-content" (also known as "Insecure content") for twitch.tv or otherwise you'll get CORS errors. +- `proxy-server.exe` needs to be ran as Admin to listen on the desired IP/Port. +- Disable other Twitch ad blocking extensions / scripts as they may interfere. +- You will likely have to try multiple locations until you find something that works. +- If you're having problems use Wireshark (or similar) to make sure the m3u8 is being re-routed. +- To build `proxy-server.cs` yourself run `proxy-server-build.bat`. If you're on Mac/Linux build it with `msbuild` which should come with `Mono` or `.NET Core` (TODO: more helpful info). +- `proxy-server` should be visible over LAN/WAN assuming correct firewall settings, however if you wish to connect to it from another machine you'll need to edit the IP in `extension/background.js`. +- TODO: Provide an authenticated option to allow Twitch Turbo to be used to provide streams to non-Turbo users. + +I would only really recommend using the info + code here as a starting point for building a more robust solution. \ No newline at end of file diff --git a/proxy/extension/README.md b/proxy/extension/README.md new file mode 100644 index 0000000..dd50edb --- /dev/null +++ b/proxy/extension/README.md @@ -0,0 +1,3 @@ +This folder contains an extension which can be used with both Chrome and Firefox to proxy twitch.tv m3u8 stream requests. + +The target url is set to `http://127.0.0.1/`, you'll want to modify that based on your requirements. \ No newline at end of file diff --git a/proxy/extension/background.js b/proxy/extension/background.js new file mode 100644 index 0000000..0c79c72 --- /dev/null +++ b/proxy/extension/background.js @@ -0,0 +1,20 @@ +var isChrome = typeof chrome !== "undefined" && typeof browser === "undefined"; +var extensionAPI = isChrome ? chrome : browser; +function onBeforeRequest(details) { + const match = /hls\/(\w+)\.m3u8/gim.exec(details.url); + if (match !== null && match.length > 1) { + return { + redirectUrl: `http://127.0.0.1/twitch-m3u8/${match[1]}` + }; + } else { + return { + redirectUrl: details.url + }; + } +} +extensionAPI.webRequest.onBeforeRequest.addListener( + onBeforeRequest, { + urls: ["https://usher.ttvnw.net/api/channel/hls/*"] + }, + ["blocking"] +); \ No newline at end of file diff --git a/proxy/extension/manifest.json b/proxy/extension/manifest.json new file mode 100644 index 0000000..be15e47 --- /dev/null +++ b/proxy/extension/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "Twitch M3U8 Proxy", + "description": "Twitch M3U8 Proxy", + "version": "1.0", + "manifest_version": 2, + "background": { + "scripts": ["background.js"], + "persistent": true + }, + "permissions": [ + "webRequest", + "webRequestBlocking", + "*://*.twitch.tv/*", + "*://*.ttvnw.net/*" + ] +} \ No newline at end of file diff --git a/proxy/proxy-server-build.bat b/proxy/proxy-server-build.bat new file mode 100644 index 0000000..c1cf5de --- /dev/null +++ b/proxy/proxy-server-build.bat @@ -0,0 +1 @@ +call %WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe -debug proxy-server.cs \ No newline at end of file diff --git a/proxy/proxy-server-info.txt b/proxy/proxy-server-info.txt new file mode 100644 index 0000000..7452c57 --- /dev/null +++ b/proxy/proxy-server-info.txt @@ -0,0 +1,31 @@ + + + + + +- If you want to use a socks5 proxy: + +The first line MUST be the IP of the socks proxy +The second line MUST be the port of the socks proxy + +- If you need a user / pass: + +The third line MUST be the username +The fourth line MUST be the password + +- If you DON'T need a user / pass then the third / fourth line MUST be empty. +- If you DON'T want to use a socks proxy then leave all four lines empty, or delete this file. + +---------------------------- +Example (with user/pass) +---------------------------- +10.1.1.49 +1080 +myusername +mypassword + +---------------------------- +Example (without user/pass) +---------------------------- +10.2.2.66 +1080 \ No newline at end of file diff --git a/proxy/proxy-server.cs b/proxy/proxy-server.cs new file mode 100644 index 0000000..b3fe981 --- /dev/null +++ b/proxy/proxy-server.cs @@ -0,0 +1,1651 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Runtime.Serialization; +using System.Reflection; +using System.Threading; +using System.Net; +using System.Net.Sockets; +using System.IO; +using System.Diagnostics; +using System.Threading.Tasks; + +class TwitchProxyServer +{ + private static string ClientID = "kimne78kx3ncx6brgo4mv6wki5h1ko"; + private static string UserAgentChrome = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"; + private static string UserAgentFirefox = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0"; + private static string UserAgent = UserAgentChrome; + private static string Platform = "web"; + private static string PlayerBackend = "mediaplayer"; + private static string playerType = "site"; + private static bool UseFastBread = true;// fast_bread (EXT-X-TWITCH-PREFETCH) + private static DeviceIdType deviceIdType = DeviceIdType.Normal; + private Thread thread; + private HttpListener listener; + private string deviceId; + + private static bool SocksProxyFound { get { return !string.IsNullOrEmpty(SocksProxyIP) && SocksProxyPort > 0; } } + private static string SocksProxyIP; + private static int SocksProxyPort; + private static string SocksProxyUser; + private static string SocksProxyPass; + private static MihaZupan.HttpToSocks5Proxy proxy = null; + + enum DeviceIdType + { + Normal, + Empty, + None, + Unique + } + + public static void Run() + { + try + { + string file = "proxy-server-info.txt"; + if (File.Exists(file)) + { + string[] lines = File.ReadAllLines(file); + if (lines.Length > 1) + { + SocksProxyIP = lines[0].Trim(); + if (!string.IsNullOrWhiteSpace(lines[1])) + { + SocksProxyPort = int.Parse(lines[1].Trim()); + } + if (lines.Length > 2) + { + SocksProxyUser = lines[2].Trim(); + } + if (lines.Length > 3) + { + SocksProxyPass = lines[3].Trim(); + } + if (SocksProxyPort > 0) + { + if (!string.IsNullOrWhiteSpace(SocksProxyUser)) + { + proxy = new MihaZupan.HttpToSocks5Proxy(SocksProxyIP, SocksProxyPort, SocksProxyUser, SocksProxyPass); + } + else + { + proxy = new MihaZupan.HttpToSocks5Proxy(SocksProxyIP, SocksProxyPort); + } + } + } + } + } + catch (Exception e) + { + Console.WriteLine(e); + SocksProxyIP = null; + SocksProxyPort = 0; + } + Console.WriteLine("Socks: " + SocksProxyFound); + + ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072; + TwitchProxyServer server = new TwitchProxyServer(); + server.Start(); + System.Diagnostics.Process.GetCurrentProcess().WaitForExit(); + } + + public void Start() + { + Stop(); + + thread = new Thread(delegate() + { + listener = new HttpListener(); + listener.Prefixes.Add("http://*:" + 80 + "/"); + listener.Start(); + while (listener != null) + { + try + { + HttpListenerContext context = listener.GetContext(); + Process(context); + } + catch + { + } + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + + public void Stop() + { + if (listener != null) + { + try + { + listener.Stop(); + } + catch + { + } + listener = null; + } + + if (thread != null) + { + try + { + thread.Abort(); + } + catch + { + } + thread = null; + } + } + + private void Process(HttpListenerContext context) + { + try + { + string url = context.Request.Url.OriginalString; + Console.WriteLine("req " + DateTime.Now.TimeOfDay + " - " + url); + + byte[] responseBuffer = null; + string response = string.Empty; + string contentType = "text/html"; + + if (url.Contains("favicon.ico")) + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.OutputStream.Close(); + return; + } + + if (context.Request.Url.Segments.Length > 2 && + context.Request.Url.Segments[1].Trim('/') == "twitch-m3u8" && + !string.IsNullOrEmpty(context.Request.Url.Segments[2])) + { + string channelName = context.Request.Url.Segments[2].Trim('/'); + response = FetchM3U8(channelName); + //Console.WriteLine(response); + } + + if (responseBuffer == null) + { + responseBuffer = Encoding.UTF8.GetBytes(response.ToString()); + } + context.Response.Headers["Access-Control-Allow-Origin"] = "*"; + context.Response.ContentType = contentType; + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.ContentLength64 = responseBuffer.Length; + context.Response.OutputStream.Write(responseBuffer, 0, responseBuffer.Length); + context.Response.OutputStream.Flush(); + context.Response.StatusCode = (int)HttpStatusCode.OK; + } + catch + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + } + + context.Response.OutputStream.Close(); + } + + private string FetchM3U8(string channel) + { + if (string.IsNullOrEmpty(deviceId) || deviceIdType == DeviceIdType.Unique) + { + UpdateDeviceId(channel); + } + using (WebClient wc = new WebClient()) + { + string response = null, token = null, sig = null; + wc.Proxy = proxy; + wc.Headers.Clear(); + wc.Headers["client-id"] = ClientID; + if (deviceIdType != DeviceIdType.None) + { + wc.Headers["Device-ID"] = deviceIdType == DeviceIdType.Empty ? string.Empty : deviceId; + } + wc.Headers["accept"] = "*/*"; + wc.Headers["accept-encoding"] = "gzip, deflate, br"; + wc.Headers["accept-language"] = "en-us"; + wc.Headers["content-type"] = "text/plain; charset=UTF-8"; + wc.Headers["origin"] = "https://www.twitch.tv"; + wc.Headers["referer"] = "https://www.twitch.tv/"; + wc.Headers["user-agent"] = UserAgent; + response = wc.UploadString("https://gql.twitch.tv/gql", @"{""operationName"":""PlaybackAccessToken_Template"",""query"":""query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \""" + Platform + @"\"", playerBackend: \""" + PlayerBackend + @"\"", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \""" + Platform + @"\"", playerBackend: \""" + PlayerBackend + @"\"", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}"",""variables"":{""isLive"":true,""login"":""" + channel + @""",""isVod"":false,""vodID"":"""",""playerType"":""" + playerType + @"""}}"); + if (!string.IsNullOrEmpty(response)) + { + TwitchAccessToken tokenInfo = JSONSerializer