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 720183f..0000000 Binary files a/proxy/proxy-server.exe and /dev/null differ 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