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.DeSerialize(response); + if (tokenInfo != null && tokenInfo.data != null && tokenInfo.data.streamPlaybackAccessToken != null && + !string.IsNullOrEmpty(tokenInfo.data.streamPlaybackAccessToken.value) && !string.IsNullOrEmpty(tokenInfo.data.streamPlaybackAccessToken.signature)) + { + token = tokenInfo.data.streamPlaybackAccessToken.value; + sig = tokenInfo.data.streamPlaybackAccessToken.signature; + } + } + if (!string.IsNullOrEmpty(token)) + { + string additionalParams = ""; + if (UseFastBread) + { + additionalParams += "&fast_bread=true"; + } + string url = "https://usher.ttvnw.net/api/channel/hls/" + channel + ".m3u8?allow_source=true" + additionalParams + "&sig=" + sig + "&token=" + System.Web.HttpUtility.UrlEncode(token); + wc.Headers.Clear(); + wc.Headers["accept"] = "application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain"; + wc.Headers["host"] = "usher.ttvnw.net"; + wc.Headers["cookie"] = "DNT=1;"; + wc.Headers["DNT"] = "1"; + wc.Headers["user-agent"] = UserAgent; + string encodingsM3u8 = wc.DownloadString(url); + return encodingsM3u8; + } + } + return null; + } + + private void UpdateDeviceId(string channel) + { + using (CookieAwareWebClient wc = new CookieAwareWebClient()) + { + wc.Proxy = null; + wc.Headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"; + wc.DownloadString("https://www.twitch.tv/" + channel); + ProcessCookies(wc.Cookies, out deviceId); + Console.WriteLine("deviceId: " + deviceId); + } + } + + static string ProcessCookies(string str) + { + string uniqueId; + return ProcessCookies(str, out uniqueId); + } + + static string ProcessCookies(string str, out string uniqueId) + { + uniqueId = null; + string result = string.Empty; + string[] cookies = str.Split(','); + foreach (string cookie in cookies) + { + if (cookie.Split(';')[0].Contains('=')) + { + string[] splitted = cookie.Split(';')[0].Split('='); + if (splitted.Length >= 2 && splitted[0] == "unique_id") + { + uniqueId = splitted[1]; + } + result += cookie.Split(';')[0] + ";"; + } + } + return result; + } + + class CookieAwareWebClient : WebClient + { + public CookieContainer CookieContainer { get; set; } + public Uri Uri { get; set; } + + public string Cookies { get; private set; } + + public CookieAwareWebClient() + : this(new CookieContainer()) + { + } + + public CookieAwareWebClient(CookieContainer cookies) + { + this.CookieContainer = new CookieContainer(); + } + + protected override WebResponse GetWebResponse(WebRequest request) + { + WebResponse response = base.GetWebResponse(request); + string setCookieHeader = response.Headers.Get("Set-Cookie"); + Cookies = setCookieHeader; + return response; + } + } + + [DataContract] + public class TwitchAccessTokenOld + { + [DataMember] + public string token { get; set; } + [DataMember] + public string sig { get; set; } + } + + [DataContract] + public class TwitchAccessToken + { + [DataMember] + public TwitchAccessToken_data data { get; set; } + } + + [DataContract] + public class TwitchAccessToken_data + { + [DataMember] + public TwitchAccessToken_streamPlaybackAccessToken streamPlaybackAccessToken { get; set; } + } + + [DataContract] + public class TwitchAccessToken_streamPlaybackAccessToken + { + [DataMember] + public string value { get; set; } + [DataMember] + public string signature { get; set; } + } + + static class JSONSerializer where TType : class + { + public static TType DeSerialize(string json) + { + return TinyJson.JSONParser.FromJson(json); + } + } + + static void Main() + { + TwitchProxyServer.Run(); + } +} + +namespace TinyJson +{ + // Really simple JSON parser in ~300 lines + // - Attempts to parse JSON files with minimal GC allocation + // - Nice and simple "[1,2,3]".FromJson>() API + // - Classes and structs can be parsed too! + // class Foo { public int Value; } + // "{\"Value\":10}".FromJson() + // - Can parse JSON without type information into Dictionary and List e.g. + // "[1,2,3]".FromJson().GetType() == typeof(List) + // "{\"Value\":10}".FromJson().GetType() == typeof(Dictionary) + // - No JIT Emit support to support AOT compilation on iOS + // - Attempts are made to NOT throw an exception if the JSON is corrupted or invalid: returns null instead. + // - Only public fields and property setters on classes/structs will be written to + // + // Limitations: + // - No JIT Emit support to parse structures quickly + // - Limited to parsing <2GB JSON files (due to int.MaxValue) + // - Parsing of abstract classes or interfaces is NOT supported and will throw an exception. + public static class JSONParser + { + [ThreadStatic] static Stack> splitArrayPool; + [ThreadStatic] static StringBuilder stringBuilder; + [ThreadStatic] static Dictionary> fieldInfoCache; + [ThreadStatic] static Dictionary> propertyInfoCache; + + public static T FromJson(this string json) + { + // Initialize, if needed, the ThreadStatic variables + if (propertyInfoCache == null) propertyInfoCache = new Dictionary>(); + if (fieldInfoCache == null) fieldInfoCache = new Dictionary>(); + if (stringBuilder == null) stringBuilder = new StringBuilder(); + if (splitArrayPool == null) splitArrayPool = new Stack>(); + + //Remove all whitespace not within strings to make parsing simpler + stringBuilder.Length = 0; + for (int i = 0; i < json.Length; i++) + { + char c = json[i]; + if (c == '"') + { + i = AppendUntilStringEnd(true, i, json); + continue; + } + if (char.IsWhiteSpace(c)) + continue; + + stringBuilder.Append(c); + } + + //Parse the thing! + return (T)ParseValue(typeof(T), stringBuilder.ToString()); + } + + static int AppendUntilStringEnd(bool appendEscapeCharacter, int startIdx, string json) + { + stringBuilder.Append(json[startIdx]); + for (int i = startIdx + 1; i < json.Length; i++) + { + if (json[i] == '\\') + { + if (appendEscapeCharacter) + stringBuilder.Append(json[i]); + stringBuilder.Append(json[i + 1]); + i++;//Skip next character as it is escaped + } + else if (json[i] == '"') + { + stringBuilder.Append(json[i]); + return i; + } + else + stringBuilder.Append(json[i]); + } + return json.Length - 1; + } + + //Splits { :, : } and [ , ] into a list of strings + static List Split(string json) + { + List splitArray = splitArrayPool.Count > 0 ? splitArrayPool.Pop() : new List(); + splitArray.Clear(); + if (json.Length == 2) + return splitArray; + int parseDepth = 0; + stringBuilder.Length = 0; + for (int i = 1; i < json.Length - 1; i++) + { + switch (json[i]) + { + case '[': + case '{': + parseDepth++; + break; + case ']': + case '}': + parseDepth--; + break; + case '"': + i = AppendUntilStringEnd(true, i, json); + continue; + case ',': + case ':': + if (parseDepth == 0) + { + splitArray.Add(stringBuilder.ToString()); + stringBuilder.Length = 0; + continue; + } + break; + } + + stringBuilder.Append(json[i]); + } + + splitArray.Add(stringBuilder.ToString()); + + return splitArray; + } + + internal static object ParseValue(Type type, string json) + { + if (type == typeof(string)) + { + if (json.Length <= 2) + return string.Empty; + StringBuilder parseStringBuilder = new StringBuilder(json.Length); + for (int i = 1; i < json.Length - 1; ++i) + { + if (json[i] == '\\' && i + 1 < json.Length - 1) + { + int j = "\"\\nrtbf/".IndexOf(json[i + 1]); + if (j >= 0) + { + parseStringBuilder.Append("\"\\\n\r\t\b\f/"[j]); + ++i; + continue; + } + if (json[i + 1] == 'u' && i + 5 < json.Length - 1) + { + UInt32 c = 0; + if (UInt32.TryParse(json.Substring(i + 2, 4), System.Globalization.NumberStyles.AllowHexSpecifier, null, out c)) + { + parseStringBuilder.Append((char)c); + i += 5; + continue; + } + } + } + parseStringBuilder.Append(json[i]); + } + return parseStringBuilder.ToString(); + } + if (type.IsPrimitive) + { + var result = Convert.ChangeType(json, type, System.Globalization.CultureInfo.InvariantCulture); + return result; + } + if (type == typeof(decimal)) + { + decimal result; + decimal.TryParse(json, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out result); + return result; + } + if (json == "null") + { + return null; + } + if (type.IsEnum) + { + if (json[0] == '"') + json = json.Substring(1, json.Length - 2); + try + { + return Enum.Parse(type, json, false); + } + catch + { + return 0; + } + } + if (type.IsArray) + { + Type arrayType = type.GetElementType(); + if (json[0] != '[' || json[json.Length - 1] != ']') + return null; + + List elems = Split(json); + Array newArray = Array.CreateInstance(arrayType, elems.Count); + for (int i = 0; i < elems.Count; i++) + newArray.SetValue(ParseValue(arrayType, elems[i]), i); + splitArrayPool.Push(elems); + return newArray; + } + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) + { + Type listType = type.GetGenericArguments()[0]; + if (json[0] != '[' || json[json.Length - 1] != ']') + return null; + + List elems = Split(json); + var list = (IList)type.GetConstructor(new Type[] { typeof(int) }).Invoke(new object[] { elems.Count }); + for (int i = 0; i < elems.Count; i++) + list.Add(ParseValue(listType, elems[i])); + splitArrayPool.Push(elems); + return list; + } + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + { + Type keyType, valueType; + { + Type[] args = type.GetGenericArguments(); + keyType = args[0]; + valueType = args[1]; + } + + //Refuse to parse dictionary keys that aren't of type string + if (keyType != typeof(string)) + return null; + //Must be a valid dictionary element + if (json[0] != '{' || json[json.Length - 1] != '}') + return null; + //The list is split into key/value pairs only, this means the split must be divisible by 2 to be valid JSON + List elems = Split(json); + if (elems.Count % 2 != 0) + return null; + + var dictionary = (IDictionary)type.GetConstructor(new Type[] { typeof(int) }).Invoke(new object[] { elems.Count / 2 }); + for (int i = 0; i < elems.Count; i += 2) + { + if (elems[i].Length <= 2) + continue; + string keyValue = elems[i].Substring(1, elems[i].Length - 2); + object val = ParseValue(valueType, elems[i + 1]); + dictionary[keyValue] = val; + } + return dictionary; + } + if (type == typeof(object)) + { + return ParseAnonymousValue(json); + } + if (json[0] == '{' && json[json.Length - 1] == '}') + { + return ParseObject(type, json); + } + + return null; + } + + static object ParseAnonymousValue(string json) + { + if (json.Length == 0) + return null; + if (json[0] == '{' && json[json.Length - 1] == '}') + { + List elems = Split(json); + if (elems.Count % 2 != 0) + return null; + var dict = new Dictionary(elems.Count / 2); + for (int i = 0; i < elems.Count; i += 2) + dict[elems[i].Substring(1, elems[i].Length - 2)] = ParseAnonymousValue(elems[i + 1]); + return dict; + } + if (json[0] == '[' && json[json.Length - 1] == ']') + { + List items = Split(json); + var finalList = new List(items.Count); + for (int i = 0; i < items.Count; i++) + finalList.Add(ParseAnonymousValue(items[i])); + return finalList; + } + if (json[0] == '"' && json[json.Length - 1] == '"') + { + string str = json.Substring(1, json.Length - 2); + return str.Replace("\\", string.Empty); + } + if (char.IsDigit(json[0]) || json[0] == '-') + { + if (json.Contains(".")) + { + double result; + double.TryParse(json, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out result); + return result; + } + else + { + int result; + int.TryParse(json, out result); + return result; + } + } + if (json == "true") + return true; + if (json == "false") + return false; + // handles json == "null" as well as invalid JSON + return null; + } + + static Dictionary CreateMemberNameDictionary(T[] members) where T : MemberInfo + { + Dictionary nameToMember = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < members.Length; i++) + { + T member = members[i]; + if (member.IsDefined(typeof(IgnoreDataMemberAttribute), true)) + continue; + + string name = member.Name; + if (member.IsDefined(typeof(DataMemberAttribute), true)) + { + DataMemberAttribute dataMemberAttribute = (DataMemberAttribute)Attribute.GetCustomAttribute(member, typeof(DataMemberAttribute), true); + if (!string.IsNullOrEmpty(dataMemberAttribute.Name)) + name = dataMemberAttribute.Name; + } + + nameToMember.Add(name, member); + } + + return nameToMember; + } + + static object ParseObject(Type type, string json) + { + object instance = FormatterServices.GetUninitializedObject(type); + + //The list is split into key/value pairs only, this means the split must be divisible by 2 to be valid JSON + List elems = Split(json); + if (elems.Count % 2 != 0) + return instance; + + Dictionary nameToField; + Dictionary nameToProperty; + if (!fieldInfoCache.TryGetValue(type, out nameToField)) + { + nameToField = CreateMemberNameDictionary(type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)); + fieldInfoCache.Add(type, nameToField); + } + if (!propertyInfoCache.TryGetValue(type, out nameToProperty)) + { + nameToProperty = CreateMemberNameDictionary(type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)); + propertyInfoCache.Add(type, nameToProperty); + } + + for (int i = 0; i < elems.Count; i += 2) + { + if (elems[i].Length <= 2) + continue; + string key = elems[i].Substring(1, elems[i].Length - 2); + string value = elems[i + 1]; + + FieldInfo fieldInfo; + PropertyInfo propertyInfo; + if (nameToField.TryGetValue(key, out fieldInfo)) + fieldInfo.SetValue(instance, ParseValue(fieldInfo.FieldType, value)); + else if (nameToProperty.TryGetValue(key, out propertyInfo)) + propertyInfo.SetValue(instance, ParseValue(propertyInfo.PropertyType, value), null); + } + + return instance; + } + } +} + +// https://github.com/MihaZupan/HttpToSocks5Proxy/tree/f595aa19b000025ee53081b8607db29c26740afa +namespace MihaZupan +{ + enum AddressType + { + IPv4 = 1, + DomainName = 3, + IPv6 = 4 + } + enum Authentication + { + NoAuthentication = 0, + GSSAPI = 1, + UsernamePassword = 2 + } + enum Command + { + Connect = 1, + Bind = 2, + UdpAssociate = 3 + } + enum SocketConnectionResult + { + OK = 0, + GeneralSocksServerFailure = 1, + ConnectionNotAllowedByRuleset = 2, + NetworkUnreachable = 3, + HostUnreachable = 4, + ConnectionRefused = 5, + TTLExpired = 6, + CommandNotSupported = 7, + AddressTypeNotSupported = 8, + + // Library specific + InvalidRequest = int.MinValue, + UnknownError, + AuthenticationError, + ConnectionReset, + ConnectionError, + InvalidProxyResponse + } + public interface IDnsResolver + { + IPAddress TryResolve(string hostname); + } + internal class DefaultDnsResolver : IDnsResolver + { + public IPAddress TryResolve(string hostname) + { + IPAddress result = null; + if (IPAddress.TryParse(hostname, out result)) + { + return result; + } + + try + { + result = System.Net.Dns.GetHostAddresses(hostname).FirstOrDefault(); + } + catch (SocketException) + { + // ignore + } + + return result; + } + } + internal static class ErrorResponseBuilder + { + public static string Build(SocketConnectionResult error, string httpVersion) + { + switch (error) + { + case SocketConnectionResult.AuthenticationError: + return httpVersion + "401 Unauthorized\r\n\r\n"; + + case SocketConnectionResult.HostUnreachable: + case SocketConnectionResult.ConnectionRefused: + case SocketConnectionResult.ConnectionReset: + return string.Concat(httpVersion, "502 ", error.ToString(), "\r\n\r\n"); + + default: + return string.Concat(httpVersion, "500 Internal Server Error\r\nX-Proxy-Error-Type: ", error.ToString(), "\r\n\r\n"); + } + } + } + internal static class Helpers + { + public static SocketConnectionResult ToConnectionResult(this SocketException exception) + { + if (exception.SocketErrorCode == SocketError.ConnectionRefused) + return SocketConnectionResult.ConnectionRefused; + + if (exception.SocketErrorCode == SocketError.HostUnreachable) + return SocketConnectionResult.HostUnreachable; + + return SocketConnectionResult.ConnectionError; + } + + public static bool ContainsDoubleNewLine(this byte[] buffer, int offset, int limit, out int endOfHeader) + { + const byte R = (byte)'\r'; + const byte N = (byte)'\n'; + + bool foundOne = false; + for (endOfHeader = offset; endOfHeader < limit; endOfHeader++) + { + if (buffer[endOfHeader] == N) + { + if (foundOne) + { + endOfHeader++; + return true; + } + foundOne = true; + } + else if (buffer[endOfHeader] != R) + { + foundOne = false; + } + } + + return false; + } + + private static readonly string[] HopByHopHeaders = new string[] + { + // ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers + "CONNECTION", "KEEP-ALIVE", "PROXY-AUTHENTICATE", "PROXY-AUTHORIZATION", "TE", "TRAILER", "TRANSFER-ENCODING", "UPGRADE" + }; + public static bool IsHopByHopHeader(this string header) + { + return HopByHopHeaders.Contains(header, StringComparer.OrdinalIgnoreCase); + } + + public static AddressType GetAddressType(string hostname) + { + IPAddress hostIP; + if (IPAddress.TryParse(hostname, out hostIP)) + { + if (hostIP.AddressFamily == AddressFamily.InterNetwork) + { + return AddressType.IPv4; + } + else + { + return AddressType.IPv6; + } + } + return AddressType.DomainName; + } + public static void TryDispose(this Socket socket) + { + if (socket.Connected) + { + try + { + socket.Shutdown(SocketShutdown.Send); + } + catch { } + } + try + { + socket.Close(); + } + catch { } + } + public static void TryDispose(this SocketAsyncEventArgs saea) + { + try + { + saea.UserToken = null; + saea.AcceptSocket = null; + + saea.Dispose(); + } + catch { } + } + } + public class ProxyInfo + { + /// + /// Proxy server address + /// + public readonly string Hostname; + /// + /// Proxy server port + /// + public readonly int Port; + + /// + /// Indicates whether credentials were provided for this + /// + public readonly bool Authenticate = false; + internal readonly byte[] AuthenticationMessage; + + public ProxyInfo(string hostname, int port) + { + if (string.IsNullOrEmpty(hostname)) throw new ArgumentNullException("hostname"); + if (port < 0 || port > 65535) throw new ArgumentOutOfRangeException("port"); + + Hostname = hostname; + Port = port; + } + public ProxyInfo(string hostname, int port, string username, string password) + : this(hostname, port) + { + if (string.IsNullOrEmpty(username)) throw new ArgumentNullException("username"); + if (string.IsNullOrEmpty(password)) throw new ArgumentNullException("password"); + + Authenticate = true; + AuthenticationMessage = Socks5.BuildAuthenticationMessage(username, password); + } + } + internal class SocketRelay + { + private SocketAsyncEventArgs RecSAEA, SendSAEA; + private Socket Source, Target; + private byte[] Buffer; + + public bool Receiving; + private int Received; + private int SendingOffset; + + public SocketRelay Other; + private bool Disposed = false; + private bool ShouldDispose = false; + + private SocketRelay(Socket source, Socket target) + { + Source = source; + Target = target; + Buffer = new byte[81920]; + RecSAEA = new SocketAsyncEventArgs() + { + UserToken = this + }; + SendSAEA = new SocketAsyncEventArgs() + { + UserToken = this + }; + RecSAEA.SetBuffer(Buffer, 0, Buffer.Length); + SendSAEA.SetBuffer(Buffer, 0, Buffer.Length); + RecSAEA.Completed += OnAsyncOperationCompleted; + SendSAEA.Completed += OnAsyncOperationCompleted; + Receiving = true; + } + + private void OnCleanup() + { + if (Disposed) + return; + + Disposed = ShouldDispose = true; + + Other.ShouldDispose = true; + Other = null; + + Source.TryDispose(); + Target.TryDispose(); + RecSAEA.TryDispose(); + SendSAEA.TryDispose(); + + Source = Target = null; + RecSAEA = SendSAEA = null; + Buffer = null; + } + + private void Process() + { + try + { + while (true) + { + if (ShouldDispose) + { + OnCleanup(); + return; + } + + if (Receiving) + { + Receiving = false; + SendingOffset = -1; + + if (Source.ReceiveAsync(RecSAEA)) + return; + } + else + { + if (SendingOffset == -1) + { + Received = RecSAEA.BytesTransferred; + SendingOffset = 0; + + if (Received == 0) + { + ShouldDispose = true; + continue; + } + } + else + { + SendingOffset += SendSAEA.BytesTransferred; + } + + if (SendingOffset != Received) + { + SendSAEA.SetBuffer(Buffer, SendingOffset, Received - SendingOffset); + + if (Target.SendAsync(SendSAEA)) + return; + } + else Receiving = true; + } + } + } + catch + { + OnCleanup(); + } + } + + private static void OnAsyncOperationCompleted(object _, SocketAsyncEventArgs saea) + { + var relay = saea.UserToken as SocketRelay; + relay.Process(); + } + + public static void RelayBiDirectionally(Socket s1, Socket s2) + { + var relayOne = new SocketRelay(s1, s2); + var relayTwo = new SocketRelay(s2, s1); + + relayOne.Other = relayTwo; + relayTwo.Other = relayOne; + + Task.Run(new Action(relayOne.Process)); + Task.Run(new Action(relayTwo.Process)); + } + } + internal static class Socks5 + { + public static SocketConnectionResult TryCreateTunnel(Socket socks5Socket, string destAddress, int destPort, ProxyInfo proxy, IDnsResolver dnsResolver = null) + { + try + { + // SEND HELLO + socks5Socket.Send(BuildHelloMessage(proxy.Authenticate)); + + // RECEIVE HELLO RESPONSE - HANDLE AUTHENTICATION + byte[] buffer = new byte[255]; + if (socks5Socket.Receive(buffer) != 2) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[0] != SocksVersion) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[1] == (byte)Authentication.UsernamePassword) + { + if (!proxy.Authenticate) + { + // Proxy server is requesting UserPass auth even tho we did not allow it + return SocketConnectionResult.InvalidProxyResponse; + } + else + { + // We have to try and authenticate using the Username and Password + // https://tools.ietf.org/html/rfc1929 + socks5Socket.Send(proxy.AuthenticationMessage); + if (socks5Socket.Receive(buffer) != 2) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[0] != SubnegotiationVersion) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[1] != 0) + return SocketConnectionResult.AuthenticationError; + } + } + else if (buffer[1] != (byte)Authentication.NoAuthentication) + return SocketConnectionResult.AuthenticationError; + + if (dnsResolver != null && Helpers.GetAddressType(destAddress) == AddressType.DomainName) + { + var ipAddress = dnsResolver.TryResolve(destAddress); + if (ipAddress == null) + { + return SocketConnectionResult.HostUnreachable; + } + + destAddress = ipAddress.ToString(); + } + + // SEND REQUEST + socks5Socket.Send(BuildRequestMessage(Command.Connect, Helpers.GetAddressType(destAddress), destAddress, destPort)); + + // RECEIVE RESPONSE + int received = socks5Socket.Receive(buffer); + if (received < 8) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[0] != SocksVersion) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[1] > 8) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[1] != 0) + return (SocketConnectionResult)buffer[1]; + if (buffer[2] != 0) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[3] != 1 && buffer[3] != 3 && buffer[3] != 4) + return SocketConnectionResult.InvalidProxyResponse; + + AddressType boundAddress = (AddressType)buffer[3]; + if (boundAddress == AddressType.IPv4) + { + if (received != 10) + return SocketConnectionResult.InvalidProxyResponse; + } + else if (boundAddress == AddressType.IPv6) + { + if (received != 22) + return SocketConnectionResult.InvalidProxyResponse; + } + else + { + int domainLength = buffer[4]; + if (received != 7 + domainLength) + return SocketConnectionResult.InvalidProxyResponse; + } + + return SocketConnectionResult.OK; + } + catch (SocketException ex) + { + return ex.ToConnectionResult(); + } + catch + { + return SocketConnectionResult.UnknownError; + } + } + + private const byte SubnegotiationVersion = 0x01; + private const byte SocksVersion = 0x05; + + private static byte[] BuildHelloMessage(bool doUsernamePasswordAuth) + { + byte[] hello = new byte[doUsernamePasswordAuth ? 4 : 3]; + hello[0] = SocksVersion; + hello[1] = (byte)(doUsernamePasswordAuth ? 2 : 1); + hello[2] = (byte)Authentication.NoAuthentication; + if (doUsernamePasswordAuth) + { + hello[3] = (byte)Authentication.UsernamePassword; + } + return hello; + } + private static byte[] BuildRequestMessage(Command command, AddressType addressType, string address, int port) + { + int addressLength; + byte[] addressBytes; + switch (addressType) + { + case AddressType.IPv4: + case AddressType.IPv6: + addressBytes = IPAddress.Parse(address).GetAddressBytes(); + addressLength = addressBytes.Length; + break; + + case AddressType.DomainName: + byte[] domainBytes = Encoding.UTF8.GetBytes(address); + addressLength = 1 + domainBytes.Length; + addressBytes = new byte[addressLength]; + addressBytes[0] = (byte)domainBytes.Length; + Array.Copy(domainBytes, 0, addressBytes, 1, domainBytes.Length); + break; + + default: + throw new ArgumentException("Unknown address type"); + } + + byte[] request = new byte[6 + addressLength]; + request[0] = SocksVersion; + request[1] = (byte)command; + //request[2] = 0x00; + request[3] = (byte)addressType; + Array.Copy(addressBytes, 0, request, 4, addressLength); + request[request.Length - 2] = (byte)(port / 256); + request[request.Length - 1] = (byte)(port % 256); + return request; + } + public static byte[] BuildAuthenticationMessage(string username, string password) + { + byte[] usernameBytes = Encoding.UTF8.GetBytes(username); + if (usernameBytes.Length > 255) throw new ArgumentOutOfRangeException("Username is too long"); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + if (passwordBytes.Length > 255) throw new ArgumentOutOfRangeException("Password is too long"); + + byte[] authMessage = new byte[3 + usernameBytes.Length + passwordBytes.Length]; + authMessage[0] = SubnegotiationVersion; + authMessage[1] = (byte)usernameBytes.Length; + Array.Copy(usernameBytes, 0, authMessage, 2, usernameBytes.Length); + authMessage[2 + usernameBytes.Length] = (byte)passwordBytes.Length; + Array.Copy(passwordBytes, 0, authMessage, 3 + usernameBytes.Length, passwordBytes.Length); + return authMessage; + } + } + /// + /// Presents itself as an HTTP(s) proxy, but connects to a SOCKS5 proxy behind-the-scenes + /// + public class HttpToSocks5Proxy : IWebProxy + { + /// + /// Ignored by this implementation + /// + public ICredentials Credentials { get; set; } + /// + /// Returned is constant for a single instance + /// Address is a local address, the port is + /// + /// Ignored by this implementation + /// + public Uri GetProxy(Uri destination) { return ProxyUri; } + /// + /// Always returns false + /// + /// Ignored by this implementation + /// + public bool IsBypassed(Uri host) { return false; } + /// + /// The port on which the internal server is listening + /// + public int InternalServerPort { get; private set; } + + /// + /// A custom domain name resolver + /// + public IDnsResolver DnsResolver + { + set + { + if (value != null) + { + dnsResolver = value; + } + else + { + throw new ArgumentNullException("value"); + } + } + } + private IDnsResolver dnsResolver; + + private readonly Uri ProxyUri; + private readonly Socket InternalServerSocket; + + private readonly ProxyInfo[] ProxyList; + + /// + /// Controls whether domain names are resolved locally or passed to the proxy server for evaluation + /// False by default + /// + public bool ResolveHostnamesLocally = false; + + #region Constructors + /// + /// Create an Http(s) to Socks5 proxy using no authentication + /// + /// IP address or hostname of the Socks5 proxy server + /// Port of the Socks5 proxy server + /// The port to listen on with the internal server, 0 means it is selected automatically + public HttpToSocks5Proxy(string socks5Hostname, int socks5Port, int internalServerPort = 0) + : this(new[] { new ProxyInfo(socks5Hostname, socks5Port) }, internalServerPort) { } + + /// + /// Create an Http(s) to Socks5 proxy using username and password authentication + /// Note that many public Socks5 servers don't actually require a username and password + /// + /// IP address or hostname of the Socks5 proxy server + /// Port of the Socks5 proxy server + /// Username for the Socks5 server authentication + /// Password for the Socks5 server authentication + /// The port to listen on with the internal server, 0 means it is selected automatically + public HttpToSocks5Proxy(string socks5Hostname, int socks5Port, string username, string password, int internalServerPort = 0) + : this(new[] { new ProxyInfo(socks5Hostname, socks5Port, username, password) }, internalServerPort) { } + + /// + /// Create an Http(s) to Socks5 proxy using one or multiple chained proxies + /// + /// List of proxies to route through + /// The port to listen on with the internal server, 0 means it is selected automatically + public HttpToSocks5Proxy(ProxyInfo[] proxyList, int internalServerPort = 0) + { + if (internalServerPort < 0 || internalServerPort > 65535) throw new ArgumentOutOfRangeException("internalServerPort"); + if (proxyList == null) throw new ArgumentNullException("proxyList"); + if (proxyList.Length == 0) throw new ArgumentException("proxyList is empty", "proxyList"); + if (proxyList.Any(p => p == null)) throw new ArgumentNullException("proxyList", "Proxy in proxyList is null"); + + ProxyList = proxyList; + InternalServerPort = internalServerPort; + dnsResolver = new DefaultDnsResolver(); + + InternalServerSocket = CreateSocket(); + InternalServerSocket.Bind(new IPEndPoint(IPAddress.Any, InternalServerPort)); + + if (InternalServerPort == 0) + InternalServerPort = ((IPEndPoint)(InternalServerSocket.LocalEndPoint)).Port; + + ProxyUri = new Uri("http://127.0.0.1:" + InternalServerPort); + InternalServerSocket.Listen(8); + InternalServerSocket.BeginAccept(OnAcceptCallback, null); + } + #endregion + + private void OnAcceptCallback(IAsyncResult AR) + { + if (Stopped) return; + + Socket clientSocket = null; + try + { + clientSocket = InternalServerSocket.EndAccept(AR); + } + catch { } + + try + { + InternalServerSocket.BeginAccept(OnAcceptCallback, null); + } + catch { StopInternalServer(); } + + if (clientSocket != null) + HandleRequest(clientSocket); + } + private void HandleRequest(Socket clientSocket) + { + Socket socks5Socket = null; + bool success = true; + + try + { + string hostname; + int port; + string httpVersion; + bool connect; + string request; + byte[] overRead; + if (TryReadTarget(clientSocket, out hostname, out port, out httpVersion, out connect, out request, out overRead)) + { + try + { + socks5Socket = CreateSocket(); + socks5Socket.Connect(dnsResolver.TryResolve(ProxyList[0].Hostname), ProxyList[0].Port); + } + catch (SocketException ex) + { + SendError(clientSocket, ex.ToConnectionResult()); + success = false; + } + catch (Exception) + { + SendError(clientSocket, SocketConnectionResult.UnknownError); + success = false; + } + + if (success) + { + SocketConnectionResult result; + for (int i = 0; i < ProxyList.Length - 1; i++) + { + var proxy = ProxyList[i]; + var nextProxy = ProxyList[i + 1]; + result = Socks5.TryCreateTunnel(socks5Socket, nextProxy.Hostname, nextProxy.Port, proxy, ResolveHostnamesLocally ? dnsResolver : null); + if (result != SocketConnectionResult.OK) + { + SendError(clientSocket, result, httpVersion); + success = false; + break; + } + } + + if (success) + { + var lastProxy = ProxyList.Last(); + result = Socks5.TryCreateTunnel(socks5Socket, hostname, port, lastProxy, ResolveHostnamesLocally ? dnsResolver : null); + if (result != SocketConnectionResult.OK) + { + SendError(clientSocket, result, httpVersion); + success = false; + } + else + { + if (!connect) + { + SendString(socks5Socket, request); + if (overRead != null) + { + socks5Socket.Send(overRead, SocketFlags.None); + } + } + else + { + SendString(clientSocket, httpVersion + "200 Connection established\r\nProxy-Agent: MihaZupan-HttpToSocks5Proxy\r\n\r\n"); + } + } + } + } + } + else success = false; + } + catch + { + success = false; + try + { + SendError(clientSocket, SocketConnectionResult.UnknownError); + } + catch { } + } + finally + { + if (success) + { + SocketRelay.RelayBiDirectionally(socks5Socket, clientSocket); + } + else + { + clientSocket.TryDispose(); + socks5Socket.TryDispose(); + } + } + } + + private static bool TryReadTarget(Socket clientSocket, out string hostname, out int port, out string httpVersion, out bool connect, out string request, out byte[] overReadBuffer) + { + hostname = null; + port = -1; + httpVersion = null; + connect = false; + request = null; + + string headerString; + if (!TryReadHeaders(clientSocket, out headerString, out overReadBuffer)) + return false; + + List headerLines = headerString.Split('\n').Select(i => i.TrimEnd('\r')).Where(i => i.Length > 0).ToList(); + string[] methodLine = headerLines[0].Split(' '); + if (methodLine.Length != 3) // METHOD URI HTTP/X.Y + { + SendError(clientSocket, SocketConnectionResult.InvalidRequest); + return false; + } + string method = methodLine[0]; + httpVersion = methodLine[2].Trim() + " "; + connect = method.Equals("Connect", StringComparison.OrdinalIgnoreCase); + string hostHeader = null; + + #region Host header + if (connect) + { + foreach (var headerLine in headerLines) + { + int colon = headerLine.IndexOf(':'); + if (colon == -1) + { + SendError(clientSocket, SocketConnectionResult.InvalidRequest, httpVersion); + return false; + } + string headerName = headerLine.Substring(0, colon).Trim(); + if (headerName.Equals("Host", StringComparison.OrdinalIgnoreCase)) + { + hostHeader = headerLine.Substring(colon + 1).Trim(); + break; + } + } + } + else + { + var hostUri = new Uri(methodLine[1]); + + StringBuilder requestBuilder = new StringBuilder(); + + requestBuilder.Append(methodLine[0]); + requestBuilder.Append(' '); + requestBuilder.Append(hostUri.PathAndQuery); + requestBuilder.Append(hostUri.Fragment); + requestBuilder.Append(' '); + requestBuilder.Append(methodLine[2]); + + for (int i = 1; i < headerLines.Count; i++) + { + int colon = headerLines[i].IndexOf(':'); + if (colon == -1) continue; // Invalid header found (no colon separator) - skip it instead of aborting the connection + string headerName = headerLines[i].Substring(0, colon).Trim(); + + if (headerName.Equals("Host", StringComparison.OrdinalIgnoreCase)) + { + hostHeader = headerLines[i].Substring(colon + 1).Trim(); + requestBuilder.Append("\r\n"); + requestBuilder.Append(headerLines[i]); + } + else if (!headerName.IsHopByHopHeader()) + { + requestBuilder.Append("\r\n"); + requestBuilder.Append(headerLines[i]); + } + } + if (hostHeader == null) + { + // Desperate attempt at salvaging a connection without a host header + requestBuilder.Append("\r\nHost: "); + requestBuilder.Append(hostUri.Host); + } + requestBuilder.Append("\r\n\r\n"); + request = requestBuilder.ToString(); + } + #endregion Host header + + #region Hostname and port + port = connect ? 443 : 80; + + if (string.IsNullOrEmpty(hostHeader)) + { + // Host was not found in the host header + string requestTarget = methodLine[1]; + hostname = requestTarget; + int colon = requestTarget.LastIndexOf(':'); + if (colon != -1) + { + if (int.TryParse(requestTarget.Substring(colon + 1), out port)) + { + // A port was specified in the first line (method line) + hostname = requestTarget.Substring(0, colon); + } + else port = connect ? 443 : 80; + } + } + else + { + int colon = hostHeader.LastIndexOf(':'); + if (colon == -1) + { + // Host was found in the header, but we'll still look for a port in the method line + hostname = hostHeader; + string requestTarget = methodLine[1]; + colon = requestTarget.LastIndexOf(':'); + if (colon != -1) + { + if (!int.TryParse(requestTarget.Substring(colon + 1), out port)) + port = connect ? 443 : 80; + } + } + else + { + // Host was found in the header, it could also contain a port + hostname = hostHeader.Substring(0, colon); + if (!int.TryParse(hostHeader.Substring(colon + 1), out port)) + port = connect ? 443 : 80; + } + } + #endregion Hostname and port + + return true; + } + private static bool TryReadHeaders(Socket clientSocket, out string headers, out byte[] overRead) + { + headers = null; + overRead = null; + + var headersBuffer = new byte[8192]; + int received = 0; + int left = 8192; + int offset; + int endOfHeader; + // According to https://stackoverflow.com/a/686243/6845657 even Apache gives up after 8KB + + do + { + if (left == 0) + { + SendError(clientSocket, SocketConnectionResult.InvalidRequest); + return false; + } + offset = received; + int read = clientSocket.Receive(headersBuffer, received, left, SocketFlags.None); + if (read == 0) + { + return false; + } + received += read; + left -= read; + } + // received - 3 is used because we could have read the start of the double new line in the previous read + while (!headersBuffer.ContainsDoubleNewLine(Math.Max(0, offset - 3), received, out endOfHeader)); + + headers = Encoding.ASCII.GetString(headersBuffer, 0, endOfHeader); + + if (received != endOfHeader) + { + int overReadCount = received - endOfHeader; + overRead = new byte[overReadCount]; + Array.Copy(headersBuffer, endOfHeader, overRead, 0, overReadCount); + } + + return true; + } + + private static void SendString(Socket socket, string text) + { + socket.Send(Encoding.UTF8.GetBytes(text)); + } + private static void SendError(Socket socket, SocketConnectionResult error, string httpVersion = "HTTP/1.1 ") + { + SendString(socket, ErrorResponseBuilder.Build(error, httpVersion)); + } + + private static Socket CreateSocket() + { + return new Socket(SocketType.Stream, ProtocolType.Tcp); + } + + private bool Stopped = false; + public void StopInternalServer() + { + if (Stopped) return; + Stopped = true; + InternalServerSocket.Close(); + } + } +} \ No newline at end of file diff --git a/proxy/proxy-server.exe b/proxy/proxy-server.exe new file mode 100644 index 0000000000000000000000000000000000000000..720183f6d09e0fd340b1cada4d33f95aa3d2eb2b GIT binary patch literal 34816 zcmeIbdwd+ll`np}d#0ynB#qQF(u`zF)>y`7?6Kr0AR8Mj+cF6JkS+NIcFb5B+k+*w z=o#4-#zxw(B-{YONtOgKSwl!RA;}GFHd(lVU0B#`vVjEhB%cI_JeEzeH=8Awy?JaF z?)RMPo*Bs|%ihoZ{qg(#uFRaSI(6#QsZ*y;ovN-GHeGWU8AKGo=gl{X9>bIW)(HH= zU;@R_*8dizzX(4+`!Q|f^RxR8`cdUr~`V#;slfI%qm47Ls>pI+mI{>7}b_NL(p9y>_{?t%i*=d>YSg#sWDN!;4T&S| zk&{uR6L07lEkThCnG1+!E)LRe5JFU&$VF;2Vx~hNPrRYU19o8LKXAKd8a5EA3f-Ac zzM=aif6pbM#Jx@!WfR;^D0y!h5=XdVdp#j5Wog(^WP%$faA+)XXs@MEqgTDwD{Y!m z+T@kOfCA);@3r5)kxp{0a*K0RZ-q{ZPIA3OVG}wqf$jw;ldK>~6gHL$xGQHHtI0}Z z^deL*-AO2!&q6WnP)r)yF>lAL(bDK_B#jP5D8<8_h8z(ppqpk**uh*jDUGHh4jPRR z)J$ET;erKe06IR6NT-pVxi~;B(x_*XUWlX439t(2#~C{H$h)i2erxaTZ6J4H9;4|7 zW}jg89$AvpnG{&7YTzD;=w>>|($Lx9JODJJO6%Xypv9_C`wI|GgQ#qljn_~RVrj3E zp2jpZN!Q;n(L1xy1YU(104FfIkc(l$naM4xjDzk;M`yzJt^qgjL0?1?v=uSiTm7b; z7A7qTq}NMdJw6q*+=c@)o=VGDR;VWWtfjhEB{R{bRCl#Xvp%a}(C7p7Z#ZZ=;-nbN z28hhKgF6`y+QEdo9jbiL4kpu3Y$R^R%=8>2ZeIifpc~zNs=UcWE{&1*2ED%OMI8d-mnvfl8Povojkx#vjwj2y{#6vjqk#^952)&#~S*3ItU@<+GY@Civ z-kZE*Da&lxR-$#${kG*S1g-miG@FKHMiw?ruhMw0%=wI#q&N$-{G65=&(Aa-&~5P- z82OMg6JZdibO&0p9Ox$EEC#6ZyR-xu_mhyt{W0qSlKQ<3JIl%$sqZW=Bj5|JcP=XD zMK_@Y(9Xrvu+AlAMCwbhFQrpDo0#WGQK4zJbEWQ{SWcM3}Rcw91PNJ zn1GlqY`3s_&uP}2gm()x$$VkI84ugxc9^}vP7)KV&ub!DFt~uTf`v7m zm3TOpmecM(LOmzZT0rSYI5)!0GRyg11^3^Wh}%qdSW1vgP^^tqEuXGdgTh+?6k0Xs z&Sh-(bSx}w)V*yw_D>l5nd#UcGxoLV*cKhwc7Q3ZT?@oXOf#=Trh5Yrtu=RGvT5;% z9Z9$!1>!+Fk~|5;29RiU&jbY1bP~#A%nbKAg_-HoH2X9yKFuum&7jn34I8M^8gb2# z%2%DlFfg0eYWEt2ORr+R!(n2*l1ae&UHMK7G=5W zWzK z?jZ2#%URkW&8kr7t*Vnr)%{O!qaGNAl#{6ds|m?WRhX?!2;2K{4EJ9>#z3f*Mza=ERBT|_qyBAb6HQoN-Ue% zg~$je@7+q~TQ%<<;A-6vIs#Ec$z43Uws~j%jR4ED^0GJIeAA2vGMFI?+9-|DG--jc zSfkzO8vnW1gTVy*Mfc;J0!6^RqY5bucHksuC)mlj3#x%*N8&E!6gv?2Do(KvNKVKc znqb#iKf!)@!7M$!0b=nu#nB$3DMG2OA4jYdkN~(%H%@S7fnUyD(2pZ_DwlJnR1O{} zM?6$6=gfl3^uiTvEC|aW7A?3&PxHb6<4|}6h`=up`$&TT_6GYf(pR$DOs5AAw;x?` zu0pCx52X7B?6odi;8 zI6CvATp%OOb{e3+L7!p2=rcL99Pha}Bb)`tgMdM;LVG2nHv+FE%KGhJnqyGuULAWS{>+2{`| z+1bS^TO2B2`bRPmg~OI1LYN2F@>3}dff0K&-bsecn$BOK&xtA#!W*Gvb?0YT6S^h6 z;()$&;o>M@TgR20?%0&M5zmz!-GJBb?uIv!J=Z(%gn?p}Z9T5AFge+M!-G&}16){D zW=)9uurg_>!LH7%4M7}Vq7<1Tii(E!u+){JWg0Rn@5$zwgq7S?ZK>WRcf&jK_IfpY z$LYMxnhb>(a7=nDYy++U^WudRRvR&&^j@^blEm!T$`q7~XqU|l8*4_jSIfc@w>x`K zSrrw~=SU=3)%gOb$q0$YTK6oAp|6U91p-fT`^@(POz2(@mt=rO+08yMskRc3oSSJP z)5W?us?@K#D5^VLs?j=z7G|Em1#C$YHAbj$*9_-6@J;a%M19OiXOZzb*Iq|v_(hNN zObiNUh3N_JsgxTGm3ts6X=atm$Uq0(SUGfOe+AP zZDgeW4BC)Nv0yrfYV+*i?M#iTKZacy?w!ztzjlgx!iZz+&M%@+cMeoE-%UnBsb5Vw zA-0XAH-@`K8+95MheY}y3uwPQ2`2L>A-jKMUEz7C%yZ@4JXpGuXL8eLf99S8Y^GjwMZ&lj-tOts;Xy?sS>;RaA8?v0246P*}9Gkzx! zOkuUq;&D5kc%jX2>_I#3xL}-AJO6sSKH&*3Dt*wdcM2#jZs4jBbbO{AvBOh3?i4{V z?>znf*3^DuxH`X%DLJj*SlZsA-y3-Tf}A@Zud!>;`6|06d2hU$?5c`6k3hFHEoAJ5 zP|?@Y@pFdy#Pp$F&E)=2tATFZ>(4V`IFrTxPwKDn+Nl~u45Y=Q(BK|Kg34~DxF$Vw zxsOl!2}PjnK3;2A0}qE&GP!u29kpxiI_C&9fWW%(9Mk)N9gW+WefkUNz*H@ZrQ@6; zy^u}Z&RkknSaRCjfWj)=AC`FpXC10m5K{%lZ`+tC>a6TFtxnUiXF_M;qgTTKvHO6J zmac#e>y>wf(5uzi%G1D`^2e7Yb$is*0McNi99H6p(w!TnMPB$6rPtzj;dQOfKt+>n=QOGOV`+=M0M_Y+ z2A;8C#dkjhzbosVbDTx6Iv?Tbq`Hy538?luzfws||^bnLD&JFuqC)XZmENEEM3Qn)j4E?<_u+F&6MiXd3!7cF z*X4NkIY^H^giYz2(MVwe01m11L7+(weMBO~nEt}mzNIJMNHETs1R-0UX`6^d-I(s- z{|NXRRJimHRaimR?aZZuD*aAWEOISgAe~!~QzsYPfaPIousmDNAEONOl9vQsHhmyj zxkWa8L3AyMeL#SEG13W{Bk5Z~A5pHTs?rhRyr|tW`d02hlK^m+gTbGo1f2mHs_UkF_1a`5XkFdf2&L=o9fk=SNwoA@pSk zsFIuFwt}7LeyJ!qmBu$dV7<8&H!V9D3psay4-W#fXtG1Rpxz0TZOYo|yq8@iP2cRVW1~HnF=K zP>x?xtezPvj|ErGJ~9gp_w#J&m!q{vW|iT-f}DPP6qYN|nEP`sMXZ9-S%&-iTbIhK zDcLK-0tp*z_N^a8#nh$?iDVk%BqlI3l1X{^t(Ev0k&KnXpiiimTs*IKf)6&!HuiNv zqd&8T^x##eS|h>=nnEm#fkUE-f^)A*?&?ogW#%U_T6IEKBV_?p#tE^M2d@ge5di&P zVy)xD9c}~m&1cEj=d58Q4|&|tHT{!AIEneff`pnq!9BSQxX!Zf2Q z6KL|bQaJC@X}RBq?%WG{2#t3uSbX%Pb06~M_bROP(^D`D{C6hRGyQiaZB*5+(pBJ% z3F7_f_0IiV7tasN)A`e>th{wlqnXXBKKFk!YU6;wo^?kGiinsAx4#DGk!I4pUY?CE z-l`$ruW}bK#RE)%gP$&D6gaUK4}PU2V~D-KYR3qq zS7OD)cu#3!d8vu0dO_f~y`fZ~CzXgnP^<@w69Ccx&#C9ZAA3(5{eZdxcx>T^oKs=Q zj1!2USSYMF0X;=)G`eFuBs$T<`ND&coDUUbaLg0kB_v$0LWWL2F?d5KMaQ{TLYD_# zs(gf1rXN=X=Plw|^>3J%z-HkgP&*$jS2+(pdEzQm@N^d-_*!Ze+dj^HQ<~8;H|lzF zZk6;iq&j_k6N;0n=k+HzqaWvfr!QfR>&LnCewsz%qcp~|D4lnJ3}8AeJ*I$wF? z%{S-yDv|fam>8As&E`Q%7vfWm-r|AySL1kvQatX4^9k@CV|Rr3^)gl~s*B&l9H7}^ z8%ALj3L=Is&nhz{1`smkVZLEu;K+}&M@VBDo6e`W zN($3nO&h%bc0LUhzhvXLpw+1$R)x6v@>*0k6Q^cQ$1h-f@|1V>#8r-w;xF}U$Y-E| zkelYYty+DnroIC;p{eBbrv94o$x|=@Pc(33(f$m0tQn2xD|-*hqJf1`BYiO{`!6NE zSG@C4Wbi_3R#m>Z#UcW(TTwG4t1ureBozbrIH=CknkRp?HA@bHrs|6)$NBX2JBSKF zXQ@jYQ&ju|Rpj?C0y4<)WyUsE^x7McM;YIAD%lYQZ0fK8XLzSkQ@Gxy=xV~yr_Z{P zEMCb);;=VBX9p;=HVhV(M1HzNbQxt@$eeL19^$S}74X&o!;1e#A)wn7jFcb8c+K#lz8v?S@5`kc^G{DncU6CU3`*87+Btv zN;=aONdH$%1LrdUe2FKLSt)Ph1M(ujI%(aR$6>XsG*zJla$i4fB|?l&V>^W~sLI{N z>l;QXa$~c(ODJo?5Hy_BzkWL0WPJT}C=s4vD)KPDQraC%anG+sF|tW7yBAsST@d_2 zkUk6%=d%1Fk1p~=@a{!J# z&}qVIL_&!xNv7jYSbin1Oip6ihpG2R^B}8!97ovYcNv_KcOC2#BQl=H`+Nef7Xlud z;Mh|xUHQB&6kdpI%DgxOUOM|qRmZ?P4=g2B92+1w!4;-za=%>)Hk5x2a@@stiEJF; z#4+dyzKlSD1DFTRU>d8jiE>3765i8k5v&vtYlA#Gjb7(>W+IEuqNr7YW>j7xHAu=!q>UYz^lSPXWKJQUV|pu;|NC3 zmRW%LMxMnQwOzb?4!s4~ih4bH#^0>Rl(6Fi@=LB+PtndX8 zoM7WnRcAR*2xH-c%T|a>%)*mE)jNL$IFGQ_88is}&u9D5kF$ay05(5*rmvo(IhMe1 z{uQCHzybBLmr%IT$ zjCqPxL1&l6Ei!CN{H=Q$O5}HJCf@zvejYCSq`-J$8XilV->w9BiFhQFgA%wsJAwmM zKbhQxb5+x>77_5^c5Rfh`L-MN8BbD5LqwcJ*kO-tdmTgy<6e{}N@gbEPRlOdjVspd z;IKPK&)eTslI3<*fhy(w5#So}f&UQ;c^VKj^ldy53NTXe zoPG?cC-G_X@H(E1e;K$R;e$CLf4r|^o*Cu;mVyOVvDTXVL(VfiFW6=I_cBn;_K#_G z&zbRk!JSw)7qVUBUPhpyKUlvt8c1x5;)#uV)J(&GO+KD)w-WK{&iSA=z1tKR^F(Xp zemg4nHcq1B%0FRQFbmJ3{Ru|FKk z)$KoNDdo3Rjnx?upa_L6LkbF*GA|(J9~nee;@+B|<=wJ%*VTDOf$oLfE0CM%d>QR; ztB(hwfemc#=;TU7-uMeSe~+SgZRTjKHvJV&av;>Y(%Hvp*rD3!hy+Pbf?%&VN8;5l z8)zPvT5#v*I#iIGz#OBwJ4?-a?X@?6&c+LA=eZJL05AK{EcVOtT4d!o4!zFS#%kY4 z;Jj(3gPF@$g-A}VapC1&ajkKy05=xlnTviqUqyPJ9d1pjmxY*8mh&~>`~qZOsK}P- zSYJ{(8IP!)UC55eP@KQ_!Or)yzC4<@)8iE->goNq!)_J|@@4dysyt;D+M)DCzA?7q z2=EV4m%LQz{E$a?5mae=cPV7>W-*(C6^%kMFP0R;g?w_SB!~v>u zOnm|Kiu%>XQ8@zTsWQOh60b7ss;zbv6!&$o^vp(@U5z(frQQeRRNXfS_P15O9=B4) zKBp}VJ;#nXA)U(E=dg*L;8qdfyp1n7w{a2bd@W@zW@aUE?7%ds-?Cydj%yucS1<7H ziPdZG`BSZy#pc)-#!l_N&#B*vJ%cW zSi{%{RMf)C84qQ0Av?IM3a8Y&;mi}^rrqgpf;1Ehq?f~%{1)yq(%%BwOyErX+emE1 z5x3WPE1!n%j+^MZ1pix2I}qrFN>rFkNi>2LX~8x`7agdo;&Zh#Ua%IyYak83?5$i_ zFxVg0KyuL9lx!}12X)G^rOtaAUI;2;9)xsyCXT}jj)A<$>ojW7g0+2Dt<}`BA?FfD zmUk@fShjfCk`-Wr6=Mi6oy1=2B+&=(>}^82ujuAS4i=c=CyR-0ssnC&A6?g_4zuT6 zvAt(K%0`j5;I!wg9pXy_@`rUu+u8bHI6Mp4-_2dd2fvV&2Jk_W;Kb!_TMkcNv(Ps= zTZPZ!{>pxU`_5|?nw7e|uJJwrF4Ny5A{Fs^WzvVW4{9cT6fi=c(;f!=TR@X~^~?1L zy+dcrD*~@GIQ=1mDW8?}3j%)>{JWq@KNnby^&4MNFd1`^$(%QuD}l)g{0U%$_F2s5 z4(moMLjO~s73TE52)DbSifiqvUIA8((O}e}g;9(zT^qeLYSM9m_XvDE%9JlfzY2V~ zmeUOaF9eLx#kJhZRRUkvgP?hzlzzPS(rAR90p6tF*IurhWYuv`7u7N5`a0%w956!n z3Ff&v=KpKKTQ-7DT4*!p!}dlyO0U~LvrTG>eNc;0cWgxrdW-#CFhRG*^05S+1~lmr zl$!KAftSTOeNDVOZqh+X-zDisCH<_xUkTh>&w9JN{+;zEJuL8x^<4LPfxoKfR?G(G zzpR1ry^{Xp2G;PG8(6|$G_Y1{5)3Z|G|5dc<+}wwB=EBWBT2@mlXlXiEs}NsBlJL$ z`Fsk{r0)oZ8kx_T#t&+j&|@$&^u0;6-NdbQ0m8UE+fp;9PNKCSa2gpfX80xS`w;rT zBI{@+ay67hPDc*A6wIm_$a8K`a?NpD)6@g z4Opg4)dFV-oU7LWvsAAGkDYow;9!@a z2e?^azrfvq8vWe31+XgcZopK4Jw#66O#(k1_$V-e;NyVfK`y-u(5BadUjm$Aegkla z`5i#le6`fpznSy29TOB{SOw_IP#0PR%xr;aKp(#%v<&zzfg1pQd|&7i;CBcd6?mt> z2LwJU@EL($75F28zY`d?xNbt=0)Z<9UIn;>zG%Hin@26-2+gB;;S^vdTm!G#8UC22 z(dO`DfI9^43qOJM0fECPHK_f#yEJKLogS;-A9b)3#1%k?#S2 zHqxQbrk_XNO|$8LM4r%QlU2189w=7DeEvk>BUN|m8a)y-b+p}?Oa_wEYHTDr?C`%>V);@B(!&CB%#S0eY4m)nann||r#4x`Mb->95O7E=zD?xrsU{{~*Qk=-kD zx0wHm+nv=M6kx>OYyKWbw%_-1519tOyw!-8D=qAFU5)Y2vSzaJ_%G?$zegEv4KUZ2>(ixl>|i z8A{=Go3hAcK93GMYOn!%kHLVf>htLZnAyPMYI zB&~}c>gKXn!$XmfzK*`&FQ|YECB3j( zhp$>J=}!ikrV{1^Kl~P@m6R$yCX@=^h_p_30pdG975IAuUy1p!lvd(DCKv@jBlMpW z%+mtDBABlO8uVSrAD|xy{+|S@TECL?Z%Y`R-k6p)$UsORplU$85e3w#S<-VPt?JH| zG#>?PxRJy(oqz_d2Mo|x^t!qrRv#|iBs7CUGbA*}ghsW?ZRuWHN>>xWXmq>4N==>; z$}=@}bq0M>N_`Bs>&wjTs?xs#rS7G_B=leLAdl?FpdSN$XDxjWFi0=qK-i>Vq(k&d z1?4Y+H|W=bVeRSiN=ze_{!BH?qefvEJ2H(@h*BBWAqGppd%j4#Vtp6zv--~f=jg8q z=C=Yh?WyQ{wXpWGz@J2))HUt3=s3Pv{Xc=Rwc6Te^d_yP_DjGos(oJ4-_Yl4`)Xg( zS7^oB9{}EpHLyqHR+b3QCG<+|YeN5PeXI71+LgfbD!qhWt9?V?4a%@FAC$F#$+`rf zD&@5s___?OpmlXEMqb-eH$pwMvu>Pj&gF3P$s)kb2BI?UeRUTZE9jBB zmBys@WZfKnQu{{T8sj$YC*adVKd;*W%GaV-1OC2lt8tHJ+dJ`0J{&XMezGB30|#z_UE}0`qT%AY){COm z?+W~(z;F_`ZgJL7MUP9_Dnz8%Q&zUof}P!wvr&xQ*)S+k$%p zbBZ1V<`gi^<^yyzzTOEa6C$^S*^!bSW9uW9A`oc7yNE|l?kq#S=)88bXN8J|^ zKSiyV6RrBA^eajKKJm}y#bbs_`x_TqPiyxyUS(<8 zqm9>E%jucM0qYievGHAEhef1cZhS_pdfe)vR~yGMyMHY((8T_#rsf9e}@~0l?qWA;9?>OS>8nJ8|u-7NqssE5LUH6Q$dTi_ajT>`TLCj?FkJR|S{flmv3 zMc@wwQb2eH4nwxt0@nzpOVZa#IxFc3NuLo&xHb(5O@PU8vVdE*<#ZwxC?6Nm*8fmwk$f%ZTl za6It7KstDH@Jqo9%$zx9PMYsBzo&i5e9C;u{DJu^6GzXXmeA_Z)uElCJ)!GDPldh~ z`d#Q|>oMzVmQFZ{z{(%QKM>-dg@0kJ?^TE*7a1H@UT546_#xvI;EKTAfTsk0P~bCx zPXMzh_$1&bgIxNkz}KqYhk)YWt3C)=SACz(EgAUKq9y+RHN!fzry-IEAss`j8pQ=R zqIDf-R}7o5gVb=6gPoq0E z&P~OA@EqJ{ji1@)(zRp5IlXRPa0Ysz#)RWGj@opL3E%O6Dp3+6zL!R{mF_V4c`cr| z(Jzc%N(Zi?-v+OtkeSi0GnZ%&ntAR0p?Bi*9&HYGgb$jh@G)o;S0h@r$r&6Q%3X%* z8_u!u_Cn4*l5;z9$8ywvG+!Jz)XV5T!P5HNk^DfeXRv>KG)L2*TzEvc)`z z);Yt&*^xo&a|RCQitC(_k=y_mY|9nKhKkg)ex!h;GX%Xr0|&A|R-(FH*Kt8G>Wma} zYsd0KgP^-IH#C}a3nbd>89Cs1+_&Y>6-gH^qAQEV(SC=UEnFmoG+Y>P+@bt_>KiW< zbHlV{|MifCx<|%_Jygf$T#+jDEnVvYLNQRofHxxBp@*)6=;gXYt}~n~=Qrft+yQ*Q zs6;@}Zt;NQ4s&8W=dOjKb5O^qP%^hE$4*f{-3= z%%dwK(8-XW@Hx=ITydXtUxeS_jCrF_P-^Q{ijqvP;}$EjEPX|`H(Mwu1Ey_bBh**S zGIbH(Wn>-u5@2ZMm&Cx3?UOd-VBedTZSSP*qp-i6Z{9p=OrR{hK%27p5$YHy;=75{ zr9F}z8q4k5N1LI)>=1Pij~2&~8G*t_@;8k6vxHj)6UfY6R(#92!g+Ap-MrB9lgs_Q zFH^Zrfz83UKb1zqg(6*%D^`}Qn%3-PhxhH{vBs!v$mcLkSD}q_kzRx66!?O0hl)JY zMNE}qTp@o@F#KF4=yO=CLfJV;j~8k&7jX;RS4pg5cuox|k|y8MSulsUb|tIcvgn+) z-jY(J6>E@QtCXjQk!*3y&H38!a;t>ooCa7YisGCikGo$EUsUpczH?aT3=JvgRp_{a zCtZGkC&R#D_(z@vcoas5^2ILK&5rjvxGCXJVE%=@K6%yZyD)SWrs3z-=fx*x-SO*| zk~jsg=C<4cuSpU!5@&aS4T@9ivIB>5JfGkVi{nx@T9Hx38{nbDUH4Ph=qP;8b~xuD zB~3TG1QwaZx9{NjrjlJF&dB(%GgeTzb?`vN+@{>{{+zoxJDe*EObRKD3lq<)_VgZE zPV1dvOiZpo$X|@PJ8gBiqOY&3w@2LN2&mZ89Ch45tQxXT(AqpC+&(zkRVX+Ed5Az; zuEx}Z?z2PUEigz}IW}bTLu>?IH7qZionqI}kaILQxORLSR$$mD!MTI^;q4>n%)p`S z{-GRQ=@g2U`7#4&eyotg^4h<#``9SlItwLB8Cds?jgG=)0#~sHPQmw#z@YMj+j7cV zY#%v1;v5~3)oj`dsc@AIv#jO3D);zdtdEVC+)+=j*F5#RjOA67#HM`9Lzg>PPq|`2 zylbx5eQY2%Dl2opQ*H#6c{K)tJ!xURGY0K!&K=zdw+bbVt{n%s5~~m|yFG=e81{t~ zdhi-znbzmAa2IkOrmHYMGSGbl%Vw90I|rxOdKk+D(QG9vPB!Q7Ha2BSYZl;bg%Fk@|gJ&Zz4+o2&u9b@}PatECvj1=a# z1A4+kt!RD*3Q_Z@e+vP{z))nJKsm?SQRc)B|>d9BXBV3To}E$g@|j zEXN>;^RX^IW3$1E&`ntLGCH-MvON{3v5SQ6 z#7h0KFKl!MvaG%yQMPA>P(5PY(c(H3ab!wYB7z*s`IAYEJv-q0 zyTpBY!f@m=%2t8rRB35dg~HBRAy!Q#W=)+#Q&E4KCPcB7+8glVC3qsS2CN7)CSgqv zWRnQB1L0ONKb-53mYq=^B5V-~v}LT=RYWKEkHI4j$X201uIFp6=h!?euD-i4kR8pf zJCt>^0~lp0aOl=Ec#Mi1vk!|qFVG=>BIY>Qrb7toe3Q+Uyv2SsyBsx#hVsK$G%&5U z98ij%d%R7dx+8~6M_+Pa>RvaBeU*J`TY26U7@GNFu?jZNEK9I*zJh0x!)Kp;h#e!d~~FOzDLCy5`+4ohsR@`gFx0xV|Lsi=M#uQI9%=F zvkH~QW}{<3cFoYBPw%f6m_ec!aSCi3S?(<_0%gNytcu;sxRTAnOO>Kn{#W*%J* z0xPDs+E_@bN9aky+E$AjkMUNduQ-m-o_a95hqFB2kL3A$2qA%NMMR(;oOkeQl}Bv64nZ#V39?z? z);X$%rf`m<3V3?Jr6Ul=qI@tXwz3{J#-|TLUy#Ehm6UBBcdL6S$96B&3W+cP^RZ{7 zP|S|N`w2bUAtt`J$6||8Y`mSM)CRMPERMtSBM=;mSGfS8GfY5c8T@F`9b+4i4uJ;Z z`zYx12o4T>%P7wZs7ux~%+H8IeqB z83Yl|f>=w+4|uyzSO7|tu6YlMWp%?)7Ml)WWg{&q7!2>6^Dv=<=#3P}IB?D@99Kp# zA{97r@Zu8G!C_yaxW)BOE&ctK#8AvY#R?LDiUaj3ZpaSj5%4m_xfl!}2NoQiN+8vD zXskF0*MVJ%m*C0BaZrEhR7l0jUS#5%2d~@Zwa&*PZrpNU8=H2yjJ+=#rWeD={!C3! zKKIQI4q}d$cRswg%60KZPI@4&yWh=@6tMN;Ju-*i<>IoSU1D9nH7Y-YLFwqv77in9 z0FrE+m7DukwFPG;AXu9kBn?freGvUgAIp+KJ6Cw#<(B0JVCl zsha0)gUsH7zvS?lEC#|`bU4Sl@uDl*gy?rOpKLL*H(NZ^H8Qw$EaxiQ-r!~>8jw<+ zH^dt8o%|+{#4>*?W!lsKN&WZq#Cmcu=~PsxlDA`3c3%xFt!D!_ro%2c+`tr}6u74Fa}L#s^TAM_sPLJsX5f>ycOO=YZ>>^`zBgy*5Tc zN1+AMD{8I8whI@Qc~JNtByG+?x)JG7T8O?5qWl0_%Ys8rF#93RbRJxeND@8E0_I4o zV%ge}FOb%xG{W_jyhV)8C{ryf>v2@t;2vIzR#{hBw8JA}!zt#b}H?rV7*58%U3^r8Lj*M$`{a9_AyyVpnVi?(b!kAtw3A+fRP z?Jt66xQr%$dmOJz9Z`V`W<@BQ2Y)lt!)T%nU1`Hb23G1g?FVEdtu&W@&>sfn2zJN) zcn*V?KSXU9q&74(Cc=(;+{*dX|2F3|UhJ|-DnsNc(+AnvG2BSCHf`%V0`{e=5jV&E;}KRJrVm3=WQ#T>6LoR#_^w2?gV~C#jNKUQviM??5Pd7@~mPU+cOUn&-L=0RGx$S zln>xKN@m9M@-zgDs#8|6s-tS0Pd6UVP#XsJ1m8ZLJx_S?oZ1ID)w~*nllEnzt<#+% zyUs&s*%img&UggwxkKhw5l$ONf#~6)X>*PjSC$ygcToq-OD&w2OFz`oh_gW$7@H+8 zq!kk2=Vz4R;--AULUyZhwO_5P+ zV0YyQP(7YAqWBov&wK0XKBNW_6hg79S>3~8!IvN`IgB=zAvb{M#WE`nVn~*Q;t29b zWpFNnJugAZferq66CNC8ag>*ZQZ9m`7Q@=o_zgy0PzO0aO)STF3+lQa4M?28hm(iT?D}{LG5LrWd2vWdZB1>9Chq$`+ez*&hx04Z@NyA{yImH0 z5ky-IB`x;g5|nO$SSsw}O#ydA)ufQJH%f8`IrL>)DzC{DW*g!eLJfU@9Asa`5T6hG$i3gaBJ^0`oOW~4_tSb9sn!j8mPQmH$ zLOKSXJo#CrH;QyM_#zm8D2%V|pqJQ+;OlrEZXe^=bb3&mC3c0&5HKvczX3|&tC=qT zF<-8`3q9(UyxJ_4OWR={<(NuqSceVBX8h}h25^N`a@24&^4;LTTa}H#?m$kP+Y7h3 z1-y3)p2PMlfoF%*gW6qqg6hKaQEfrZ9=b+oR2v$uWp~4W_oJi>WgC&(hVO{0{AToS zgJ9YLH-q~YM3Fs6U4gCLc1U)G)LYLyY~PvsfeM93MH6tZwjbD+%50A4cw2TxRl=04`S0S}pS&Rc{O*m9|M>8yF8}S?wPgL}jeB-9Eq~=U-Hhs{Zr~S{3^Ue% zPZNH>K-WzR$z4ctn_4iXX|a|-N{dDTv}k~k!rjwov@UAk?x|dHF$X%I4)Yozh}hoEg1+GZ3sGrpN|DZ!Is-xO9N(-B@UJ}tEC%>YaQ`Q`{}bH*39dN7#1oJ_ zwpG++cjBShow4mS#{8J3%9LD(Ia~yLoK#j;9e93gDG8$TA@?`Fv^_-Hntm| z-VjP+yJNc{zt$Yvi)+A=)y<%$X~Aj=qMhAQE4DGV3hY{f_J`|4f7=SU_(ZPa=Y-tLW!Hv!L0xj0a{fkDeM=sxYv;D2e%DPZh ztOkf!BS2J#1n5bf9teR74YfcTn6a)m#3ud$_6{?SXa@Hjf3@5wn#6xbOVmOo4Ra4* zE)*LLg^bwzXlykgMDA$o}KAHbTqH?b9lK8vsL$GYr^ ze+`8KmN5%IxEeAnjEBrwC@@ak?>N`Z9LIloB%QKfYbsMWSwk4 z0!7U-&K6t{%A4Cm4Y3uL6>bJMb}JB2n8YTVST+N>$tAH( zNH&IiG7Fw9Ht`Fu63-EI-9iPZHm;$EEwOHUas>u7)-AanFV~|u#Jas_4;yGR)X*BL zYA#C|>lW6r9(bWxcdVyb=K+e=#kOM3wZOksi+A;mj+@!a=Cn1o6@&q{J+{-R-3gW3 zlU+#LoooxbuGiOTM%h}WuVS=3h3v_0VPh}UWp+;Xz#cR<&XyV({9Bmqg_jU#^ep`Mka5-K*n;h$+2c&(XSj&`Q19O5X7DwZw%EUiv>c5 zOW{7S*T+BIQfx23IJ2}s1mzx`-UNY51eJ97zB8NW zMQ}7;IjkSUJb5CVq6T|%lFe40P$XtIG>&DuHB=WB(}E*{Y*DN{DqEl;q-rb>ap%e#&XA+)8Dv; z>kO>i!>C_Ned0bPe`7Lu35vyRc`JFI_0;T~F#59o!Et z|L^{5yuU1e2d7izd}Yf2|DqG&MJz5c<>U(!@?Dl(u48Zr+fV$xIhRsvB=!Fdz~5XG zl}z9(@z~bW#MFMN-)q88Ih+@N=sb9?KaCg|Wx;nLb><9yvy2Vc|N98h&sDKt_UOy; z+=2bYKG_U!LrQIA_sO<&12(Aq_hjIgZ}@x#;Q{&K4x|J4RIruO^@7`h-Hamfz!*X?R0e{t&p?py%FHZfiHA z2YfVx-fHFh|OW(&g|N0zjuhO;C21GpaR-Skzh zfmJG1V%8&f%ZuYvB*KphLMpz6ySi=DI4*t-dEfNwz?I{+MT!HiCGord1J2u&XK9<% zL)!)Ko~M@w#=F*>8^krPBe-{ZFjsh+T$lM=Q}`?MfeRgwrDbF8NNy-K#Bg<6wxBLd zxoxSjJm29cK#d1*u|DT@N|-DAoFYEelEAXv4?A0cFTwEyqlIU0Zyt_E@q O-r@Y;zyJSf;C}(kZxb~D literal 0 HcmV?d00001 diff --git a/strip-alt/strip-alt-ublock-origin.js b/strip-alt/strip-alt-ublock-origin.js index 11f4949..c22a072 100644 --- a/strip-alt/strip-alt-ublock-origin.js +++ b/strip-alt/strip-alt-ublock-origin.js @@ -141,13 +141,11 @@ twitch-videoad.js application/javascript StreamInfos[channelName] = streamInfo = {}; } streamInfo.ChannelName = channelName; - streamInfo.Urls = []; streamInfo.AdUrlCache = []; streamInfo.IsMidroll = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); StreamInfosByUrl[lines[i]] = streamInfo; } } diff --git a/strip-alt/strip-alt.user.js b/strip-alt/strip-alt.user.js index d3ffa68..6f8aa6d 100644 --- a/strip-alt/strip-alt.user.js +++ b/strip-alt/strip-alt.user.js @@ -152,13 +152,11 @@ StreamInfos[channelName] = streamInfo = {}; } streamInfo.ChannelName = channelName; - streamInfo.Urls = []; streamInfo.AdUrlCache = []; streamInfo.IsMidroll = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); StreamInfosByUrl[lines[i]] = streamInfo; } }