From bd96135e245977c7fda8ab66aef86728dca19466 Mon Sep 17 00:00:00 2001 From: pixeltris <6952411+pixeltris@users.noreply.github.com> Date: Thu, 31 Mar 2022 16:33:10 +0100 Subject: [PATCH] Add "notify-swap" #59 and "vaft" #61 - Added "notify-swap" #59 which swaps the entire stream during ads instead of stripping ad segments. This should fix freezing issues on firefox. - Added "vaft" #61 which is "Video Ad-Block, for Twitch" (fork) as a script. - Removed "proxy" as there is a better open source alternative https://github.com/arthurbolsoni/Purple-adblock/ - Unlist "bypass" as it's built into ublock origin and doesn't really work anymore (though may reduce prerolls). - Updated misc links in full-list.md --- README.md | 15 +- full-list.md | 12 +- notify-swap/notify-swap-ublock-origin.js | 664 +++++++++ notify-swap/notify-swap.user.js | 675 +++++++++ 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 34816 -> 0 bytes vaft/vaft-ublock-origin.js | 727 ++++++++++ vaft/vaft.user.js | 738 ++++++++++ 14 files changed, 2815 insertions(+), 1775 deletions(-) create mode 100644 notify-swap/notify-swap-ublock-origin.js create mode 100644 notify-swap/notify-swap.user.js delete mode 100644 proxy/README.md delete mode 100644 proxy/extension/README.md delete mode 100644 proxy/extension/background.js delete mode 100644 proxy/extension/manifest.json delete mode 100644 proxy/proxy-server-build.bat delete mode 100644 proxy/proxy-server-info.txt delete mode 100644 proxy/proxy-server.cs delete mode 100644 proxy/proxy-server.exe create mode 100644 vaft/vaft-ublock-origin.js create mode 100644 vaft/vaft.user.js diff --git a/README.md b/README.md index 0d31c47..fa5a05c 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ Proxies are the most reliable way of avoiding ads ([buffering / downtime info](f Alternatively: -- `Video Ad-Block, for Twitch` (forked) - [chrome (manual install)](https://github.com/cleanlock/VideoAdBlockForTwitch#installation-steps) / [code](https://github.com/cleanlock/VideoAdBlockForTwitch) +- `Video Ad-Block, for Twitch` (fork) - [chrome (manual install)](https://github.com/cleanlock/VideoAdBlockForTwitch#installation-steps) / [code](https://github.com/cleanlock/VideoAdBlockForTwitch) - `Alternate Player for Twitch.tv` - [chrome](https://chrome.google.com/webstore/detail/alternate-player-for-twit/bhplkbgoehhhddaoolmakpocnenplmhf) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/twitch_5/) -- `notify-strip` - see below +- `notify-strip` / `notify-swap` / `vaft` - see below [Read this for a full list and descriptions.](full-list.md) @@ -27,15 +27,14 @@ Alternatively: - Ad segments are replaced by low resolution stream segments. - Notifies Twitch that ads were "watched" (reduces preroll ad frequency). - *You may experience a small jump in time when the regular stream kicks in*. -- notify-reload ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js)) - - Notifies that ads were watched, then reloads the player (preroll only, falls back to `notify-strip` on midroll). - - Repeats this until no ads **(which may never happen ~ infinite reload)**. - - You should expect 3-10 player reloads (give or take). Once successful you shouldn't see preroll ads for a while on any stream (10+ minutes?). +- notify-swap ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-swap/notify-swap-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-swap/notify-swap.user.js)) + - The same as `notify-strip` with a slightly different method to fix freezing issues (especially on Firefox). + - *Has a longer jump in time compared to `notify-strip`*. +- vaft ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js)) + - `Video Ad-Block, for Twitch` (fork) as a script. - low-res ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res.user.js)) - No ads. - The stream is 480p for the duration of the stream. -- bypass ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/bypass/bypass-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/bypass/bypass.user.js)) - - No ads *(no longer works for many people)*. ## Applying a script (uBlock Origin) diff --git a/full-list.md b/full-list.md index 3562e25..889f478 100644 --- a/full-list.md +++ b/full-list.md @@ -4,7 +4,7 @@ - Uses a proxy on the main m3u8 file to get a stream without ads. - `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [code](https://github.com/arthurbolsoni/Purple-adblock/) - Uses a proxy on the main m3u8 file to get a stream without ads. -- `Video Ad-Block, for Twitch` (forked) - [chrome (manual install)](https://github.com/cleanlock/VideoAdBlockForTwitch#installation-steps) / [code](https://github.com/cleanlock/VideoAdBlockForTwitch) +- `Video Ad-Block, for Twitch` (fork) - [chrome (manual install)](https://github.com/cleanlock/VideoAdBlockForTwitch#installation-steps) / [code](https://github.com/cleanlock/VideoAdBlockForTwitch) - Replaces ad segments with ad-free segments (480p resolution). Afterwards it invokes a pause/play to resync the player which then continues normally (normal resolution). - `Alternate Player for Twitch.tv` - [chrome](https://chrome.google.com/webstore/detail/alternate-player-for-twit/bhplkbgoehhhddaoolmakpocnenplmhf) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/twitch_5/) - Removes ad segments (no playback until ad-free stream). @@ -21,7 +21,7 @@ *Compile from source* -- `city17` - [server code](https://github.com/AlyoshaVasilieva/city17) / [extension code](https://github.com/AlyoshaVasilieva/city17-ext) +- `luminous-ttv` - [server code](https://github.com/AlyoshaVasilieva/luminous-ttv) / [extension code](https://github.com/AlyoshaVasilieva/luminous-ttv-ext) - Uses a proxy on the main m3u8 file to get a stream without ads. ## Web browser scripts (uBlock Origin / userscript) @@ -40,17 +40,13 @@ ## Applications / third party websites - `streamlink` - [code](https://github.com/streamlink/streamlink) / [website](https://streamlink.github.io/streamlink-twitch-gui/) - Removes ad segments (no playback until ad-free stream). -- `multiChat for Twitch` - [android](https://play.google.com/store/apps/details?id=org.mchatty) - - Unsure how this one blocks ads, but it claims that it does. +- `Xtra for Twitch` (fork) - [apks](https://github.com/crackededed/Xtra/releases) [code](https://github.com/crackededed/Xtra) + - Android app. I think this blocks ads, but I'm not 100% sure. If not maybe try [Twire](https://github.com/twireapp/Twire). - https://twitchls.com/ - Uses the `embed` player. Purple screen may display every 10-15 mins. - https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/ - Some countries don't get ads. A simple VPN/VPS could be used to block ads by proxying the m3u8 without having to proxy all your traffic (just the initial m3u8). -## Additional lists - -- https://github.com/saucettv/WorkingTwitchAdBlockers - ## Proxy issues Proxy solutions can have downtime and you'll either see ads or error 2000. This isn't Twitch retaliating. diff --git a/notify-swap/notify-swap-ublock-origin.js b/notify-swap/notify-swap-ublock-origin.js new file mode 100644 index 0000000..be55fd8 --- /dev/null +++ b/notify-swap/notify-swap-ublock-origin.js @@ -0,0 +1,664 @@ +twitch-videoad.js application/javascript +(function() { + if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } + function declareOptions(scope) { + // Options / globals + scope.OPT_ROLLING_DEVICE_ID = true; + scope.OPT_MODE_STRIP_AD_SEGMENTS = true; + scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; + scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false; + scope.OPT_BACKUP_PLAYER_TYPE = 'thunderdome';//'picture-by-picture';'thunderdome'; + scope.OPT_REGULAR_PLAYER_TYPE = 'site'; + scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; + scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; + scope.AD_SIGNIFIER = 'stitched-ad'; + scope.LIVE_SIGNIFIER = ',live'; + scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + // These are only really for Worker scope... + scope.StreamInfos = []; + scope.StreamInfosByUrl = []; + scope.CurrentChannelNameFromM3U8 = null; + // Need this in both scopes. Window scope needs to update this to worker scope. + scope.gql_device_id = null; + scope.gql_device_id_rolling = ''; + // Rolling device id crap... TODO: improve this + var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } + var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; + var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0; + for (var i = 0; i < bs.length; i++) { + scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; + } + } + declareOptions(window); + var twitchMainWorker = null; + const oldWorker = window.Worker; + window.Worker = class Worker extends oldWorker { + constructor(twitchBlobUrl) { + if (twitchMainWorker) { + super(twitchBlobUrl); + return; + } + var jsURL = getWasmWorkerUrl(twitchBlobUrl); + if (typeof jsURL !== 'string') { + super(twitchBlobUrl); + return; + } + var newBlobStr = ` + ${processM3U8.toString()} + ${hookWorkerFetch.toString()} + ${declareOptions.toString()} + ${getAccessToken.toString()} + ${gqlRequest.toString()} + ${makeGraphQlPacket.toString()} + ${tryNotifyAdsWatchedM3U8.toString()} + ${parseAttributes.toString()} + declareOptions(self); + self.addEventListener('message', function(e) { + if (e.data.key == 'UboUpdateDeviceId') { + gql_device_id = e.data.value; + } + }); + hookWorkerFetch(); + importScripts('${jsURL}'); + ` + super(URL.createObjectURL(new Blob([newBlobStr]))); + twitchMainWorker = this; + this.onmessage = function(e) { + // NOTE: Removed adDiv caching as '.video-player' can change between streams? + if (e.data.key == 'UboShowAdBanner') { + var adDiv = getAdDiv(); + if (adDiv != null) { + adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; + adDiv.style.display = 'block'; + } + } + else if (e.data.key == 'UboHideAdBanner') { + var adDiv = getAdDiv(); + if (adDiv != null) { + adDiv.style.display = 'none'; + } + } + else if (e.data.key == 'UboChannelNameM3U8Changed') { + //console.log('M3U8 channel name changed to ' + e.data.value); + } + else if (e.data.key == 'UboReloadPlayer') { + reloadTwitchPlayer(); + } + else if (e.data.key == 'UboPauseResumePlayer') { + reloadTwitchPlayer(false, true); + } + else if (e.data.key == 'UboSeekPlayer') { + reloadTwitchPlayer(true); + } + } + function getAdDiv() { + var playerRootDiv = document.querySelector('.video-player'); + var adDiv = null; + if (playerRootDiv != null) { + adDiv = playerRootDiv.querySelector('.ubo-overlay'); + if (adDiv == null) { + adDiv = document.createElement('div'); + adDiv.className = 'ubo-overlay'; + adDiv.innerHTML = '

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

'; + adDiv.style.display = 'none'; + adDiv.P = adDiv.querySelector('p'); + playerRootDiv.appendChild(adDiv); + } + } + return adDiv; + } + } + } + function getWasmWorkerUrl(twitchBlobUrl) { + var req = new XMLHttpRequest(); + req.open('GET', twitchBlobUrl, false); + req.send(); + return req.responseText.split("'")[1]; + } + async function processM3U8(url, textStr, realFetch) { + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url ' + url); + //postMessage({key:'UboHideAdBanner'}); + return textStr; + } + if (!OPT_MODE_STRIP_AD_SEGMENTS) { + return textStr; + } + var haveAdTags = textStr.includes(AD_SIGNIFIER); + if (streamInfo.IsLowResNoAds) { + var wasBackupNull = streamInfo.BackupUrl == null; + for (var i = 0; i < 2; i++) { + try { + if (i != 0 && streamInfo.IsMidroll) { + // Doesn't work well with midrolls (often wont see the ad until a few requests in, which creates a reload loop) + continue; + } + let index = i; + var targetUrl = index == 0 ? streamInfo.BackupUrl : null; + if (index != 0 || (streamInfo.BackupUrl == null && !streamInfo.IsRequestingBackup)) { + // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. + if (index == 0) { + streamInfo.IsRequestingBackup = true; + } + var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + var encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + // TODO: Maybe look for the most optimal m3u8 + var encodingsM3u8 = await encodingsM3u8Response.text(); + var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; + // Maybe this request is a bit unnecessary + var streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + targetUrl = streamM3u8Url; + streamInfo.BackupEncodings[index] = encodingsM3u8; + if (index == 0) { + streamInfo.BackupUrl = streamM3u8Url; + streamInfo.IsRequestingBackup = false; + console.log('Fetched backup url: ' + streamInfo.BackupUrl); + } + } else { + console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); + } + } else { + console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); + } + } else { + console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); + } + } + if (targetUrl != null) { + var backupM3u8 = null; + var backupM3u8Response = await realFetch(targetUrl); + if (backupM3u8Response.status == 200) { + backupM3u8 = await backupM3u8Response.text(); + } + if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { + if (streamInfo.LastBackupRequestWithoutAds[index] < Date.now() - 1333) { + streamInfo.LastBackupRequestWithoutAds[index] = Date.now(); + streamInfo.NumBackupRequestWithoutAds[index]++; + } + } else { + // TODO: Throttle this. Currently this sends way too many requests. + if (index == 0 && !streamInfo.IsMidroll) { + /*await */tryNotifyAdsWatchedM3U8(backupM3u8); + } + streamInfo.NumBackupRequestWithoutAds[index] = 0; + } + } + } catch (err) { + console.log('Fetching backup m3u8 failed'); + console.log(err); + } + if (wasBackupNull) { + continue; + } + } + if (streamInfo.NumBackupRequestWithoutAds[0] >= 3 || streamInfo.NumBackupRequestWithoutAds[1] >= 4) { + console.log('No more ads ' + streamInfo.NumBackupRequestWithoutAds[0] + ' ' + streamInfo.NumBackupRequestWithoutAds[1]); + postMessage({key:'UboHideAdBanner'}); + streamInfo.SwappedEncodings = streamInfo.BackupEncodings[streamInfo.NumBackupRequestWithoutAds[0] >= 3 ? 0 : 1]; + streamInfo.SwappedEncodingsTime = Date.now(); + streamInfo.IsLowResNoAds = false; + postMessage({key:'UboReloadPlayer'}); + } + if (haveAdTags) { + console.log('Double dipping ads?'); + return ''; + } + } else if (haveAdTags) { + console.log('Found ads, switch to low res'); + streamInfo.IsLowResNoAds = true; + streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + postMessage({key:'UboReloadPlayer'}); + return ''; + } else { + postMessage({key:'UboHideAdBanner'}); + } + return textStr; + } + function hookWorkerFetch() { + console.log('hookWorkerFetch'); + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + url = url.trimEnd(); + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + var str = await processM3U8(url, await response.text(), realFetch); + resolve(new Response(str)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + console.log('fetch hook err ' + err); + reject(err); + }); + }; + send(); + }); + } + else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (CurrentChannelNameFromM3U8 != channelName) { + postMessage({ + key: 'UboChannelNameM3U8Changed', + value: channelName + }); + } + CurrentChannelNameFromM3U8 = channelName; + if (OPT_MODE_STRIP_AD_SEGMENTS) { + return new Promise(async function(resolve, reject) { + // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). + // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. + var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; + var attempts = 0; + while(true) { + var encodingsM3u8Response = await realFetch(url, options); + if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { + var encodingsM3u8 = await encodingsM3u8Response.text(); + var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; + var streamM3u8Response = await realFetch(streamM3u8Url); + var streamM3u8 = await streamM3u8Response.text(); + if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { + if (maxAttempts > 1 && attempts >= maxAttempts) { + console.log('max skip ad attempts reached (attempt #' + attempts + ')'); + } + var hasSwappedEncodings = false; + var streamInfo = StreamInfos[channelName]; + if (streamInfo == null) { + StreamInfos[channelName] = streamInfo = {}; + } else if (streamInfo.SwappedEncodings != null && streamInfo.SwappedEncodingsTime >= Date.now() - 10000) { + encodingsM3u8 = streamInfo.SwappedEncodings; + hasSwappedEncodings = true; + } + var forcedLowRes = false; + var existingStreamInfo = StreamInfos[channelName]; + if (existingStreamInfo.IsLowResNoAds || (!hasSwappedEncodings && streamM3u8.includes(AD_SIGNIFIER))) { + var accessTokenResponse = await getAccessToken(channelName, OPT_BACKUP_PLAYER_TYPE); + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8' + (new URL(url)).search); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + var lowResEncodingsM3u8Response = await realFetch(urlInfo.href); + if (lowResEncodingsM3u8Response != null && lowResEncodingsM3u8Response.status === 200) { + var lowResEncodingsM3u8 = await lowResEncodingsM3u8Response.text(); + var lowResLines = lowResEncodingsM3u8.replace('\r', '').split('\n'); + var lowResBestUrl = null; + for (var i = 0; i < lowResLines.length; i++) { + if (lowResLines[i].startsWith('#EXT-X-STREAM-INF')) { + var res = parseAttributes(lowResLines[i])['RESOLUTION']; + if (res && lowResLines[i + 1].endsWith('.m3u8')) { + // Assumes resolutions are correctly ordered + lowResBestUrl = lowResLines[i + 1]; + break; + } + } + } + if (lowResBestUrl != null) { + var normalEncodingsM3u8 = encodingsM3u8; + var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n'); + for (var i = 0; i < normalLines.length - 1; i++) { + if (normalLines[i].startsWith('#EXT-X-STREAM-INF')) { + var res = parseAttributes(normalLines[i])['RESOLUTION']; + if (res) { + lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique + normalLines[i + 1] = lowResBestUrl; + } + } + } + encodingsM3u8 = normalLines.join('\r\n'); + } else { + encodingsM3u8 = lowResEncodingsM3u8; + } + forcedLowRes = true; + postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); + } + } + } + // This might potentially backfire... maybe just add the new urls + streamInfo.ChannelName = channelName; + streamInfo.RootM3U8Url = url; + streamInfo.RootM3U8Params = (new URL(url)).search; + streamInfo.BackupUrl = null; + streamInfo.BackupEncodings = [null,null]; + streamInfo.IsRequestingBackup = false; + streamInfo.NumBackupRequestWithoutAds = [0,0]; + streamInfo.LastBackupRequestWithoutAds = [0,0]; + streamInfo.SwappedEncodings = null; + streamInfo.SwappedEncodingsTime = 0; + streamInfo.IsMidroll = !!streamInfo.IsMidroll; + streamInfo.IsLowResNoAds = forcedLowRes; + var lines = encodingsM3u8.replace('\r', '').split('\n'); + for (var i = 0; i < lines.length; i++) { + if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { + StreamInfosByUrl[lines[i].trimEnd()] = streamInfo; + } + } + resolve(new Response(encodingsM3u8)); + break; + } + console.log('attempt to skip ad (attempt #' + attempts + ')'); + } else { + // Stream is offline? + resolve(encodingsM3u8Response); + break; + } + } + }); + } + } + } + return realFetch.apply(this, arguments); + } + } + function makeGraphQlPacket(event, radToken, payload) { + return [{ + operationName: 'ClientSideAdEventHandling_RecordAdEvent', + variables: { + input: { + eventName: event, + eventPayload: JSON.stringify(payload), + radToken, + }, + }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', + }, + }, + }]; + } + function getAccessToken(channelName, playerType, realFetch) { + var body = null; + var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; + body = { + operationName: 'PlaybackAccessToken_Template', + query: templateQuery, + variables: { + 'isLive': true, + 'login': channelName, + 'isVod': false, + 'vodID': '', + 'playerType': playerType + } + }; + return gqlRequest(body, realFetch); + } + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + return fetchFunc('https://gql.twitch.tv/gql', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'client-id': CLIENT_ID, + 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id + } + }); + } + function parseAttributes(str) { + return Object.fromEntries( + str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) + .filter(Boolean) + .map(x => { + const idx = x.indexOf('='); + const key = x.substring(0, idx); + const value = x.substring(idx +1); + const num = Number(value); + return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] + })); + } + async function tryNotifyAdsWatchedM3U8(streamM3u8) { + try { + //console.log(streamM3u8); + if (!streamM3u8 || !streamM3u8.includes(AD_SIGNIFIER)) { + return 1; + } + var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); + if (matches.length > 1) { + const attrString = matches[1]; + const attr = parseAttributes(attrString); + var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); + var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); + var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; + var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; + var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; + var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; + var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; + var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); + const baseData = { + stitched: true, + roll_type: rollType, + player_mute: false, + player_volume: 0.5, + visible: true, + }; + for (let podPosition = 0; podPosition < podLength; podPosition++) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { + // This is all that's actually required at the moment + await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); + } else { + const extendedData = { + ...baseData, + ad_id: adId, + ad_position: podPosition, + duration: 30, + creative_id: creativeId, + total_ads: podLength, + order_id: orderId, + line_item_id: lineItemId, + }; + await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); + for (let quartile = 0; quartile < 4; quartile++) { + await gqlRequest( + makeGraphQlPacket('video_ad_quartile_complete', radToken, { + ...extendedData, + quartile: quartile + 1, + }) + ); + } + await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); + } + } + } + return 0; + } catch (err) { + console.log(err); + return 0; + } + } + function hookFetch() { + var realFetch = window.fetch; + window.fetch = function(url, init, ...args) { + if (typeof url === 'string') { + if (url.includes('gql')) { + var deviceId = init.headers['X-Device-Id']; + if (typeof deviceId !== 'string') { + deviceId = init.headers['Device-ID']; + } + if (typeof deviceId === 'string') { + gql_device_id = deviceId; + } + if (gql_device_id && twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UboUpdateDeviceId', + value: gql_device_id + }); + } + if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { + const newBody = JSON.parse(init.body); + newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; + init.body = JSON.stringify(newBody); + } + if (OPT_ROLLING_DEVICE_ID) { + if (typeof init.headers['X-Device-Id'] === 'string') { + init.headers['X-Device-Id'] = gql_device_id_rolling; + } + if (typeof init.headers['Device-ID'] === 'string') { + init.headers['Device-ID'] = gql_device_id_rolling; + } + } + } + } + } + return realFetch.apply(this, arguments); + }; + } + function reloadTwitchPlayer(isSeek, isPausePlay) { + // Taken from ttv-tools / ffz + // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts + // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx + function findReactNode(root, constraint) { + if (root.stateNode && constraint(root.stateNode)) { + return root.stateNode; + } + let node = root.child; + while (node) { + const result = findReactNode(node, constraint); + if (result) { + return result; + } + node = node.sibling; + } + return null; + } + var reactRootNode = null; + var rootNode = document.querySelector('#root'); + if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { + reactRootNode = rootNode._reactRootContainer._internalRoot.current; + } + if (!reactRootNode) { + console.log('Could not find react root'); + return; + } + var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); + player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; + var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + if (!player) { + console.log('Could not find player'); + return; + } + if (!playerState) { + console.log('Could not find player state'); + return; + } + if (player.paused) { + return; + } + if (isSeek) { + console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered())); + var pos = player.getPosition(); + player.seekTo(0); + player.seekTo(pos); + return; + } + if (isPausePlay) { + player.pause(); + player.play(); + return; + } + const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); + if (sink && sink.video && sink.video._ffz_compressor) { + const video = sink.video; + const volume = video.volume ? video.volume : player.getVolume(); + const muted = player.isMuted(); + const newVideo = document.createElement('video'); + newVideo.volume = muted ? 0 : volume; + newVideo.playsInline = true; + video.replaceWith(newVideo); + player.attachHTMLVideoElement(newVideo); + setImmediate(() => { + player.setVolume(volume); + player.setMuted(muted); + }); + } + playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false + } + window.reloadTwitchPlayer = reloadTwitchPlayer; + hookFetch(); + function onContentLoaded() { + // This stops Twitch from pausing the player when in another tab and an ad shows. + // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 + try { + Object.defineProperty(document, 'visibilityState', { + get() { + return 'visible'; + } + }); + }catch{} + try { + Object.defineProperty(document, 'hidden', { + get() { + return false; + } + }); + }catch{} + var block = e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + document.addEventListener('visibilitychange', block, true); + document.addEventListener('webkitvisibilitychange', block, true); + document.addEventListener('mozvisibilitychange', block, true); + document.addEventListener('hasFocus', block, true); + try { + if (/Firefox/.test(navigator.userAgent)) { + Object.defineProperty(document, 'mozHidden', { + get() { + return false; + } + }); + } else { + Object.defineProperty(document, 'webkitHidden', { + get() { + return false; + } + }); + } + }catch{} + // Hooks for preserving volume / resolution + var keysToCache = [ + 'video-quality', + 'video-muted', + 'volume', + 'lowLatencyModeEnabled',// Low Latency + 'persistenceEnabled',// Mini Player + ]; + var cachedValues = new Map(); + for (var i = 0; i < keysToCache.length; i++) { + cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); + } + var realSetItem = localStorage.setItem; + localStorage.setItem = function(key, value) { + if (cachedValues.has(key)) { + cachedValues.set(key, value); + } + realSetItem.apply(this, arguments); + }; + var realGetItem = localStorage.getItem; + localStorage.getItem = function(key) { + if (cachedValues.has(key)) { + return cachedValues.get(key); + } + return realGetItem.apply(this, arguments); + }; + } + if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { + onContentLoaded(); + } else { + window.addEventListener("DOMContentLoaded", function() { + onContentLoaded(); + }); + } +})(); diff --git a/proxy/README.md b/proxy/README.md deleted file mode 100644 index 2b3c304..0000000 --- a/proxy/README.md +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index dd50edb..0000000 --- a/proxy/extension/README.md +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 0c79c72..0000000 --- a/proxy/extension/background.js +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index be15e47..0000000 --- a/proxy/extension/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index c1cf5de..0000000 --- a/proxy/proxy-server-build.bat +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 7452c57..0000000 --- a/proxy/proxy-server-info.txt +++ /dev/null @@ -1,31 +0,0 @@ - - - - - -- 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 deleted file mode 100644 index b3fe981..0000000 --- a/proxy/proxy-server.cs +++ /dev/null @@ -1,1651 +0,0 @@ -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 deleted file mode 100644 index 720183f6d09e0fd340b1cada4d33f95aa3d2eb2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34816 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 O-r@Y;zyJSf;C}(kZxb~D diff --git a/vaft/vaft-ublock-origin.js b/vaft/vaft-ublock-origin.js new file mode 100644 index 0000000..9e7bcd2 --- /dev/null +++ b/vaft/vaft-ublock-origin.js @@ -0,0 +1,727 @@ +// This code is directly copied from https://github.com/cleanlock/VideoAdBlockForTwitch (only change is whitespace is removed for the ublock origin script - also indented) +twitch-videoad.js application/javascript +(function() { + if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } + //This stops Twitch from pausing the player when in another tab and an ad shows. + try { + Object.defineProperty(document, 'visibilityState', { + get() { + return 'visible'; + } + }); + Object.defineProperty(document, 'hidden', { + get() { + return false; + } + }); + const block = e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + const process = e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + //This corrects the background tab buffer bug when switching to the background tab for the first time after an extended period. + doTwitchPlayerTask(false, false, true, false, false); + }; + document.addEventListener('visibilitychange', process, true); + document.addEventListener('webkitvisibilitychange', block, true); + document.addEventListener('mozvisibilitychange', block, true); + document.addEventListener('hasFocus', block, true); + if (/Firefox/.test(navigator.userAgent)) { + Object.defineProperty(document, 'mozHidden', { + get() { + return false; + } + }); + } else { + Object.defineProperty(document, 'webkitHidden', { + get() { + return false; + } + }); + } + } catch (err) {} + //Send settings updates to worker. + window.addEventListener("message", (event) => { + if (event.source != window) + return; + if (event.data.type && (event.data.type == "SetHideBlockingMessage")) { + if (twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'SetHideBlockingMessage', + value: event.data.value + }); + } + } + }, false); + function declareOptions(scope) { + scope.AdSignifier = 'stitched'; + scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + scope.ClientVersion = 'null'; + scope.ClientSession = 'null'; + scope.PlayerType1 = 'site'; //Source + scope.PlayerType2 = 'thunderdome'; //480p + scope.PlayerType3 = 'pop_tart'; //480p + scope.PlayerType4 = 'picture-by-picture'; //360p + scope.CurrentChannelName = null; + scope.UsherParams = null; + scope.WasShowingAd = false; + scope.GQLDeviceID = null; + scope.HideBlockingMessage = false; + scope.IsSquadStream = false; + } + declareOptions(window); + var twitchMainWorker = null; + var adBlockDiv = null; + var OriginalVideoPlayerQuality = null; + var IsPlayerAutoQuality = 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 = ` + ${getNewUsher.toString()} + ${processM3U8.toString()} + ${hookWorkerFetch.toString()} + ${declareOptions.toString()} + ${getAccessToken.toString()} + ${gqlRequest.toString()} + ${adRecordgqlPacket.toString()} + ${tryNotifyTwitch.toString()} + ${parseAttributes.toString()} + declareOptions(self); + self.addEventListener('message', function(e) { + if (e.data.key == 'UpdateIsSquadStream') { + IsSquadStream = e.data.value; + } else if (e.data.key == 'UpdateClientVersion') { + ClientVersion = e.data.value; + } else if (e.data.key == 'UpdateClientSession') { + ClientSession = e.data.value; + } else if (e.data.key == 'UpdateClientId') { + ClientID = e.data.value; + } else if (e.data.key == 'UpdateDeviceId') { + GQLDeviceID = e.data.value; + } else if (e.data.key == 'SetHideBlockingMessage') { + if (e.data.value == "true") { + HideBlockingMessage = true; + } else if (e.data.value == "false") { + HideBlockingMessage = false; + } + } + }); + hookWorkerFetch(); + importScripts('${jsURL}'); + `; + super(URL.createObjectURL(new Blob([newBlobStr]))); + twitchMainWorker = this; + this.onmessage = function(e) { + if (e.data.key == 'ShowAdBlockBanner') { + if (adBlockDiv == null) { + adBlockDiv = getAdBlockDiv(); + } + adBlockDiv.P.textContent = 'Blocking ads...'; + adBlockDiv.style.display = 'block'; + } else if (e.data.key == 'HideAdBlockBanner') { + if (adBlockDiv == null) { + adBlockDiv = getAdBlockDiv(); + } + adBlockDiv.style.display = 'none'; + } else if (e.data.key == 'PauseResumePlayer') { + doTwitchPlayerTask(true, false, false, false, false); + } else if (e.data.key == 'ForceChangeQuality') { + //This is used to fix the bug where the video would freeze. + try { + var autoQuality = doTwitchPlayerTask(false, false, false, true, false); + var currentQuality = doTwitchPlayerTask(false, true, false, false, false); + if (IsPlayerAutoQuality == null) { + IsPlayerAutoQuality = autoQuality; + } + if (OriginalVideoPlayerQuality == null) { + OriginalVideoPlayerQuality = currentQuality; + } + if (!currentQuality.includes('480') || e.data.value != null) { + if (!OriginalVideoPlayerQuality.includes('480')) { + var settingsMenu = document.querySelector('div[data-a-target="player-settings-menu"]'); + if (settingsMenu == null) { + var settingsCog = document.querySelector('button[data-a-target="player-settings-button"]'); + if (settingsCog) { + settingsCog.click(); + var qualityMenu = document.querySelector('button[data-a-target="player-settings-menu-item-quality"]'); + if (qualityMenu) { + qualityMenu.click(); + } + var lowQuality = document.querySelectorAll('input[data-a-target="tw-radio"'); + if (lowQuality) { + var qualityToSelect = lowQuality.length - 3; + if (e.data.value != null) { + if (e.data.value.includes('original')) { + e.data.value = OriginalVideoPlayerQuality; + if (IsPlayerAutoQuality) { + e.data.value = 'auto'; + } + } + if (e.data.value.includes('160p')) { + qualityToSelect = 5; + } + if (e.data.value.includes('360p')) { + qualityToSelect = 4; + } + if (e.data.value.includes('480p')) { + qualityToSelect = 3; + } + if (e.data.value.includes('720p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('822p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('864p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('900p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('936p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('960p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('1080p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('source')) { + qualityToSelect = 1; + } + if (e.data.value.includes('auto')) { + qualityToSelect = 0; + } + } + lowQuality[qualityToSelect].click(); + var originalQuality = JSON.parse(OriginalVideoPlayerQuality); + window.localStorage.setItem('video-quality', '{"default":"'+originalQuality.group+'"}'); + if (e.data.value != null) { + OriginalVideoPlayerQuality = null; + IsPlayerAutoQuality = null; + doTwitchPlayerTask(false, false, false, true, true); + } + } + } + } + } + } + } catch (err) { + OriginalVideoPlayerQuality = null; + IsPlayerAutoQuality = null; + } + } + }; + function getAdBlockDiv() { + //To display a notification to the user, that an ad is being blocked. + var playerRootDiv = document.querySelector('.video-player'); + var adBlockDiv = null; + if (playerRootDiv != null) { + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + adBlockDiv.style.display = 'none'; + adBlockDiv.P = adBlockDiv.querySelector('p'); + playerRootDiv.appendChild(adBlockDiv); + } + } + return adBlockDiv; + } + } + }; + function getWasmWorkerUrl(twitchBlobUrl) { + var req = new XMLHttpRequest(); + req.open('GET', twitchBlobUrl, false); + req.send(); + return req.responseText.split("'")[1]; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.includes('video-weaver')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + //Here we check the m3u8 for any ads and also try fallback player types if needed. + var responseText = await response.text(); + var weaverText = null; + weaverText = await processM3U8(url, responseText, realFetch, PlayerType2); + if (weaverText.includes(AdSignifier)) { + weaverText = await processM3U8(url, responseText, realFetch, PlayerType3); + } + if (weaverText.includes(AdSignifier)) { + weaverText = await processM3U8(url, responseText, realFetch, PlayerType4); + } + resolve(new Response(weaverText)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } else if (url.includes('/api/channel/hls/')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + UsherParams = (new URL(url)).search; + CurrentChannelName = channelName; + //To prevent pause/resume loop for mid-rolls. + var isPBYPRequest = url.includes('picture-by-picture'); + if (isPBYPRequest) { + url = ''; + } + //Make new Usher request if needed to create fallback if UBlock bypass method fails. + var useNewUsher = false; + if (url.includes('subscriber%22%3Afalse') && url.includes('hide_ads%22%3Afalse') && url.includes('show_ads%22%3Atrue')) { + useNewUsher = true; + } + if (url.includes('subscriber%22%3Atrue') && url.includes('hide_ads%22%3Afalse') && url.includes('show_ads%22%3Atrue')) { + useNewUsher = true; + } + if (useNewUsher == true) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + encodingsM3u8 = await getNewUsher(realFetch, response, channelName); + if (encodingsM3u8.length > 1) { + resolve(new Response(encodingsM3u8)); + } else { + postMessage({ + key: 'HideAdBlockBanner' + }); + resolve(encodingsM3u8); + } + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } + } + } + return realFetch.apply(this, arguments); + }; + } + //Added as fallback for when UBlock method fails. + async function getNewUsher(realFetch, originalResponse, channelName) { + var accessTokenResponse = await getAccessToken(channelName, PlayerType1); + var encodingsM3u8 = ''; + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + try { + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8' + UsherParams); + 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) { + encodingsM3u8 = await encodingsM3u8Response.text(); + return encodingsM3u8; + } else { + return originalResponse; + } + } catch (err) {} + return originalResponse; + } else { + return originalResponse; + } + } + async function processM3U8(url, textStr, realFetch, playerType) { + //Checks the m3u8 for ads and if it finds one, instead returns an ad-free stream. + //Ad blocking for squad streams is disabled due to the way multiple weaver urls are used. No workaround so far. + if (IsSquadStream == true) { + return textStr; + } + if (!textStr) { + return textStr; + } + //Some live streams use mp4. + if (!textStr.includes(".ts") && !textStr.includes(".mp4")) { + return textStr; + } + var haveAdTags = textStr.includes(AdSignifier); + if (haveAdTags) { + //Reduces ad frequency. + try { + tryNotifyTwitch(textStr); + } catch (err) {} + var accessTokenResponse = await getAccessToken(CurrentChannelName, playerType); + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + try { + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + CurrentChannelName + '.m3u8' + UsherParams); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + var encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + var encodingsM3u8 = await encodingsM3u8Response.text(); + streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/mg)[0]; + var streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + var m3u8Text = await streamM3u8Response.text(); + WasShowingAd = true; + if (HideBlockingMessage == false) { + postMessage({ + key: 'ShowAdBlockBanner' + }); + } else if (HideBlockingMessage == true) { + postMessage({ + key: 'HideAdBlockBanner' + }); + } + postMessage({ + key: 'ForceChangeQuality' + }); + return m3u8Text; + } else { + return textStr; + } + } else { + return textStr; + } + } catch (err) {} + return textStr; + } else { + return textStr; + } + } else { + if (WasShowingAd) { + WasShowingAd = false; + //Here we put player back to original quality and remove the blocking message. + postMessage({ + key: 'ForceChangeQuality', + value: 'original' + }); + postMessage({ + key: 'PauseResumePlayer' + }); + postMessage({ + key: 'HideAdBlockBanner' + }); + } + return textStr; + } + return textStr; + } + 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 tryNotifyTwitch(streamM3u8) { + //We notify that an ad was requested but was not visible and was also muted. + 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: true, + player_volume: 0.0, + visible: false, + }; + for (let podPosition = 0; podPosition < podLength; podPosition++) { + const extendedData = { + ...baseData, + ad_id: adId, + ad_position: podPosition, + duration: 0, + creative_id: creativeId, + total_ads: podLength, + order_id: orderId, + line_item_id: lineItemId, + }; + await gqlRequest(adRecordgqlPacket('video_ad_impression', radToken, extendedData)); + for (let quartile = 0; quartile < 4; quartile++) { + await gqlRequest( + adRecordgqlPacket('video_ad_quartile_complete', radToken, { + ...extendedData, + quartile: quartile + 1, + }) + ); + } + await gqlRequest(adRecordgqlPacket('video_ad_pod_complete', radToken, baseData)); + } + } + } + function adRecordgqlPacket(event, radToken, payload) { + return [{ + operationName: 'ClientSideAdEventHandling_RecordAdEvent', + variables: { + input: { + eventName: event, + eventPayload: JSON.stringify(payload), + radToken, + }, + }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', + }, + }, + }]; + } + function getAccessToken(channelName, playerType, realFetch) { + var body = null; + var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; + body = { + operationName: 'PlaybackAccessToken_Template', + query: templateQuery, + variables: { + 'isLive': true, + 'login': channelName, + 'isVod': false, + 'vodID': '', + 'playerType': playerType + } + }; + return gqlRequest(body, realFetch); + } + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + if (!GQLDeviceID) { + var dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + var dcharactersLength = dcharacters.length; + for (var i = 0; i < 32; i++) { + GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength)); + } + } + return fetchFunc('https://gql.twitch.tv/gql', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Client-ID': ClientID, + 'Device-ID': GQLDeviceID, + 'X-Device-Id': GQLDeviceID, + 'Client-Version': ClientVersion, + 'Client-Session-Id': ClientSession + } + }); + } + function doTwitchPlayerTask(isPausePlay, isCheckQuality, isCorrectBuffer, isAutoQuality, setAutoQuality) { + //This will do an instant pause/play to return to original quality once the ad is finished. + //We also use this function to get the current video player quality set by the user. + //We also use this function to quickly pause/play the player when switching tabs to stop delays. + try { + var videoController = null; + var videoPlayer = null; + 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; + } + videoPlayer = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); + videoPlayer = videoPlayer && videoPlayer.props && videoPlayer.props.mediaPlayerInstance ? videoPlayer.props.mediaPlayerInstance : null; + + if (isPausePlay) { + videoPlayer.pause(); + videoPlayer.play(); + return; + } + if (isCheckQuality) { + if (typeof videoPlayer.getQuality() == 'undefined') { + return; + } + var playerQuality = JSON.stringify(videoPlayer.getQuality()); + if (playerQuality) { + return playerQuality; + } else { + return; + } + } + if (isAutoQuality) { + if (typeof videoPlayer.isAutoQualityMode() == 'undefined') { + return false; + } + var autoQuality = videoPlayer.isAutoQualityMode(); + if (autoQuality) { + videoPlayer.setAutoQualityMode(false); + return autoQuality; + } else { + return false; + } + } + if (setAutoQuality) { + videoPlayer.setAutoQualityMode(true); + return; + } + //This only happens when switching tabs and is to correct the high latency caused when opening background tabs and going to them at a later time. + //We check that this is a live stream by the page URL, to prevent vod/clip pause/plays. + try { + var currentPageURL = document.URL; + var isLive = true; + if (currentPageURL.includes('videos/') || currentPageURL.includes('clip/')) { + isLive = false; + } + if (isCorrectBuffer && isLive) { + //A timer is needed due to the player not resuming without it. + setTimeout(function() { + //If latency to broadcaster is above 5 or 15 seconds upon switching tabs, we pause and play the player to reset the latency. + //If latency is between 0-6, user can manually pause and resume to reset latency further. + if (videoPlayer.isLiveLowLatency() && videoPlayer.getLiveLatency() > 5) { + videoPlayer.pause(); + videoPlayer.play(); + } else if (videoPlayer.getLiveLatency() > 15) { + videoPlayer.pause(); + videoPlayer.play(); + } + }, 3000); + } + } catch (err) {} + } catch (err) {} + } + var localDeviceID = null; + localDeviceID = window.localStorage.getItem('local_copy_unique_id'); + function hookFetch() { + var realFetch = window.fetch; + window.fetch = function(url, init, ...args) { + if (typeof url === 'string') { + //Check if squad stream. + if (window.location.pathname.includes('/squad')) { + if (twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateIsSquadStream', + value: true + }); + } + } else { + if (twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateIsSquadStream', + value: false + }); + } + } + if (url.includes('/access_token') || url.includes('gql')) { + //Device ID is used when notifying Twitch of ads. + var deviceId = init.headers['X-Device-Id']; + if (typeof deviceId !== 'string') { + deviceId = init.headers['Device-ID']; + } + //Added to prevent eventual UBlock conflicts. + if (typeof deviceId === 'string' && !deviceId.includes('twitch-web-wall-mason')) { + GQLDeviceID = deviceId; + } else if (localDeviceID) { + GQLDeviceID = localDeviceID.replace('"', ''); + GQLDeviceID = GQLDeviceID.replace('"', ''); + } + if (GQLDeviceID && twitchMainWorker) { + if (typeof init.headers['X-Device-Id'] === 'string') { + init.headers['X-Device-Id'] = GQLDeviceID; + } + if (typeof init.headers['Device-ID'] === 'string') { + init.headers['Device-ID'] = GQLDeviceID; + } + twitchMainWorker.postMessage({ + key: 'UpdateDeviceId', + value: GQLDeviceID + }); + } + //Client version is used in GQL requests. + var clientVersion = init.headers['Client-Version']; + if (clientVersion && typeof clientVersion == 'string') { + ClientVersion = clientVersion; + } + if (ClientVersion && twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateClientVersion', + value: ClientVersion + }); + } + //Client session is used in GQL requests. + var clientSession = init.headers['Client-Session-Id']; + if (clientSession && typeof clientSession == 'string') { + ClientSession = clientSession; + } + if (ClientSession && twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateClientSession', + value: ClientSession + }); + } + //Client ID is used in GQL requests. + if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + var clientId = init.headers['Client-ID']; + if (clientId && typeof clientId == 'string') { + ClientID = clientId; + } else { + clientId = init.headers['Client-Id']; + if (clientId && typeof clientId == 'string') { + ClientID = clientId; + } + } + if (ClientID && twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateClientId', + value: ClientID + }); + } + } + //To prevent pause/resume loop for mid-rolls. + if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && init.body.includes('picture-by-picture')) { + init.body = ''; + } + var isPBYPRequest = url.includes('picture-by-picture'); + if (isPBYPRequest) { + url = ''; + } + } + } + return realFetch.apply(this, arguments); + }; + } + hookFetch(); +})(); \ No newline at end of file diff --git a/vaft/vaft.user.js b/vaft/vaft.user.js new file mode 100644 index 0000000..6cdbcfa --- /dev/null +++ b/vaft/vaft.user.js @@ -0,0 +1,738 @@ +// ==UserScript== +// @name TwitchAdSolutions (vaft) +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 5.3.5 +// @description Multiple solutions for blocking Twitch ads (vaft) +// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js +// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js +// @author https://github.com/cleanlock/VideoAdBlockForTwitch#credits +// @match *://*.twitch.tv/* +// @run-at document-start +// @grant none +// ==/UserScript== +// This code is directly copied from https://github.com/cleanlock/VideoAdBlockForTwitch (only change is whitespace is removed for the ublock origin script - also indented) +(function() { + 'use strict'; + //This stops Twitch from pausing the player when in another tab and an ad shows. + try { + Object.defineProperty(document, 'visibilityState', { + get() { + return 'visible'; + } + }); + Object.defineProperty(document, 'hidden', { + get() { + return false; + } + }); + const block = e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + const process = e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + //This corrects the background tab buffer bug when switching to the background tab for the first time after an extended period. + doTwitchPlayerTask(false, false, true, false, false); + }; + document.addEventListener('visibilitychange', process, true); + document.addEventListener('webkitvisibilitychange', block, true); + document.addEventListener('mozvisibilitychange', block, true); + document.addEventListener('hasFocus', block, true); + if (/Firefox/.test(navigator.userAgent)) { + Object.defineProperty(document, 'mozHidden', { + get() { + return false; + } + }); + } else { + Object.defineProperty(document, 'webkitHidden', { + get() { + return false; + } + }); + } + } catch (err) {} + //Send settings updates to worker. + window.addEventListener("message", (event) => { + if (event.source != window) + return; + if (event.data.type && (event.data.type == "SetHideBlockingMessage")) { + if (twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'SetHideBlockingMessage', + value: event.data.value + }); + } + } + }, false); + function declareOptions(scope) { + scope.AdSignifier = 'stitched'; + scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + scope.ClientVersion = 'null'; + scope.ClientSession = 'null'; + scope.PlayerType1 = 'site'; //Source + scope.PlayerType2 = 'thunderdome'; //480p + scope.PlayerType3 = 'pop_tart'; //480p + scope.PlayerType4 = 'picture-by-picture'; //360p + scope.CurrentChannelName = null; + scope.UsherParams = null; + scope.WasShowingAd = false; + scope.GQLDeviceID = null; + scope.HideBlockingMessage = false; + scope.IsSquadStream = false; + } + declareOptions(window); + var twitchMainWorker = null; + var adBlockDiv = null; + var OriginalVideoPlayerQuality = null; + var IsPlayerAutoQuality = 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 = ` + ${getNewUsher.toString()} + ${processM3U8.toString()} + ${hookWorkerFetch.toString()} + ${declareOptions.toString()} + ${getAccessToken.toString()} + ${gqlRequest.toString()} + ${adRecordgqlPacket.toString()} + ${tryNotifyTwitch.toString()} + ${parseAttributes.toString()} + declareOptions(self); + self.addEventListener('message', function(e) { + if (e.data.key == 'UpdateIsSquadStream') { + IsSquadStream = e.data.value; + } else if (e.data.key == 'UpdateClientVersion') { + ClientVersion = e.data.value; + } else if (e.data.key == 'UpdateClientSession') { + ClientSession = e.data.value; + } else if (e.data.key == 'UpdateClientId') { + ClientID = e.data.value; + } else if (e.data.key == 'UpdateDeviceId') { + GQLDeviceID = e.data.value; + } else if (e.data.key == 'SetHideBlockingMessage') { + if (e.data.value == "true") { + HideBlockingMessage = true; + } else if (e.data.value == "false") { + HideBlockingMessage = false; + } + } + }); + hookWorkerFetch(); + importScripts('${jsURL}'); + `; + super(URL.createObjectURL(new Blob([newBlobStr]))); + twitchMainWorker = this; + this.onmessage = function(e) { + if (e.data.key == 'ShowAdBlockBanner') { + if (adBlockDiv == null) { + adBlockDiv = getAdBlockDiv(); + } + adBlockDiv.P.textContent = 'Blocking ads...'; + adBlockDiv.style.display = 'block'; + } else if (e.data.key == 'HideAdBlockBanner') { + if (adBlockDiv == null) { + adBlockDiv = getAdBlockDiv(); + } + adBlockDiv.style.display = 'none'; + } else if (e.data.key == 'PauseResumePlayer') { + doTwitchPlayerTask(true, false, false, false, false); + } else if (e.data.key == 'ForceChangeQuality') { + //This is used to fix the bug where the video would freeze. + try { + var autoQuality = doTwitchPlayerTask(false, false, false, true, false); + var currentQuality = doTwitchPlayerTask(false, true, false, false, false); + if (IsPlayerAutoQuality == null) { + IsPlayerAutoQuality = autoQuality; + } + if (OriginalVideoPlayerQuality == null) { + OriginalVideoPlayerQuality = currentQuality; + } + if (!currentQuality.includes('480') || e.data.value != null) { + if (!OriginalVideoPlayerQuality.includes('480')) { + var settingsMenu = document.querySelector('div[data-a-target="player-settings-menu"]'); + if (settingsMenu == null) { + var settingsCog = document.querySelector('button[data-a-target="player-settings-button"]'); + if (settingsCog) { + settingsCog.click(); + var qualityMenu = document.querySelector('button[data-a-target="player-settings-menu-item-quality"]'); + if (qualityMenu) { + qualityMenu.click(); + } + var lowQuality = document.querySelectorAll('input[data-a-target="tw-radio"'); + if (lowQuality) { + var qualityToSelect = lowQuality.length - 3; + if (e.data.value != null) { + if (e.data.value.includes('original')) { + e.data.value = OriginalVideoPlayerQuality; + if (IsPlayerAutoQuality) { + e.data.value = 'auto'; + } + } + if (e.data.value.includes('160p')) { + qualityToSelect = 5; + } + if (e.data.value.includes('360p')) { + qualityToSelect = 4; + } + if (e.data.value.includes('480p')) { + qualityToSelect = 3; + } + if (e.data.value.includes('720p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('822p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('864p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('900p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('936p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('960p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('1080p')) { + qualityToSelect = 2; + } + if (e.data.value.includes('source')) { + qualityToSelect = 1; + } + if (e.data.value.includes('auto')) { + qualityToSelect = 0; + } + } + lowQuality[qualityToSelect].click(); + var originalQuality = JSON.parse(OriginalVideoPlayerQuality); + window.localStorage.setItem('video-quality', '{"default":"'+originalQuality.group+'"}'); + if (e.data.value != null) { + OriginalVideoPlayerQuality = null; + IsPlayerAutoQuality = null; + doTwitchPlayerTask(false, false, false, true, true); + } + } + } + } + } + } + } catch (err) { + OriginalVideoPlayerQuality = null; + IsPlayerAutoQuality = null; + } + } + }; + function getAdBlockDiv() { + //To display a notification to the user, that an ad is being blocked. + var playerRootDiv = document.querySelector('.video-player'); + var adBlockDiv = null; + if (playerRootDiv != null) { + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + adBlockDiv.style.display = 'none'; + adBlockDiv.P = adBlockDiv.querySelector('p'); + playerRootDiv.appendChild(adBlockDiv); + } + } + return adBlockDiv; + } + } + }; + function getWasmWorkerUrl(twitchBlobUrl) { + var req = new XMLHttpRequest(); + req.open('GET', twitchBlobUrl, false); + req.send(); + return req.responseText.split("'")[1]; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.includes('video-weaver')) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + //Here we check the m3u8 for any ads and also try fallback player types if needed. + var responseText = await response.text(); + var weaverText = null; + weaverText = await processM3U8(url, responseText, realFetch, PlayerType2); + if (weaverText.includes(AdSignifier)) { + weaverText = await processM3U8(url, responseText, realFetch, PlayerType3); + } + if (weaverText.includes(AdSignifier)) { + weaverText = await processM3U8(url, responseText, realFetch, PlayerType4); + } + resolve(new Response(weaverText)); + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } else if (url.includes('/api/channel/hls/')) { + var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + UsherParams = (new URL(url)).search; + CurrentChannelName = channelName; + //To prevent pause/resume loop for mid-rolls. + var isPBYPRequest = url.includes('picture-by-picture'); + if (isPBYPRequest) { + url = ''; + } + //Make new Usher request if needed to create fallback if UBlock bypass method fails. + var useNewUsher = false; + if (url.includes('subscriber%22%3Afalse') && url.includes('hide_ads%22%3Afalse') && url.includes('show_ads%22%3Atrue')) { + useNewUsher = true; + } + if (url.includes('subscriber%22%3Atrue') && url.includes('hide_ads%22%3Afalse') && url.includes('show_ads%22%3Atrue')) { + useNewUsher = true; + } + if (useNewUsher == true) { + return new Promise(function(resolve, reject) { + var processAfter = async function(response) { + encodingsM3u8 = await getNewUsher(realFetch, response, channelName); + if (encodingsM3u8.length > 1) { + resolve(new Response(encodingsM3u8)); + } else { + postMessage({ + key: 'HideAdBlockBanner' + }); + resolve(encodingsM3u8); + } + }; + var send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } + } + } + return realFetch.apply(this, arguments); + }; + } + //Added as fallback for when UBlock method fails. + async function getNewUsher(realFetch, originalResponse, channelName) { + var accessTokenResponse = await getAccessToken(channelName, PlayerType1); + var encodingsM3u8 = ''; + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + try { + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8' + UsherParams); + 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) { + encodingsM3u8 = await encodingsM3u8Response.text(); + return encodingsM3u8; + } else { + return originalResponse; + } + } catch (err) {} + return originalResponse; + } else { + return originalResponse; + } + } + async function processM3U8(url, textStr, realFetch, playerType) { + //Checks the m3u8 for ads and if it finds one, instead returns an ad-free stream. + //Ad blocking for squad streams is disabled due to the way multiple weaver urls are used. No workaround so far. + if (IsSquadStream == true) { + return textStr; + } + if (!textStr) { + return textStr; + } + //Some live streams use mp4. + if (!textStr.includes(".ts") && !textStr.includes(".mp4")) { + return textStr; + } + var haveAdTags = textStr.includes(AdSignifier); + if (haveAdTags) { + //Reduces ad frequency. + try { + tryNotifyTwitch(textStr); + } catch (err) {} + var accessTokenResponse = await getAccessToken(CurrentChannelName, playerType); + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + try { + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + CurrentChannelName + '.m3u8' + UsherParams); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + var encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + var encodingsM3u8 = await encodingsM3u8Response.text(); + streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/mg)[0]; + var streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + var m3u8Text = await streamM3u8Response.text(); + WasShowingAd = true; + if (HideBlockingMessage == false) { + postMessage({ + key: 'ShowAdBlockBanner' + }); + } else if (HideBlockingMessage == true) { + postMessage({ + key: 'HideAdBlockBanner' + }); + } + postMessage({ + key: 'ForceChangeQuality' + }); + return m3u8Text; + } else { + return textStr; + } + } else { + return textStr; + } + } catch (err) {} + return textStr; + } else { + return textStr; + } + } else { + if (WasShowingAd) { + WasShowingAd = false; + //Here we put player back to original quality and remove the blocking message. + postMessage({ + key: 'ForceChangeQuality', + value: 'original' + }); + postMessage({ + key: 'PauseResumePlayer' + }); + postMessage({ + key: 'HideAdBlockBanner' + }); + } + return textStr; + } + return textStr; + } + 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 tryNotifyTwitch(streamM3u8) { + //We notify that an ad was requested but was not visible and was also muted. + 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: true, + player_volume: 0.0, + visible: false, + }; + for (let podPosition = 0; podPosition < podLength; podPosition++) { + const extendedData = { + ...baseData, + ad_id: adId, + ad_position: podPosition, + duration: 0, + creative_id: creativeId, + total_ads: podLength, + order_id: orderId, + line_item_id: lineItemId, + }; + await gqlRequest(adRecordgqlPacket('video_ad_impression', radToken, extendedData)); + for (let quartile = 0; quartile < 4; quartile++) { + await gqlRequest( + adRecordgqlPacket('video_ad_quartile_complete', radToken, { + ...extendedData, + quartile: quartile + 1, + }) + ); + } + await gqlRequest(adRecordgqlPacket('video_ad_pod_complete', radToken, baseData)); + } + } + } + function adRecordgqlPacket(event, radToken, payload) { + return [{ + operationName: 'ClientSideAdEventHandling_RecordAdEvent', + variables: { + input: { + eventName: event, + eventPayload: JSON.stringify(payload), + radToken, + }, + }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', + }, + }, + }]; + } + function getAccessToken(channelName, playerType, realFetch) { + var body = null; + var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; + body = { + operationName: 'PlaybackAccessToken_Template', + query: templateQuery, + variables: { + 'isLive': true, + 'login': channelName, + 'isVod': false, + 'vodID': '', + 'playerType': playerType + } + }; + return gqlRequest(body, realFetch); + } + function gqlRequest(body, realFetch) { + var fetchFunc = realFetch ? realFetch : fetch; + if (!GQLDeviceID) { + var dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + var dcharactersLength = dcharacters.length; + for (var i = 0; i < 32; i++) { + GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength)); + } + } + return fetchFunc('https://gql.twitch.tv/gql', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Client-ID': ClientID, + 'Device-ID': GQLDeviceID, + 'X-Device-Id': GQLDeviceID, + 'Client-Version': ClientVersion, + 'Client-Session-Id': ClientSession + } + }); + } + function doTwitchPlayerTask(isPausePlay, isCheckQuality, isCorrectBuffer, isAutoQuality, setAutoQuality) { + //This will do an instant pause/play to return to original quality once the ad is finished. + //We also use this function to get the current video player quality set by the user. + //We also use this function to quickly pause/play the player when switching tabs to stop delays. + try { + var videoController = null; + var videoPlayer = null; + 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; + } + videoPlayer = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); + videoPlayer = videoPlayer && videoPlayer.props && videoPlayer.props.mediaPlayerInstance ? videoPlayer.props.mediaPlayerInstance : null; + + if (isPausePlay) { + videoPlayer.pause(); + videoPlayer.play(); + return; + } + if (isCheckQuality) { + if (typeof videoPlayer.getQuality() == 'undefined') { + return; + } + var playerQuality = JSON.stringify(videoPlayer.getQuality()); + if (playerQuality) { + return playerQuality; + } else { + return; + } + } + if (isAutoQuality) { + if (typeof videoPlayer.isAutoQualityMode() == 'undefined') { + return false; + } + var autoQuality = videoPlayer.isAutoQualityMode(); + if (autoQuality) { + videoPlayer.setAutoQualityMode(false); + return autoQuality; + } else { + return false; + } + } + if (setAutoQuality) { + videoPlayer.setAutoQualityMode(true); + return; + } + //This only happens when switching tabs and is to correct the high latency caused when opening background tabs and going to them at a later time. + //We check that this is a live stream by the page URL, to prevent vod/clip pause/plays. + try { + var currentPageURL = document.URL; + var isLive = true; + if (currentPageURL.includes('videos/') || currentPageURL.includes('clip/')) { + isLive = false; + } + if (isCorrectBuffer && isLive) { + //A timer is needed due to the player not resuming without it. + setTimeout(function() { + //If latency to broadcaster is above 5 or 15 seconds upon switching tabs, we pause and play the player to reset the latency. + //If latency is between 0-6, user can manually pause and resume to reset latency further. + if (videoPlayer.isLiveLowLatency() && videoPlayer.getLiveLatency() > 5) { + videoPlayer.pause(); + videoPlayer.play(); + } else if (videoPlayer.getLiveLatency() > 15) { + videoPlayer.pause(); + videoPlayer.play(); + } + }, 3000); + } + } catch (err) {} + } catch (err) {} + } + var localDeviceID = null; + localDeviceID = window.localStorage.getItem('local_copy_unique_id'); + function hookFetch() { + var realFetch = window.fetch; + window.fetch = function(url, init, ...args) { + if (typeof url === 'string') { + //Check if squad stream. + if (window.location.pathname.includes('/squad')) { + if (twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateIsSquadStream', + value: true + }); + } + } else { + if (twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateIsSquadStream', + value: false + }); + } + } + if (url.includes('/access_token') || url.includes('gql')) { + //Device ID is used when notifying Twitch of ads. + var deviceId = init.headers['X-Device-Id']; + if (typeof deviceId !== 'string') { + deviceId = init.headers['Device-ID']; + } + //Added to prevent eventual UBlock conflicts. + if (typeof deviceId === 'string' && !deviceId.includes('twitch-web-wall-mason')) { + GQLDeviceID = deviceId; + } else if (localDeviceID) { + GQLDeviceID = localDeviceID.replace('"', ''); + GQLDeviceID = GQLDeviceID.replace('"', ''); + } + if (GQLDeviceID && twitchMainWorker) { + if (typeof init.headers['X-Device-Id'] === 'string') { + init.headers['X-Device-Id'] = GQLDeviceID; + } + if (typeof init.headers['Device-ID'] === 'string') { + init.headers['Device-ID'] = GQLDeviceID; + } + twitchMainWorker.postMessage({ + key: 'UpdateDeviceId', + value: GQLDeviceID + }); + } + //Client version is used in GQL requests. + var clientVersion = init.headers['Client-Version']; + if (clientVersion && typeof clientVersion == 'string') { + ClientVersion = clientVersion; + } + if (ClientVersion && twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateClientVersion', + value: ClientVersion + }); + } + //Client session is used in GQL requests. + var clientSession = init.headers['Client-Session-Id']; + if (clientSession && typeof clientSession == 'string') { + ClientSession = clientSession; + } + if (ClientSession && twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateClientSession', + value: ClientSession + }); + } + //Client ID is used in GQL requests. + if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + var clientId = init.headers['Client-ID']; + if (clientId && typeof clientId == 'string') { + ClientID = clientId; + } else { + clientId = init.headers['Client-Id']; + if (clientId && typeof clientId == 'string') { + ClientID = clientId; + } + } + if (ClientID && twitchMainWorker) { + twitchMainWorker.postMessage({ + key: 'UpdateClientId', + value: ClientID + }); + } + } + //To prevent pause/resume loop for mid-rolls. + if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && init.body.includes('picture-by-picture')) { + init.body = ''; + } + var isPBYPRequest = url.includes('picture-by-picture'); + if (isPBYPRequest) { + url = ''; + } + } + } + return realFetch.apply(this, arguments); + }; + } + hookFetch(); +})(); \ No newline at end of file