diff --git a/README.md b/README.md index bc75957..a2cba88 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ This repo aims to provide multiple solutions for blocking Twitch ads. M3U8 proxies (or full proxies / VPNs) are currently the most reliable way of avoiding ads. More proxy hosts would be ideal (see [#8](https://github.com/pixeltris/TwitchAdSolutions/issues/8)). -- *There currently aren't any public proxies. `Twitch AdBlock` was taken down on March 31 (see [#22](https://github.com/pixeltris/TwitchAdSolutions/issues/22)).* +- `TTV.LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [code](https://github.com/TTV-LOL/extensions) +- *`Twitch AdBlock` was taken down on March 31 (see [#22](https://github.com/pixeltris/TwitchAdSolutions/issues/22)).* Alternatively: @@ -48,14 +49,6 @@ Alternatively: - Currently only the initial m3u8 is proxied, so there shouldn't be any additional latency. - **Assumes the proxy server acts in good faith and maintains a good uptime.** -*The following renamed/deprecated scripts will be removed from the master branch on `1st March 2021`:* - -- `dyn` renamed to `strip` as this better reflects the functionality (strips ad segments). -- `dyn-skip` removed as it no longer works. -- `dyn-skip-midroll` renamed to `notify-reload` as the original name has lost its meaning. -- `dyn-skip-midroll-alt` renamed to `notify-strip` as the original name has lost its meaning. -- `dyn-video-swap` renamed to `video-swap`. - ## Applying a solution (uBlock Origin) - Navigate to the uBlock Origin Dashboard (the extension options) @@ -79,11 +72,12 @@ For a more detailed description of the following please refer to [this](other-so - https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707 (UserScript + FrankerFaceZ(optional)) - https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code (UserScript + FrankerFaceZ(optional)) - [Alternate Player for Twitch.tv](https://chrome.google.com/webstore/detail/bhplkbgoehhhddaoolmakpocnenplmhf) - [code](https://robwu.nl/crxviewer/?crx=bhplkbgoehhhddaoolmakpocnenplmhf&qf=player.js) (extension) +- https://github.com/TTV-LOL/extensions (extension) --- - https://github.com/streamlink/streamlink (desktop application) -- https://github.com/nopbreak/TwitchMod (android app) +- [multiChat for Twitch](https://play.google.com/store/apps/details?id=org.mchatty) (android app) - https://twitchls.com/ (external site - purple screen may display every 10-15 mins) - [Use a VPN targeting a region without ads](https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/) diff --git a/dyn-skip-midroll-alt/dyn-skip-midroll-alt-ublock-origin.js b/dyn-skip-midroll-alt/dyn-skip-midroll-alt-ublock-origin.js deleted file mode 100644 index e41e3e3..0000000 --- a/dyn-skip-midroll-alt/dyn-skip-midroll-alt-ublock-origin.js +++ /dev/null @@ -1,811 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - } - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body); - } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('/access_token')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn-skip-midroll-alt/dyn-skip-midroll-alt.cfg b/dyn-skip-midroll-alt/dyn-skip-midroll-alt.cfg deleted file mode 100644 index c8dba84..0000000 --- a/dyn-skip-midroll-alt/dyn-skip-midroll-alt.cfg +++ /dev/null @@ -1,2 +0,0 @@ -OPT_MODE_STRIP_AD_SEGMENTS true -OPT_MODE_NOTIFY_ADS_WATCHED true \ No newline at end of file diff --git a/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js b/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js deleted file mode 100644 index e8203d9..0000000 --- a/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js +++ /dev/null @@ -1,822 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll-alt/dyn-skip-midroll-alt.user.js -// @description Multiple solutions for blocking Twitch ads (dyn-skip-midroll-alt) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - } - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body); - } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('/access_token')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn-skip-midroll/dyn-skip-midroll-ublock-origin.js b/dyn-skip-midroll/dyn-skip-midroll-ublock-origin.js deleted file mode 100644 index 2972014..0000000 --- a/dyn-skip-midroll/dyn-skip-midroll-ublock-origin.js +++ /dev/null @@ -1,811 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - } - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body); - } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('/access_token')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn-skip-midroll/dyn-skip-midroll.cfg b/dyn-skip-midroll/dyn-skip-midroll.cfg deleted file mode 100644 index a42491d..0000000 --- a/dyn-skip-midroll/dyn-skip-midroll.cfg +++ /dev/null @@ -1,3 +0,0 @@ -OPT_MODE_NOTIFY_ADS_WATCHED true -OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS 1 -OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT true \ No newline at end of file diff --git a/dyn-skip-midroll/dyn-skip-midroll.user.js b/dyn-skip-midroll/dyn-skip-midroll.user.js deleted file mode 100644 index 134aa4b..0000000 --- a/dyn-skip-midroll/dyn-skip-midroll.user.js +++ /dev/null @@ -1,822 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll/dyn-skip-midroll.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip-midroll/dyn-skip-midroll.user.js -// @description Multiple solutions for blocking Twitch ads (dyn-skip-midroll) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - } - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body); - } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('/access_token')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn-skip/dyn-skip-ublock-origin.js b/dyn-skip/dyn-skip-ublock-origin.js deleted file mode 100644 index 5d5187d..0000000 --- a/dyn-skip/dyn-skip-ublock-origin.js +++ /dev/null @@ -1,929 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = true; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) - scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest - scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${getSegmentUrls.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - function getSegmentUrls(lines, includePrefetch) { - var result = []; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('http')) { - result.push(line); - } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { - result.push(line.substring(line.indexOf(':') + 1)); - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ""; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - }*/ - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segUrls = getSegmentUrls(lines, false); - var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); - for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { - if (streamInfo.SegmentMap[segUrls[i]] == null) { - streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line == null) { - continue; - } - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; - //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - lines[i + 1] = newSegUrl != null ? newSegUrl : ''; - } - /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { - // TODO - lines[i] = ''; - }*/ - } - } else { - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentMap = []; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { - var channelName = JSON.parse(init.body).variables.login; - // See if the first response has an ad, if it does then send requests until there is no ad - { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - console.log('No ad in main request'); - resolve(new Response(responseStr)); - return; - } else { - console.log('Ad in main request'); - } - } - } - if (!channelName) { - resolve(response); - return; - } - // Ads are being served, try skipping a bunch of ads - var resolved = false; - var skipAdTries = 0; - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - new Promise(async (skipAdResolve, skipAdReject) => { - var noAds = false; - var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); - if (accessTokenResponse.status == 200) { - var responseStr = await accessTokenResponse.text(); - var responseData = JSON.parse(responseStr); - var hasAd = false; - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; - } - var attempt = ++skipAdTries; - console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); - if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { - resolved = true; - resolve(new Response(responseStr)); - return; - } - } else if (!resolved) { - resolved = true; - resolve(response); - return; - } - }).catch(console.log); - } - } else { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn-skip/dyn-skip.cfg b/dyn-skip/dyn-skip.cfg deleted file mode 100644 index 8d820a1..0000000 --- a/dyn-skip/dyn-skip.cfg +++ /dev/null @@ -1,2 +0,0 @@ -OPT_MODE_MUTE_BLACK true -OPT_MODE_NOTIFY_ADS_WATCHED true \ No newline at end of file diff --git a/dyn-skip/dyn-skip.user.js b/dyn-skip/dyn-skip.user.js deleted file mode 100644 index 1b1ad6f..0000000 --- a/dyn-skip/dyn-skip.user.js +++ /dev/null @@ -1,940 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.2 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip/dyn-skip.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip/dyn-skip.user.js -// @description Multiple solutions for blocking Twitch ads (dyn-skip) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = true; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any) - scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest - scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump. - scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_REGULAR_PLAYER_TYPE = 'site'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${getSegmentUrls.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} - ${tryNotifyAdsWatchedM3U8.toString()} - ${parseAttributes.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } - else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - else if (e.data.key == 'UboReloadPlayer') { - reloadTwitchPlayer(); - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - function getSegmentUrls(lines, includePrefetch) { - var result = []; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('http')) { - result.push(line); - } else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { - result.push(line.substring(line.indexOf(':') + 1)); - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? - var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - noAds = (await tryNotifyAdsWatchedM3U8(await streamM3u8Response.text())) == 1; - console.log('Notify ad watched. Response has ads: ' + !noAds); - } - } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); - } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ""; - } - } - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - /*if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - }*/ - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) { - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segUrls = getSegmentUrls(lines, false); - var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH); - for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) { - if (streamInfo.SegmentMap[segUrls[i]] == null) { - streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j]; - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line == null) { - continue; - } - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - var newSegUrl = streamInfo.SegmentMap[lines[i + 1]]; - //lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - lines[i + 1] = newSegUrl != null ? newSegUrl : ''; - } - /*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) { - // TODO - lines[i] = ''; - }*/ - } - } else { - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Url = url; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - streamInfo.SegmentMap = []; - streamInfo.NotifyFirstTime = 0; - streamInfo.NotifyObservedNoAds = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType, realFetch) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body, realFetch); - } - function gqlRequest(body, realFetch) { - var fetchFunc = realFetch ? realFetch : fetch; - return fetchFunc('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) { - var channelName = JSON.parse(init.body).variables.login; - // See if the first response has an ad, if it does then send requests until there is no ad - { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - console.log('No ad in main request'); - resolve(new Response(responseStr)); - return; - } else { - console.log('Ad in main request'); - } - } - } - if (!channelName) { - resolve(response); - return; - } - // Ads are being served, try skipping a bunch of ads - var resolved = false; - var skipAdTries = 0; - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - new Promise(async (skipAdResolve, skipAdReject) => { - var noAds = false; - var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch); - if (accessTokenResponse.status == 200) { - var responseStr = await accessTokenResponse.text(); - var responseData = JSON.parse(responseStr); - var hasAd = false; - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0; - } - var attempt = ++skipAdTries; - console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad')); - if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) { - resolved = true; - resolve(new Response(responseStr)); - return; - } - } else if (!resolved) { - resolved = true; - resolve(response); - return; - } - }).catch(console.log); - } - } else { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn-video-swap/dyn-video-swap-ublock-origin.js b/dyn-video-swap/dyn-video-swap-ublock-origin.js deleted file mode 100644 index 724b955..0000000 --- a/dyn-video-swap/dyn-video-swap-ublock-origin.js +++ /dev/null @@ -1,811 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = true; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - } - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body); - } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('/access_token')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn-video-swap/dyn-video-swap.cfg b/dyn-video-swap/dyn-video-swap.cfg deleted file mode 100644 index 07aab42..0000000 --- a/dyn-video-swap/dyn-video-swap.cfg +++ /dev/null @@ -1 +0,0 @@ -OPT_MODE_VIDEO_SWAP true \ No newline at end of file diff --git a/dyn-video-swap/dyn-video-swap.user.js b/dyn-video-swap/dyn-video-swap.user.js deleted file mode 100644 index d7b383e..0000000 --- a/dyn-video-swap/dyn-video-swap.user.js +++ /dev/null @@ -1,822 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-video-swap/dyn-video-swap.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-video-swap/dyn-video-swap.user.js -// @description Multiple solutions for blocking Twitch ads (dyn-video-swap) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = true; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - } - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body); - } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('/access_token')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn/dyn-ublock-origin.js b/dyn/dyn-ublock-origin.js deleted file mode 100644 index ec18cf9..0000000 --- a/dyn/dyn-ublock-origin.js +++ /dev/null @@ -1,811 +0,0 @@ -twitch-videoad.js application/javascript -(function() { - if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - } - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body); - } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('/access_token')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/dyn/dyn.cfg b/dyn/dyn.cfg deleted file mode 100644 index 44012de..0000000 --- a/dyn/dyn.cfg +++ /dev/null @@ -1 +0,0 @@ -OPT_MODE_STRIP_AD_SEGMENTS true \ No newline at end of file diff --git a/dyn/dyn.user.js b/dyn/dyn.user.js deleted file mode 100644 index d7c75d5..0000000 --- a/dyn/dyn.user.js +++ /dev/null @@ -1,822 +0,0 @@ -// ==UserScript== -// @name TwitchAdSolutions -// @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.1 -// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn/dyn.user.js -// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn/dyn.user.js -// @description Multiple solutions for blocking Twitch ads (dyn) -// @author pixeltris -// @match *://*.twitch.tv/* -// @run-at document-start -// @grant none -// ==/UserScript== -(function() { - 'use strict'; - function declareOptions(scope) { - // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; - scope.OPT_MODE_STRIP_AD_SEGMENTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 2;// Larger values might increase load time. Lower values may increase ad chance. - scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; - scope.AD_SIGNIFIER = 'stitched-ad'; - scope.LIVE_SIGNIFIER = ',live'; - scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } - // These are only really for Worker scope... - scope.StreamInfos = []; - scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; - scope.LastAdUrl = null; - scope.LastAdTime = 0; - // Need this in both scopes. Window scope needs to update this to worker scope. - scope.gql_device_id = null; - } - declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; - var twitchMainWorker = null; - const oldWorker = window.Worker; - window.Worker = class Worker extends oldWorker { - constructor(twitchBlobUrl) { - if (twitchMainWorker) { - super(twitchBlobUrl); - return; - } - var jsURL = getWasmWorkerUrl(twitchBlobUrl); - if (typeof jsURL !== 'string') { - super(twitchBlobUrl); - return; - } - var newBlobStr = ` - ${processM3U8.toString()} - ${getSegmentTimes.toString()} - ${hookWorkerFetch.toString()} - ${declareOptions.toString()} - ${getAccessToken.toString()} - ${gqlRequest.toString()} - declareOptions(self); - self.addEventListener('message', function(e) { - if (e.data.key == 'UboUpdateDeviceId') { - gql_device_id = e.data.value; - } - }); - hookWorkerFetch(); - importScripts('${jsURL}'); - ` - super(URL.createObjectURL(new Blob([newBlobStr]))); - twitchMainWorker = this; - var adDiv = null; - this.onmessage = function(e) { - if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; - } - else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; - } - else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; - } - } - function getAdDiv() { - var playerRootDiv = document.querySelector('.video-player'); - var adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } - } - } - function getWasmWorkerUrl(twitchBlobUrl) { - var req = new XMLHttpRequest(); - req.open('GET', twitchBlobUrl, false); - req.send(); - return req.responseText.split("'")[1]; - } - function getSegmentTimes(lines) { - var result = []; - var lastDate = 0; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) { - lastDate = Date.parse(line.substring(line.indexOf(':') + 1)); - } else if (line.startsWith('http')) { - result[lastDate] = line; - } - } - return result; - } - async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (haveAdTags) { - postMessage({ - key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), - streamM3u8: textStr - }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - // NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads - if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) { - postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')}); - } else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) { - postMessage({key:'UboHideAdBanner'}); - LastAdTime = 0; - } - if (haveAdTags) { - LastAdUrl = url; - LastAdTime = Date.now(); - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - console.log('Stripping ads (instead of skipping ads)'); - } - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url! ' + url); - return textStr; - } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { - var backupM3u8Response = await realFetch(streamInfo.BackupUrl); - if (backupM3u8Response.status == 200) { - backupM3u8 = await backupM3u8Response.text(); - } else { - console.log('Backup m3u8 failed with ' + backupM3u8Response.status); - } - } - var lines = textStr.replace('\r', '').split('\n'); - var segmentMap = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segTimes = getSegmentTimes(lines); - var backupSegTimes = getSegmentTimes(backupLines); - for (const [segTime, segUrl] of Object.entries(segTimes)) { - //segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1]; - var closestTime = Number.MAX_VALUE; - var matchingBackupTime = Number.MAX_VALUE; - for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) { - var timeDiff = Math.abs(segTime - backupSegTime); - if (timeDiff < closestTime) { - closestTime = timeDiff; - matchingBackupTime = backupSegTime; - segmentMap[segUrl] = backupSegUrl; - } - } - if (closestTime != Number.MAX_VALUE) { - backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1); - } - } - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.includes('stitched-ad')) { - lines[i] = ''; - } - if (line.startsWith('#EXTINF:') && !line.includes(',live')) { - lines[i] = line.substring(0, line.indexOf(',')) + ',live'; - var backupSegment = segmentMap[lines[i + 1]]; - lines[i + 1] = backupSegment != null ? backupSegment : '' - } - } - textStr = lines.join('\n'); - //console.log(textStr); - } - return textStr; - } - function hookWorkerFetch() { - var realFetch = fetch; - fetch = async function(url, options) { - if (typeof url === 'string') { - if (url.endsWith('m3u8')) { - return new Promise(function(resolve, reject) { - var processAfter = async function(response) { - var str = await processM3U8(url, await response.text(), realFetch); - resolve(new Response(str)); - }; - var send = function() { - return realFetch(url, options).then(function(response) { - processAfter(response); - })['catch'](function(err) { - console.log('fetch hook err ' + err); - reject(err); - }); - }; - send(); - }); - } - else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { - var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { - return new Promise(async function(resolve, reject) { - // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). - // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. - var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS; - var attempts = 0; - while(true) { - var encodingsM3u8Response = await realFetch(url, options); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) { - if (maxAttempts > 1 && attempts >= maxAttempts) { - console.log('max skip ad attempts reached (attempt #' + attempts + ')'); - } - var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; - } - // This might potentially backfire... maybe just add the new urls - streamInfo.ChannelName = channelName; - streamInfo.Urls = []; - streamInfo.RootM3U8Params = (new URL(url)).search; - streamInfo.BackupUrl = null; - streamInfo.BackupFailed = false; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); - StreamInfosByUrl[lines[i]] = streamInfo; - } - } - resolve(new Response(encodingsM3u8)); - break; - } - console.log('attempt to skip ad (attempt #' + attempts + ')'); - } else { - // Stream is offline? - resolve(encodingsM3u8Response); - break; - } - } - }); - } - } - } - return realFetch.apply(this, arguments); - } - } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } - function getAccessToken(channelName, playerType) { - var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } - return gqlRequest(body); - } - function gqlRequest(body) { - return fetch('https://gql.twitch.tv/gql', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id - } - }); - } - function parseAttributes(str) { - return Object.fromEntries( - str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) - .filter(Boolean) - .map(x => { - const idx = x.indexOf('='); - const key = x.substring(0, idx); - const value = x.substring(idx +1); - const num = Number(value); - return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] - })); - } - async function tryNotifyAdsWatchedM3U8(streamM3u8) { - //console.log(streamM3u8); - if (!streamM3u8.includes(AD_SIGNIFIER)) { - return 1; - } - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: false, - player_volume: 0.5, - visible: true, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) { - // This is all that's actually required at the moment - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } else { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 30, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - makeGraphQlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - return 0; - } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - return res; - } else { - // http error - return 2; - } - return 0; - } - function hookFetch() { - var realFetch = window.fetch; - window.fetch = function(url, init, ...args) { - if (typeof url === 'string') { - if (url.includes('/access_token') || url.includes('gql')) { - if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { - if (url.includes('/access_token')) { - var modifiedUrl = new URL(url); - modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE); - arguments[0] = modifiedUrl.href; - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - const newBody = JSON.parse(init.body); - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - init.body = JSON.stringify(newBody); - } - } - var deviceId = init.headers['X-Device-Id']; - if (typeof deviceId !== 'string') { - deviceId = init.headers['Device-ID']; - } - if (typeof deviceId === 'string') { - gql_device_id = deviceId; - } - if (gql_device_id && twitchMainWorker) { - twitchMainWorker.postMessage({ - key: 'UboUpdateDeviceId', - value: gql_device_id - }); - } - if (OPT_MODE_NOTIFY_ADS_WATCHED) { - var tok = null, sig = null; - if (url.includes('/access_token')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - // NOTE: This code path is untested - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.sig && responseData.token) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.sig, responseData.token) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); - } - } - } - } - return realFetch.apply(this, arguments); - } - } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { - console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; - if (streamM3u8) { - tryNotifyAdsWatchedM3U8(streamM3u8); - } - reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } - } - } - function pollForAds() { - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - setTimeout(pollForAds,100); - } - function reloadTwitchPlayer() { - // Taken from ttv-tools / ffz - // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts - // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx - function findReactNode(root, constraint) { - if (root.stateNode && constraint(root.stateNode)) { - return root.stateNode; - } - let node = root.child; - while (node) { - const result = findReactNode(node, constraint); - if (result) { - return result; - } - node = node.sibling; - } - return null; - } - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; - } - if (!reactRootNode) { - console.log('Could not find react root'); - return; - } - var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; - var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); - if (!player) { - console.log('Could not find player'); - return; - } - if (!playerState) { - console.log('Could not find player state'); - return; - } - if (player.paused) { - return; - } - const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null); - if (sink && sink.video && sink.video._ffz_compressor) { - const video = sink.video; - const volume = video.volume ? video.volume : player.getVolume(); - const muted = player.isMuted(); - const newVideo = document.createElement('video'); - newVideo.volume = muted ? 0 : volume; - newVideo.playsInline = true; - video.replaceWith(newVideo); - player.attachHTMLVideoElement(newVideo); - setImmediate(() => { - player.setVolume(volume); - player.setMuted(muted); - }); - } - playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false - } - window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAds(); - } - document.head.appendChild(script); - } else { - pollForAds(); - } - } - hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } -})(); diff --git a/other-solutions.md b/other-solutions.md index f3352a4..15e2e36 100644 --- a/other-solutions.md +++ b/other-solutions.md @@ -16,16 +16,15 @@ Web browser extensions / scripts: - https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code - Reloads the player (or page) when it detects the ad banner in DOM. - [Alternate Player for Twitch.tv](https://chrome.google.com/webstore/detail/bhplkbgoehhhddaoolmakpocnenplmhf) - [code](https://robwu.nl/crxviewer/?crx=bhplkbgoehhhddaoolmakpocnenplmhf&qf=player.js) - - Notifies Twitch that ads were watched before loading the main stream (prerolls). - - Removes ad segments which cannot be skipped (midrolls). The player will freeze on the last live frame until no more ads. -- [Twitch AdBlock](https://addons.mozilla.org/en-GB/firefox/addon/twitch-adblock/) - [code](https://robwu.nl/crxviewer/?crx=https%3A%2F%2Faddons.mozilla.org%2Fen-GB%2Ffirefox%2Faddon%2Ftwitch-adblock%2F&qf=js/background.js) + - Removes ad segments which cannot be skipped. The player will freeze on the last live frame until no more ads. +- https://github.com/TTV-LOL/extensions - Uses a proxy on the main m3u8 file to get a stream without ads (no prerolls / midrolls). Applications / third party websites: - https://github.com/streamlink/streamlink - Removes ad segments (I assume this will freeze on the last live frame until no more ads). -- https://github.com/nopbreak/TwitchMod - - Unsure how this one blocks ads, but it claims that it can. As this is a mod of the official Twitch app (which isn't obfuscated) they might have insight into better ad blocking methods. +- [multiChat for Twitch](https://play.google.com/store/apps/details?id=org.mchatty) + - Unsure how this one blocks ads, but it claims that it does. - https://twitchls.com/ - Uses the `embed` player. Purple screen may display every 10-15 mins. - https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/ diff --git a/proxy-m3u8/proxy-m3u8-ublock-origin.js b/proxy-m3u8/proxy-m3u8-ublock-origin.js index 8e9257b..1c283f9 100644 --- a/proxy-m3u8/proxy-m3u8-ublock-origin.js +++ b/proxy-m3u8/proxy-m3u8-ublock-origin.js @@ -16,10 +16,10 @@ twitch-videoad.js application/javascript scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = 'Ax0ZHhYNF0QLXg8PFBsRBgIfR0MVWQUJBVwFVBJBHAYfBQBFS0UcVhhM'; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = true; + scope.OPT_MODE_PROXY_M3U8 = 'http://127.0.0.1/twitch-m3u8/'; + scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = true; + scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; scope.OPT_REGULAR_PLAYER_TYPE = 'site'; diff --git a/proxy-m3u8/proxy-m3u8.cfg b/proxy-m3u8/proxy-m3u8.cfg index c04bed7..73a6f8d 100644 --- a/proxy-m3u8/proxy-m3u8.cfg +++ b/proxy-m3u8/proxy-m3u8.cfg @@ -1,3 +1 @@ -OPT_MODE_PROXY_M3U8 'Ax0ZHhYNF0QLXg8PFBsRBgIfR0MVWQUJBVwFVBJBHAYfBQBFS0UcVhhM' -OPT_MODE_PROXY_M3U8_OBFUSCATED true -OPT_MODE_PROXY_M3U8_PARTIAL_URL true \ No newline at end of file +OPT_MODE_PROXY_M3U8 'http://127.0.0.1/twitch-m3u8/' \ No newline at end of file diff --git a/proxy-m3u8/proxy-m3u8.user.js b/proxy-m3u8/proxy-m3u8.user.js index 6bed9a7..9a8e997 100644 --- a/proxy-m3u8/proxy-m3u8.user.js +++ b/proxy-m3u8/proxy-m3u8.user.js @@ -27,10 +27,10 @@ scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = 'Ax0ZHhYNF0QLXg8PFBsRBgIfR0MVWQUJBVwFVBJBHAYfBQBFS0UcVhhM'; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = true; + scope.OPT_MODE_PROXY_M3U8 = 'http://127.0.0.1/twitch-m3u8/'; + scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = true; + scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; scope.OPT_REGULAR_PLAYER_TYPE = 'site'; diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 0000000..2b3c304 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,37 @@ +This gives an overview of using proxies to avoid Twitch ads (without having to proxy all of your traffic ~ just the initial m3u8 per-stream). + +`proxy-server` fetches the m3u8 (hopefully ad-free). `extension` contains a Chrome / Firefox compatible extension for sending the m3u8 request. `proxy-m3u8` (uBlock Origin / userscript) scripts also work as an alternative to the extension. + +## Socks5 + +- Put your socks5 proxy info into `proxy-server-info.txt` and run `proxy-server.exe` (install `Mono` if using Linux/Mac and run via `mono proxy-server.exe`). +- Load the `extension` folder as an unpacked extension in your browser. Alternatively use `proxy-m3u8` `uBlock Origin` / `userscript` scripts ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8.user.js)). (TODO: more helpful info). + +## VPN + VMWare + +- Set up `VMWare Workstation` with `Windows` and your desired VPN. +- In your VM `Settings` under the `Hardware` tab select `Network Adapter` and change the `Network connection` to `Bridged`. This is to simplify connecting to `proxy-server` from your host. You can do it without bridged but it requires additional VMWare network configuration. +- Add `proxy-server.exe` to your Windows VM Firewall (or disable your Windows Firewall in the VM) and then run `proxy-server.exe`. +- Modify `extension/background.js` and change the IP to your VM IP (obtained via `ipconfig` inside your VM). If you're using `proxy-m3u8` change the IP there. + +NOTE: See "mixed-content" below. + +## VPS + +- Run `proxy-server.exe` on your VPS which is hosted in an ad-free country (install `Mono` if using Linux and run via `mono proxy-server.exe`). +- Modify the url in `extension/background.js` to point to your VPS and load the `extension` folder as an unpacked extension. If using `proxy-m3u8` scripts find the equivalent urls there and modify them where applicable (you'll likely need to fork to do this). + +NOTE: See "mixed-content" below. + +## Notes + +- Running the `HttpListener` on https has many convoluted steps. On localhost (127.0.0.1) Chrome / Firefox allow mixed content requests so there aren't any issues there. For other IPs (i.e. in the VPS/VPN example) you'll need to enable "mixed-content" (also known as "Insecure content") for twitch.tv or otherwise you'll get CORS errors. +- `proxy-server.exe` needs to be ran as Admin to listen on the desired IP/Port. +- Disable other Twitch ad blocking extensions / scripts as they may interfere. +- You will likely have to try multiple locations until you find something that works. +- If you're having problems use Wireshark (or similar) to make sure the m3u8 is being re-routed. +- To build `proxy-server.cs` yourself run `proxy-server-build.bat`. If you're on Mac/Linux build it with `msbuild` which should come with `Mono` or `.NET Core` (TODO: more helpful info). +- `proxy-server` should be visible over LAN/WAN assuming correct firewall settings, however if you wish to connect to it from another machine you'll need to edit the IP in `extension/background.js`. +- TODO: Provide an authenticated option to allow Twitch Turbo to be used to provide streams to non-Turbo users. + +I would only really recommend using the info + code here as a starting point for building a more robust solution. \ No newline at end of file diff --git a/proxy/extension/README.md b/proxy/extension/README.md new file mode 100644 index 0000000..dd50edb --- /dev/null +++ b/proxy/extension/README.md @@ -0,0 +1,3 @@ +This folder contains an extension which can be used with both Chrome and Firefox to proxy twitch.tv m3u8 stream requests. + +The target url is set to `http://127.0.0.1/`, you'll want to modify that based on your requirements. \ No newline at end of file diff --git a/proxy/extension/background.js b/proxy/extension/background.js new file mode 100644 index 0000000..0c79c72 --- /dev/null +++ b/proxy/extension/background.js @@ -0,0 +1,20 @@ +var isChrome = typeof chrome !== "undefined" && typeof browser === "undefined"; +var extensionAPI = isChrome ? chrome : browser; +function onBeforeRequest(details) { + const match = /hls\/(\w+)\.m3u8/gim.exec(details.url); + if (match !== null && match.length > 1) { + return { + redirectUrl: `http://127.0.0.1/twitch-m3u8/${match[1]}` + }; + } else { + return { + redirectUrl: details.url + }; + } +} +extensionAPI.webRequest.onBeforeRequest.addListener( + onBeforeRequest, { + urls: ["https://usher.ttvnw.net/api/channel/hls/*"] + }, + ["blocking"] +); \ No newline at end of file diff --git a/proxy/extension/manifest.json b/proxy/extension/manifest.json new file mode 100644 index 0000000..be15e47 --- /dev/null +++ b/proxy/extension/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "Twitch M3U8 Proxy", + "description": "Twitch M3U8 Proxy", + "version": "1.0", + "manifest_version": 2, + "background": { + "scripts": ["background.js"], + "persistent": true + }, + "permissions": [ + "webRequest", + "webRequestBlocking", + "*://*.twitch.tv/*", + "*://*.ttvnw.net/*" + ] +} \ No newline at end of file diff --git a/proxy/proxy-server-build.bat b/proxy/proxy-server-build.bat new file mode 100644 index 0000000..c1cf5de --- /dev/null +++ b/proxy/proxy-server-build.bat @@ -0,0 +1 @@ +call %WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe -debug proxy-server.cs \ No newline at end of file diff --git a/proxy/proxy-server-info.txt b/proxy/proxy-server-info.txt new file mode 100644 index 0000000..7452c57 --- /dev/null +++ b/proxy/proxy-server-info.txt @@ -0,0 +1,31 @@ + + + + + +- If you want to use a socks5 proxy: + +The first line MUST be the IP of the socks proxy +The second line MUST be the port of the socks proxy + +- If you need a user / pass: + +The third line MUST be the username +The fourth line MUST be the password + +- If you DON'T need a user / pass then the third / fourth line MUST be empty. +- If you DON'T want to use a socks proxy then leave all four lines empty, or delete this file. + +---------------------------- +Example (with user/pass) +---------------------------- +10.1.1.49 +1080 +myusername +mypassword + +---------------------------- +Example (without user/pass) +---------------------------- +10.2.2.66 +1080 \ No newline at end of file diff --git a/proxy/proxy-server.cs b/proxy/proxy-server.cs new file mode 100644 index 0000000..b3fe981 --- /dev/null +++ b/proxy/proxy-server.cs @@ -0,0 +1,1651 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Runtime.Serialization; +using System.Reflection; +using System.Threading; +using System.Net; +using System.Net.Sockets; +using System.IO; +using System.Diagnostics; +using System.Threading.Tasks; + +class TwitchProxyServer +{ + private static string ClientID = "kimne78kx3ncx6brgo4mv6wki5h1ko"; + private static string UserAgentChrome = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"; + private static string UserAgentFirefox = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0"; + private static string UserAgent = UserAgentChrome; + private static string Platform = "web"; + private static string PlayerBackend = "mediaplayer"; + private static string playerType = "site"; + private static bool UseFastBread = true;// fast_bread (EXT-X-TWITCH-PREFETCH) + private static DeviceIdType deviceIdType = DeviceIdType.Normal; + private Thread thread; + private HttpListener listener; + private string deviceId; + + private static bool SocksProxyFound { get { return !string.IsNullOrEmpty(SocksProxyIP) && SocksProxyPort > 0; } } + private static string SocksProxyIP; + private static int SocksProxyPort; + private static string SocksProxyUser; + private static string SocksProxyPass; + private static MihaZupan.HttpToSocks5Proxy proxy = null; + + enum DeviceIdType + { + Normal, + Empty, + None, + Unique + } + + public static void Run() + { + try + { + string file = "proxy-server-info.txt"; + if (File.Exists(file)) + { + string[] lines = File.ReadAllLines(file); + if (lines.Length > 1) + { + SocksProxyIP = lines[0].Trim(); + if (!string.IsNullOrWhiteSpace(lines[1])) + { + SocksProxyPort = int.Parse(lines[1].Trim()); + } + if (lines.Length > 2) + { + SocksProxyUser = lines[2].Trim(); + } + if (lines.Length > 3) + { + SocksProxyPass = lines[3].Trim(); + } + if (SocksProxyPort > 0) + { + if (!string.IsNullOrWhiteSpace(SocksProxyUser)) + { + proxy = new MihaZupan.HttpToSocks5Proxy(SocksProxyIP, SocksProxyPort, SocksProxyUser, SocksProxyPass); + } + else + { + proxy = new MihaZupan.HttpToSocks5Proxy(SocksProxyIP, SocksProxyPort); + } + } + } + } + } + catch (Exception e) + { + Console.WriteLine(e); + SocksProxyIP = null; + SocksProxyPort = 0; + } + Console.WriteLine("Socks: " + SocksProxyFound); + + ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072; + TwitchProxyServer server = new TwitchProxyServer(); + server.Start(); + System.Diagnostics.Process.GetCurrentProcess().WaitForExit(); + } + + public void Start() + { + Stop(); + + thread = new Thread(delegate() + { + listener = new HttpListener(); + listener.Prefixes.Add("http://*:" + 80 + "/"); + listener.Start(); + while (listener != null) + { + try + { + HttpListenerContext context = listener.GetContext(); + Process(context); + } + catch + { + } + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + + public void Stop() + { + if (listener != null) + { + try + { + listener.Stop(); + } + catch + { + } + listener = null; + } + + if (thread != null) + { + try + { + thread.Abort(); + } + catch + { + } + thread = null; + } + } + + private void Process(HttpListenerContext context) + { + try + { + string url = context.Request.Url.OriginalString; + Console.WriteLine("req " + DateTime.Now.TimeOfDay + " - " + url); + + byte[] responseBuffer = null; + string response = string.Empty; + string contentType = "text/html"; + + if (url.Contains("favicon.ico")) + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.OutputStream.Close(); + return; + } + + if (context.Request.Url.Segments.Length > 2 && + context.Request.Url.Segments[1].Trim('/') == "twitch-m3u8" && + !string.IsNullOrEmpty(context.Request.Url.Segments[2])) + { + string channelName = context.Request.Url.Segments[2].Trim('/'); + response = FetchM3U8(channelName); + //Console.WriteLine(response); + } + + if (responseBuffer == null) + { + responseBuffer = Encoding.UTF8.GetBytes(response.ToString()); + } + context.Response.Headers["Access-Control-Allow-Origin"] = "*"; + context.Response.ContentType = contentType; + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.ContentLength64 = responseBuffer.Length; + context.Response.OutputStream.Write(responseBuffer, 0, responseBuffer.Length); + context.Response.OutputStream.Flush(); + context.Response.StatusCode = (int)HttpStatusCode.OK; + } + catch + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + } + + context.Response.OutputStream.Close(); + } + + private string FetchM3U8(string channel) + { + if (string.IsNullOrEmpty(deviceId) || deviceIdType == DeviceIdType.Unique) + { + UpdateDeviceId(channel); + } + using (WebClient wc = new WebClient()) + { + string response = null, token = null, sig = null; + wc.Proxy = proxy; + wc.Headers.Clear(); + wc.Headers["client-id"] = ClientID; + if (deviceIdType != DeviceIdType.None) + { + wc.Headers["Device-ID"] = deviceIdType == DeviceIdType.Empty ? string.Empty : deviceId; + } + wc.Headers["accept"] = "*/*"; + wc.Headers["accept-encoding"] = "gzip, deflate, br"; + wc.Headers["accept-language"] = "en-us"; + wc.Headers["content-type"] = "text/plain; charset=UTF-8"; + wc.Headers["origin"] = "https://www.twitch.tv"; + wc.Headers["referer"] = "https://www.twitch.tv/"; + wc.Headers["user-agent"] = UserAgent; + response = wc.UploadString("https://gql.twitch.tv/gql", @"{""operationName"":""PlaybackAccessToken_Template"",""query"":""query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \""" + Platform + @"\"", playerBackend: \""" + PlayerBackend + @"\"", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \""" + Platform + @"\"", playerBackend: \""" + PlayerBackend + @"\"", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}"",""variables"":{""isLive"":true,""login"":""" + channel + @""",""isVod"":false,""vodID"":"""",""playerType"":""" + playerType + @"""}}"); + if (!string.IsNullOrEmpty(response)) + { + TwitchAccessToken tokenInfo = JSONSerializer.DeSerialize(response); + if (tokenInfo != null && tokenInfo.data != null && tokenInfo.data.streamPlaybackAccessToken != null && + !string.IsNullOrEmpty(tokenInfo.data.streamPlaybackAccessToken.value) && !string.IsNullOrEmpty(tokenInfo.data.streamPlaybackAccessToken.signature)) + { + token = tokenInfo.data.streamPlaybackAccessToken.value; + sig = tokenInfo.data.streamPlaybackAccessToken.signature; + } + } + if (!string.IsNullOrEmpty(token)) + { + string additionalParams = ""; + if (UseFastBread) + { + additionalParams += "&fast_bread=true"; + } + string url = "https://usher.ttvnw.net/api/channel/hls/" + channel + ".m3u8?allow_source=true" + additionalParams + "&sig=" + sig + "&token=" + System.Web.HttpUtility.UrlEncode(token); + wc.Headers.Clear(); + wc.Headers["accept"] = "application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain"; + wc.Headers["host"] = "usher.ttvnw.net"; + wc.Headers["cookie"] = "DNT=1;"; + wc.Headers["DNT"] = "1"; + wc.Headers["user-agent"] = UserAgent; + string encodingsM3u8 = wc.DownloadString(url); + return encodingsM3u8; + } + } + return null; + } + + private void UpdateDeviceId(string channel) + { + using (CookieAwareWebClient wc = new CookieAwareWebClient()) + { + wc.Proxy = null; + wc.Headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"; + wc.DownloadString("https://www.twitch.tv/" + channel); + ProcessCookies(wc.Cookies, out deviceId); + Console.WriteLine("deviceId: " + deviceId); + } + } + + static string ProcessCookies(string str) + { + string uniqueId; + return ProcessCookies(str, out uniqueId); + } + + static string ProcessCookies(string str, out string uniqueId) + { + uniqueId = null; + string result = string.Empty; + string[] cookies = str.Split(','); + foreach (string cookie in cookies) + { + if (cookie.Split(';')[0].Contains('=')) + { + string[] splitted = cookie.Split(';')[0].Split('='); + if (splitted.Length >= 2 && splitted[0] == "unique_id") + { + uniqueId = splitted[1]; + } + result += cookie.Split(';')[0] + ";"; + } + } + return result; + } + + class CookieAwareWebClient : WebClient + { + public CookieContainer CookieContainer { get; set; } + public Uri Uri { get; set; } + + public string Cookies { get; private set; } + + public CookieAwareWebClient() + : this(new CookieContainer()) + { + } + + public CookieAwareWebClient(CookieContainer cookies) + { + this.CookieContainer = new CookieContainer(); + } + + protected override WebResponse GetWebResponse(WebRequest request) + { + WebResponse response = base.GetWebResponse(request); + string setCookieHeader = response.Headers.Get("Set-Cookie"); + Cookies = setCookieHeader; + return response; + } + } + + [DataContract] + public class TwitchAccessTokenOld + { + [DataMember] + public string token { get; set; } + [DataMember] + public string sig { get; set; } + } + + [DataContract] + public class TwitchAccessToken + { + [DataMember] + public TwitchAccessToken_data data { get; set; } + } + + [DataContract] + public class TwitchAccessToken_data + { + [DataMember] + public TwitchAccessToken_streamPlaybackAccessToken streamPlaybackAccessToken { get; set; } + } + + [DataContract] + public class TwitchAccessToken_streamPlaybackAccessToken + { + [DataMember] + public string value { get; set; } + [DataMember] + public string signature { get; set; } + } + + static class JSONSerializer where TType : class + { + public static TType DeSerialize(string json) + { + return TinyJson.JSONParser.FromJson(json); + } + } + + static void Main() + { + TwitchProxyServer.Run(); + } +} + +namespace TinyJson +{ + // Really simple JSON parser in ~300 lines + // - Attempts to parse JSON files with minimal GC allocation + // - Nice and simple "[1,2,3]".FromJson>() API + // - Classes and structs can be parsed too! + // class Foo { public int Value; } + // "{\"Value\":10}".FromJson() + // - Can parse JSON without type information into Dictionary and List e.g. + // "[1,2,3]".FromJson().GetType() == typeof(List) + // "{\"Value\":10}".FromJson().GetType() == typeof(Dictionary) + // - No JIT Emit support to support AOT compilation on iOS + // - Attempts are made to NOT throw an exception if the JSON is corrupted or invalid: returns null instead. + // - Only public fields and property setters on classes/structs will be written to + // + // Limitations: + // - No JIT Emit support to parse structures quickly + // - Limited to parsing <2GB JSON files (due to int.MaxValue) + // - Parsing of abstract classes or interfaces is NOT supported and will throw an exception. + public static class JSONParser + { + [ThreadStatic] static Stack> splitArrayPool; + [ThreadStatic] static StringBuilder stringBuilder; + [ThreadStatic] static Dictionary> fieldInfoCache; + [ThreadStatic] static Dictionary> propertyInfoCache; + + public static T FromJson(this string json) + { + // Initialize, if needed, the ThreadStatic variables + if (propertyInfoCache == null) propertyInfoCache = new Dictionary>(); + if (fieldInfoCache == null) fieldInfoCache = new Dictionary>(); + if (stringBuilder == null) stringBuilder = new StringBuilder(); + if (splitArrayPool == null) splitArrayPool = new Stack>(); + + //Remove all whitespace not within strings to make parsing simpler + stringBuilder.Length = 0; + for (int i = 0; i < json.Length; i++) + { + char c = json[i]; + if (c == '"') + { + i = AppendUntilStringEnd(true, i, json); + continue; + } + if (char.IsWhiteSpace(c)) + continue; + + stringBuilder.Append(c); + } + + //Parse the thing! + return (T)ParseValue(typeof(T), stringBuilder.ToString()); + } + + static int AppendUntilStringEnd(bool appendEscapeCharacter, int startIdx, string json) + { + stringBuilder.Append(json[startIdx]); + for (int i = startIdx + 1; i < json.Length; i++) + { + if (json[i] == '\\') + { + if (appendEscapeCharacter) + stringBuilder.Append(json[i]); + stringBuilder.Append(json[i + 1]); + i++;//Skip next character as it is escaped + } + else if (json[i] == '"') + { + stringBuilder.Append(json[i]); + return i; + } + else + stringBuilder.Append(json[i]); + } + return json.Length - 1; + } + + //Splits { :, : } and [ , ] into a list of strings + static List Split(string json) + { + List splitArray = splitArrayPool.Count > 0 ? splitArrayPool.Pop() : new List(); + splitArray.Clear(); + if (json.Length == 2) + return splitArray; + int parseDepth = 0; + stringBuilder.Length = 0; + for (int i = 1; i < json.Length - 1; i++) + { + switch (json[i]) + { + case '[': + case '{': + parseDepth++; + break; + case ']': + case '}': + parseDepth--; + break; + case '"': + i = AppendUntilStringEnd(true, i, json); + continue; + case ',': + case ':': + if (parseDepth == 0) + { + splitArray.Add(stringBuilder.ToString()); + stringBuilder.Length = 0; + continue; + } + break; + } + + stringBuilder.Append(json[i]); + } + + splitArray.Add(stringBuilder.ToString()); + + return splitArray; + } + + internal static object ParseValue(Type type, string json) + { + if (type == typeof(string)) + { + if (json.Length <= 2) + return string.Empty; + StringBuilder parseStringBuilder = new StringBuilder(json.Length); + for (int i = 1; i < json.Length - 1; ++i) + { + if (json[i] == '\\' && i + 1 < json.Length - 1) + { + int j = "\"\\nrtbf/".IndexOf(json[i + 1]); + if (j >= 0) + { + parseStringBuilder.Append("\"\\\n\r\t\b\f/"[j]); + ++i; + continue; + } + if (json[i + 1] == 'u' && i + 5 < json.Length - 1) + { + UInt32 c = 0; + if (UInt32.TryParse(json.Substring(i + 2, 4), System.Globalization.NumberStyles.AllowHexSpecifier, null, out c)) + { + parseStringBuilder.Append((char)c); + i += 5; + continue; + } + } + } + parseStringBuilder.Append(json[i]); + } + return parseStringBuilder.ToString(); + } + if (type.IsPrimitive) + { + var result = Convert.ChangeType(json, type, System.Globalization.CultureInfo.InvariantCulture); + return result; + } + if (type == typeof(decimal)) + { + decimal result; + decimal.TryParse(json, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out result); + return result; + } + if (json == "null") + { + return null; + } + if (type.IsEnum) + { + if (json[0] == '"') + json = json.Substring(1, json.Length - 2); + try + { + return Enum.Parse(type, json, false); + } + catch + { + return 0; + } + } + if (type.IsArray) + { + Type arrayType = type.GetElementType(); + if (json[0] != '[' || json[json.Length - 1] != ']') + return null; + + List elems = Split(json); + Array newArray = Array.CreateInstance(arrayType, elems.Count); + for (int i = 0; i < elems.Count; i++) + newArray.SetValue(ParseValue(arrayType, elems[i]), i); + splitArrayPool.Push(elems); + return newArray; + } + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) + { + Type listType = type.GetGenericArguments()[0]; + if (json[0] != '[' || json[json.Length - 1] != ']') + return null; + + List elems = Split(json); + var list = (IList)type.GetConstructor(new Type[] { typeof(int) }).Invoke(new object[] { elems.Count }); + for (int i = 0; i < elems.Count; i++) + list.Add(ParseValue(listType, elems[i])); + splitArrayPool.Push(elems); + return list; + } + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + { + Type keyType, valueType; + { + Type[] args = type.GetGenericArguments(); + keyType = args[0]; + valueType = args[1]; + } + + //Refuse to parse dictionary keys that aren't of type string + if (keyType != typeof(string)) + return null; + //Must be a valid dictionary element + if (json[0] != '{' || json[json.Length - 1] != '}') + return null; + //The list is split into key/value pairs only, this means the split must be divisible by 2 to be valid JSON + List elems = Split(json); + if (elems.Count % 2 != 0) + return null; + + var dictionary = (IDictionary)type.GetConstructor(new Type[] { typeof(int) }).Invoke(new object[] { elems.Count / 2 }); + for (int i = 0; i < elems.Count; i += 2) + { + if (elems[i].Length <= 2) + continue; + string keyValue = elems[i].Substring(1, elems[i].Length - 2); + object val = ParseValue(valueType, elems[i + 1]); + dictionary[keyValue] = val; + } + return dictionary; + } + if (type == typeof(object)) + { + return ParseAnonymousValue(json); + } + if (json[0] == '{' && json[json.Length - 1] == '}') + { + return ParseObject(type, json); + } + + return null; + } + + static object ParseAnonymousValue(string json) + { + if (json.Length == 0) + return null; + if (json[0] == '{' && json[json.Length - 1] == '}') + { + List elems = Split(json); + if (elems.Count % 2 != 0) + return null; + var dict = new Dictionary(elems.Count / 2); + for (int i = 0; i < elems.Count; i += 2) + dict[elems[i].Substring(1, elems[i].Length - 2)] = ParseAnonymousValue(elems[i + 1]); + return dict; + } + if (json[0] == '[' && json[json.Length - 1] == ']') + { + List items = Split(json); + var finalList = new List(items.Count); + for (int i = 0; i < items.Count; i++) + finalList.Add(ParseAnonymousValue(items[i])); + return finalList; + } + if (json[0] == '"' && json[json.Length - 1] == '"') + { + string str = json.Substring(1, json.Length - 2); + return str.Replace("\\", string.Empty); + } + if (char.IsDigit(json[0]) || json[0] == '-') + { + if (json.Contains(".")) + { + double result; + double.TryParse(json, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out result); + return result; + } + else + { + int result; + int.TryParse(json, out result); + return result; + } + } + if (json == "true") + return true; + if (json == "false") + return false; + // handles json == "null" as well as invalid JSON + return null; + } + + static Dictionary CreateMemberNameDictionary(T[] members) where T : MemberInfo + { + Dictionary nameToMember = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < members.Length; i++) + { + T member = members[i]; + if (member.IsDefined(typeof(IgnoreDataMemberAttribute), true)) + continue; + + string name = member.Name; + if (member.IsDefined(typeof(DataMemberAttribute), true)) + { + DataMemberAttribute dataMemberAttribute = (DataMemberAttribute)Attribute.GetCustomAttribute(member, typeof(DataMemberAttribute), true); + if (!string.IsNullOrEmpty(dataMemberAttribute.Name)) + name = dataMemberAttribute.Name; + } + + nameToMember.Add(name, member); + } + + return nameToMember; + } + + static object ParseObject(Type type, string json) + { + object instance = FormatterServices.GetUninitializedObject(type); + + //The list is split into key/value pairs only, this means the split must be divisible by 2 to be valid JSON + List elems = Split(json); + if (elems.Count % 2 != 0) + return instance; + + Dictionary nameToField; + Dictionary nameToProperty; + if (!fieldInfoCache.TryGetValue(type, out nameToField)) + { + nameToField = CreateMemberNameDictionary(type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)); + fieldInfoCache.Add(type, nameToField); + } + if (!propertyInfoCache.TryGetValue(type, out nameToProperty)) + { + nameToProperty = CreateMemberNameDictionary(type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)); + propertyInfoCache.Add(type, nameToProperty); + } + + for (int i = 0; i < elems.Count; i += 2) + { + if (elems[i].Length <= 2) + continue; + string key = elems[i].Substring(1, elems[i].Length - 2); + string value = elems[i + 1]; + + FieldInfo fieldInfo; + PropertyInfo propertyInfo; + if (nameToField.TryGetValue(key, out fieldInfo)) + fieldInfo.SetValue(instance, ParseValue(fieldInfo.FieldType, value)); + else if (nameToProperty.TryGetValue(key, out propertyInfo)) + propertyInfo.SetValue(instance, ParseValue(propertyInfo.PropertyType, value), null); + } + + return instance; + } + } +} + +// https://github.com/MihaZupan/HttpToSocks5Proxy/tree/f595aa19b000025ee53081b8607db29c26740afa +namespace MihaZupan +{ + enum AddressType + { + IPv4 = 1, + DomainName = 3, + IPv6 = 4 + } + enum Authentication + { + NoAuthentication = 0, + GSSAPI = 1, + UsernamePassword = 2 + } + enum Command + { + Connect = 1, + Bind = 2, + UdpAssociate = 3 + } + enum SocketConnectionResult + { + OK = 0, + GeneralSocksServerFailure = 1, + ConnectionNotAllowedByRuleset = 2, + NetworkUnreachable = 3, + HostUnreachable = 4, + ConnectionRefused = 5, + TTLExpired = 6, + CommandNotSupported = 7, + AddressTypeNotSupported = 8, + + // Library specific + InvalidRequest = int.MinValue, + UnknownError, + AuthenticationError, + ConnectionReset, + ConnectionError, + InvalidProxyResponse + } + public interface IDnsResolver + { + IPAddress TryResolve(string hostname); + } + internal class DefaultDnsResolver : IDnsResolver + { + public IPAddress TryResolve(string hostname) + { + IPAddress result = null; + if (IPAddress.TryParse(hostname, out result)) + { + return result; + } + + try + { + result = System.Net.Dns.GetHostAddresses(hostname).FirstOrDefault(); + } + catch (SocketException) + { + // ignore + } + + return result; + } + } + internal static class ErrorResponseBuilder + { + public static string Build(SocketConnectionResult error, string httpVersion) + { + switch (error) + { + case SocketConnectionResult.AuthenticationError: + return httpVersion + "401 Unauthorized\r\n\r\n"; + + case SocketConnectionResult.HostUnreachable: + case SocketConnectionResult.ConnectionRefused: + case SocketConnectionResult.ConnectionReset: + return string.Concat(httpVersion, "502 ", error.ToString(), "\r\n\r\n"); + + default: + return string.Concat(httpVersion, "500 Internal Server Error\r\nX-Proxy-Error-Type: ", error.ToString(), "\r\n\r\n"); + } + } + } + internal static class Helpers + { + public static SocketConnectionResult ToConnectionResult(this SocketException exception) + { + if (exception.SocketErrorCode == SocketError.ConnectionRefused) + return SocketConnectionResult.ConnectionRefused; + + if (exception.SocketErrorCode == SocketError.HostUnreachable) + return SocketConnectionResult.HostUnreachable; + + return SocketConnectionResult.ConnectionError; + } + + public static bool ContainsDoubleNewLine(this byte[] buffer, int offset, int limit, out int endOfHeader) + { + const byte R = (byte)'\r'; + const byte N = (byte)'\n'; + + bool foundOne = false; + for (endOfHeader = offset; endOfHeader < limit; endOfHeader++) + { + if (buffer[endOfHeader] == N) + { + if (foundOne) + { + endOfHeader++; + return true; + } + foundOne = true; + } + else if (buffer[endOfHeader] != R) + { + foundOne = false; + } + } + + return false; + } + + private static readonly string[] HopByHopHeaders = new string[] + { + // ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers + "CONNECTION", "KEEP-ALIVE", "PROXY-AUTHENTICATE", "PROXY-AUTHORIZATION", "TE", "TRAILER", "TRANSFER-ENCODING", "UPGRADE" + }; + public static bool IsHopByHopHeader(this string header) + { + return HopByHopHeaders.Contains(header, StringComparer.OrdinalIgnoreCase); + } + + public static AddressType GetAddressType(string hostname) + { + IPAddress hostIP; + if (IPAddress.TryParse(hostname, out hostIP)) + { + if (hostIP.AddressFamily == AddressFamily.InterNetwork) + { + return AddressType.IPv4; + } + else + { + return AddressType.IPv6; + } + } + return AddressType.DomainName; + } + public static void TryDispose(this Socket socket) + { + if (socket.Connected) + { + try + { + socket.Shutdown(SocketShutdown.Send); + } + catch { } + } + try + { + socket.Close(); + } + catch { } + } + public static void TryDispose(this SocketAsyncEventArgs saea) + { + try + { + saea.UserToken = null; + saea.AcceptSocket = null; + + saea.Dispose(); + } + catch { } + } + } + public class ProxyInfo + { + /// + /// Proxy server address + /// + public readonly string Hostname; + /// + /// Proxy server port + /// + public readonly int Port; + + /// + /// Indicates whether credentials were provided for this + /// + public readonly bool Authenticate = false; + internal readonly byte[] AuthenticationMessage; + + public ProxyInfo(string hostname, int port) + { + if (string.IsNullOrEmpty(hostname)) throw new ArgumentNullException("hostname"); + if (port < 0 || port > 65535) throw new ArgumentOutOfRangeException("port"); + + Hostname = hostname; + Port = port; + } + public ProxyInfo(string hostname, int port, string username, string password) + : this(hostname, port) + { + if (string.IsNullOrEmpty(username)) throw new ArgumentNullException("username"); + if (string.IsNullOrEmpty(password)) throw new ArgumentNullException("password"); + + Authenticate = true; + AuthenticationMessage = Socks5.BuildAuthenticationMessage(username, password); + } + } + internal class SocketRelay + { + private SocketAsyncEventArgs RecSAEA, SendSAEA; + private Socket Source, Target; + private byte[] Buffer; + + public bool Receiving; + private int Received; + private int SendingOffset; + + public SocketRelay Other; + private bool Disposed = false; + private bool ShouldDispose = false; + + private SocketRelay(Socket source, Socket target) + { + Source = source; + Target = target; + Buffer = new byte[81920]; + RecSAEA = new SocketAsyncEventArgs() + { + UserToken = this + }; + SendSAEA = new SocketAsyncEventArgs() + { + UserToken = this + }; + RecSAEA.SetBuffer(Buffer, 0, Buffer.Length); + SendSAEA.SetBuffer(Buffer, 0, Buffer.Length); + RecSAEA.Completed += OnAsyncOperationCompleted; + SendSAEA.Completed += OnAsyncOperationCompleted; + Receiving = true; + } + + private void OnCleanup() + { + if (Disposed) + return; + + Disposed = ShouldDispose = true; + + Other.ShouldDispose = true; + Other = null; + + Source.TryDispose(); + Target.TryDispose(); + RecSAEA.TryDispose(); + SendSAEA.TryDispose(); + + Source = Target = null; + RecSAEA = SendSAEA = null; + Buffer = null; + } + + private void Process() + { + try + { + while (true) + { + if (ShouldDispose) + { + OnCleanup(); + return; + } + + if (Receiving) + { + Receiving = false; + SendingOffset = -1; + + if (Source.ReceiveAsync(RecSAEA)) + return; + } + else + { + if (SendingOffset == -1) + { + Received = RecSAEA.BytesTransferred; + SendingOffset = 0; + + if (Received == 0) + { + ShouldDispose = true; + continue; + } + } + else + { + SendingOffset += SendSAEA.BytesTransferred; + } + + if (SendingOffset != Received) + { + SendSAEA.SetBuffer(Buffer, SendingOffset, Received - SendingOffset); + + if (Target.SendAsync(SendSAEA)) + return; + } + else Receiving = true; + } + } + } + catch + { + OnCleanup(); + } + } + + private static void OnAsyncOperationCompleted(object _, SocketAsyncEventArgs saea) + { + var relay = saea.UserToken as SocketRelay; + relay.Process(); + } + + public static void RelayBiDirectionally(Socket s1, Socket s2) + { + var relayOne = new SocketRelay(s1, s2); + var relayTwo = new SocketRelay(s2, s1); + + relayOne.Other = relayTwo; + relayTwo.Other = relayOne; + + Task.Run(new Action(relayOne.Process)); + Task.Run(new Action(relayTwo.Process)); + } + } + internal static class Socks5 + { + public static SocketConnectionResult TryCreateTunnel(Socket socks5Socket, string destAddress, int destPort, ProxyInfo proxy, IDnsResolver dnsResolver = null) + { + try + { + // SEND HELLO + socks5Socket.Send(BuildHelloMessage(proxy.Authenticate)); + + // RECEIVE HELLO RESPONSE - HANDLE AUTHENTICATION + byte[] buffer = new byte[255]; + if (socks5Socket.Receive(buffer) != 2) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[0] != SocksVersion) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[1] == (byte)Authentication.UsernamePassword) + { + if (!proxy.Authenticate) + { + // Proxy server is requesting UserPass auth even tho we did not allow it + return SocketConnectionResult.InvalidProxyResponse; + } + else + { + // We have to try and authenticate using the Username and Password + // https://tools.ietf.org/html/rfc1929 + socks5Socket.Send(proxy.AuthenticationMessage); + if (socks5Socket.Receive(buffer) != 2) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[0] != SubnegotiationVersion) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[1] != 0) + return SocketConnectionResult.AuthenticationError; + } + } + else if (buffer[1] != (byte)Authentication.NoAuthentication) + return SocketConnectionResult.AuthenticationError; + + if (dnsResolver != null && Helpers.GetAddressType(destAddress) == AddressType.DomainName) + { + var ipAddress = dnsResolver.TryResolve(destAddress); + if (ipAddress == null) + { + return SocketConnectionResult.HostUnreachable; + } + + destAddress = ipAddress.ToString(); + } + + // SEND REQUEST + socks5Socket.Send(BuildRequestMessage(Command.Connect, Helpers.GetAddressType(destAddress), destAddress, destPort)); + + // RECEIVE RESPONSE + int received = socks5Socket.Receive(buffer); + if (received < 8) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[0] != SocksVersion) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[1] > 8) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[1] != 0) + return (SocketConnectionResult)buffer[1]; + if (buffer[2] != 0) + return SocketConnectionResult.InvalidProxyResponse; + if (buffer[3] != 1 && buffer[3] != 3 && buffer[3] != 4) + return SocketConnectionResult.InvalidProxyResponse; + + AddressType boundAddress = (AddressType)buffer[3]; + if (boundAddress == AddressType.IPv4) + { + if (received != 10) + return SocketConnectionResult.InvalidProxyResponse; + } + else if (boundAddress == AddressType.IPv6) + { + if (received != 22) + return SocketConnectionResult.InvalidProxyResponse; + } + else + { + int domainLength = buffer[4]; + if (received != 7 + domainLength) + return SocketConnectionResult.InvalidProxyResponse; + } + + return SocketConnectionResult.OK; + } + catch (SocketException ex) + { + return ex.ToConnectionResult(); + } + catch + { + return SocketConnectionResult.UnknownError; + } + } + + private const byte SubnegotiationVersion = 0x01; + private const byte SocksVersion = 0x05; + + private static byte[] BuildHelloMessage(bool doUsernamePasswordAuth) + { + byte[] hello = new byte[doUsernamePasswordAuth ? 4 : 3]; + hello[0] = SocksVersion; + hello[1] = (byte)(doUsernamePasswordAuth ? 2 : 1); + hello[2] = (byte)Authentication.NoAuthentication; + if (doUsernamePasswordAuth) + { + hello[3] = (byte)Authentication.UsernamePassword; + } + return hello; + } + private static byte[] BuildRequestMessage(Command command, AddressType addressType, string address, int port) + { + int addressLength; + byte[] addressBytes; + switch (addressType) + { + case AddressType.IPv4: + case AddressType.IPv6: + addressBytes = IPAddress.Parse(address).GetAddressBytes(); + addressLength = addressBytes.Length; + break; + + case AddressType.DomainName: + byte[] domainBytes = Encoding.UTF8.GetBytes(address); + addressLength = 1 + domainBytes.Length; + addressBytes = new byte[addressLength]; + addressBytes[0] = (byte)domainBytes.Length; + Array.Copy(domainBytes, 0, addressBytes, 1, domainBytes.Length); + break; + + default: + throw new ArgumentException("Unknown address type"); + } + + byte[] request = new byte[6 + addressLength]; + request[0] = SocksVersion; + request[1] = (byte)command; + //request[2] = 0x00; + request[3] = (byte)addressType; + Array.Copy(addressBytes, 0, request, 4, addressLength); + request[request.Length - 2] = (byte)(port / 256); + request[request.Length - 1] = (byte)(port % 256); + return request; + } + public static byte[] BuildAuthenticationMessage(string username, string password) + { + byte[] usernameBytes = Encoding.UTF8.GetBytes(username); + if (usernameBytes.Length > 255) throw new ArgumentOutOfRangeException("Username is too long"); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + if (passwordBytes.Length > 255) throw new ArgumentOutOfRangeException("Password is too long"); + + byte[] authMessage = new byte[3 + usernameBytes.Length + passwordBytes.Length]; + authMessage[0] = SubnegotiationVersion; + authMessage[1] = (byte)usernameBytes.Length; + Array.Copy(usernameBytes, 0, authMessage, 2, usernameBytes.Length); + authMessage[2 + usernameBytes.Length] = (byte)passwordBytes.Length; + Array.Copy(passwordBytes, 0, authMessage, 3 + usernameBytes.Length, passwordBytes.Length); + return authMessage; + } + } + /// + /// Presents itself as an HTTP(s) proxy, but connects to a SOCKS5 proxy behind-the-scenes + /// + public class HttpToSocks5Proxy : IWebProxy + { + /// + /// Ignored by this implementation + /// + public ICredentials Credentials { get; set; } + /// + /// Returned is constant for a single instance + /// Address is a local address, the port is + /// + /// Ignored by this implementation + /// + public Uri GetProxy(Uri destination) { return ProxyUri; } + /// + /// Always returns false + /// + /// Ignored by this implementation + /// + public bool IsBypassed(Uri host) { return false; } + /// + /// The port on which the internal server is listening + /// + public int InternalServerPort { get; private set; } + + /// + /// A custom domain name resolver + /// + public IDnsResolver DnsResolver + { + set + { + if (value != null) + { + dnsResolver = value; + } + else + { + throw new ArgumentNullException("value"); + } + } + } + private IDnsResolver dnsResolver; + + private readonly Uri ProxyUri; + private readonly Socket InternalServerSocket; + + private readonly ProxyInfo[] ProxyList; + + /// + /// Controls whether domain names are resolved locally or passed to the proxy server for evaluation + /// False by default + /// + public bool ResolveHostnamesLocally = false; + + #region Constructors + /// + /// Create an Http(s) to Socks5 proxy using no authentication + /// + /// IP address or hostname of the Socks5 proxy server + /// Port of the Socks5 proxy server + /// The port to listen on with the internal server, 0 means it is selected automatically + public HttpToSocks5Proxy(string socks5Hostname, int socks5Port, int internalServerPort = 0) + : this(new[] { new ProxyInfo(socks5Hostname, socks5Port) }, internalServerPort) { } + + /// + /// Create an Http(s) to Socks5 proxy using username and password authentication + /// Note that many public Socks5 servers don't actually require a username and password + /// + /// IP address or hostname of the Socks5 proxy server + /// Port of the Socks5 proxy server + /// Username for the Socks5 server authentication + /// Password for the Socks5 server authentication + /// The port to listen on with the internal server, 0 means it is selected automatically + public HttpToSocks5Proxy(string socks5Hostname, int socks5Port, string username, string password, int internalServerPort = 0) + : this(new[] { new ProxyInfo(socks5Hostname, socks5Port, username, password) }, internalServerPort) { } + + /// + /// Create an Http(s) to Socks5 proxy using one or multiple chained proxies + /// + /// List of proxies to route through + /// The port to listen on with the internal server, 0 means it is selected automatically + public HttpToSocks5Proxy(ProxyInfo[] proxyList, int internalServerPort = 0) + { + if (internalServerPort < 0 || internalServerPort > 65535) throw new ArgumentOutOfRangeException("internalServerPort"); + if (proxyList == null) throw new ArgumentNullException("proxyList"); + if (proxyList.Length == 0) throw new ArgumentException("proxyList is empty", "proxyList"); + if (proxyList.Any(p => p == null)) throw new ArgumentNullException("proxyList", "Proxy in proxyList is null"); + + ProxyList = proxyList; + InternalServerPort = internalServerPort; + dnsResolver = new DefaultDnsResolver(); + + InternalServerSocket = CreateSocket(); + InternalServerSocket.Bind(new IPEndPoint(IPAddress.Any, InternalServerPort)); + + if (InternalServerPort == 0) + InternalServerPort = ((IPEndPoint)(InternalServerSocket.LocalEndPoint)).Port; + + ProxyUri = new Uri("http://127.0.0.1:" + InternalServerPort); + InternalServerSocket.Listen(8); + InternalServerSocket.BeginAccept(OnAcceptCallback, null); + } + #endregion + + private void OnAcceptCallback(IAsyncResult AR) + { + if (Stopped) return; + + Socket clientSocket = null; + try + { + clientSocket = InternalServerSocket.EndAccept(AR); + } + catch { } + + try + { + InternalServerSocket.BeginAccept(OnAcceptCallback, null); + } + catch { StopInternalServer(); } + + if (clientSocket != null) + HandleRequest(clientSocket); + } + private void HandleRequest(Socket clientSocket) + { + Socket socks5Socket = null; + bool success = true; + + try + { + string hostname; + int port; + string httpVersion; + bool connect; + string request; + byte[] overRead; + if (TryReadTarget(clientSocket, out hostname, out port, out httpVersion, out connect, out request, out overRead)) + { + try + { + socks5Socket = CreateSocket(); + socks5Socket.Connect(dnsResolver.TryResolve(ProxyList[0].Hostname), ProxyList[0].Port); + } + catch (SocketException ex) + { + SendError(clientSocket, ex.ToConnectionResult()); + success = false; + } + catch (Exception) + { + SendError(clientSocket, SocketConnectionResult.UnknownError); + success = false; + } + + if (success) + { + SocketConnectionResult result; + for (int i = 0; i < ProxyList.Length - 1; i++) + { + var proxy = ProxyList[i]; + var nextProxy = ProxyList[i + 1]; + result = Socks5.TryCreateTunnel(socks5Socket, nextProxy.Hostname, nextProxy.Port, proxy, ResolveHostnamesLocally ? dnsResolver : null); + if (result != SocketConnectionResult.OK) + { + SendError(clientSocket, result, httpVersion); + success = false; + break; + } + } + + if (success) + { + var lastProxy = ProxyList.Last(); + result = Socks5.TryCreateTunnel(socks5Socket, hostname, port, lastProxy, ResolveHostnamesLocally ? dnsResolver : null); + if (result != SocketConnectionResult.OK) + { + SendError(clientSocket, result, httpVersion); + success = false; + } + else + { + if (!connect) + { + SendString(socks5Socket, request); + if (overRead != null) + { + socks5Socket.Send(overRead, SocketFlags.None); + } + } + else + { + SendString(clientSocket, httpVersion + "200 Connection established\r\nProxy-Agent: MihaZupan-HttpToSocks5Proxy\r\n\r\n"); + } + } + } + } + } + else success = false; + } + catch + { + success = false; + try + { + SendError(clientSocket, SocketConnectionResult.UnknownError); + } + catch { } + } + finally + { + if (success) + { + SocketRelay.RelayBiDirectionally(socks5Socket, clientSocket); + } + else + { + clientSocket.TryDispose(); + socks5Socket.TryDispose(); + } + } + } + + private static bool TryReadTarget(Socket clientSocket, out string hostname, out int port, out string httpVersion, out bool connect, out string request, out byte[] overReadBuffer) + { + hostname = null; + port = -1; + httpVersion = null; + connect = false; + request = null; + + string headerString; + if (!TryReadHeaders(clientSocket, out headerString, out overReadBuffer)) + return false; + + List headerLines = headerString.Split('\n').Select(i => i.TrimEnd('\r')).Where(i => i.Length > 0).ToList(); + string[] methodLine = headerLines[0].Split(' '); + if (methodLine.Length != 3) // METHOD URI HTTP/X.Y + { + SendError(clientSocket, SocketConnectionResult.InvalidRequest); + return false; + } + string method = methodLine[0]; + httpVersion = methodLine[2].Trim() + " "; + connect = method.Equals("Connect", StringComparison.OrdinalIgnoreCase); + string hostHeader = null; + + #region Host header + if (connect) + { + foreach (var headerLine in headerLines) + { + int colon = headerLine.IndexOf(':'); + if (colon == -1) + { + SendError(clientSocket, SocketConnectionResult.InvalidRequest, httpVersion); + return false; + } + string headerName = headerLine.Substring(0, colon).Trim(); + if (headerName.Equals("Host", StringComparison.OrdinalIgnoreCase)) + { + hostHeader = headerLine.Substring(colon + 1).Trim(); + break; + } + } + } + else + { + var hostUri = new Uri(methodLine[1]); + + StringBuilder requestBuilder = new StringBuilder(); + + requestBuilder.Append(methodLine[0]); + requestBuilder.Append(' '); + requestBuilder.Append(hostUri.PathAndQuery); + requestBuilder.Append(hostUri.Fragment); + requestBuilder.Append(' '); + requestBuilder.Append(methodLine[2]); + + for (int i = 1; i < headerLines.Count; i++) + { + int colon = headerLines[i].IndexOf(':'); + if (colon == -1) continue; // Invalid header found (no colon separator) - skip it instead of aborting the connection + string headerName = headerLines[i].Substring(0, colon).Trim(); + + if (headerName.Equals("Host", StringComparison.OrdinalIgnoreCase)) + { + hostHeader = headerLines[i].Substring(colon + 1).Trim(); + requestBuilder.Append("\r\n"); + requestBuilder.Append(headerLines[i]); + } + else if (!headerName.IsHopByHopHeader()) + { + requestBuilder.Append("\r\n"); + requestBuilder.Append(headerLines[i]); + } + } + if (hostHeader == null) + { + // Desperate attempt at salvaging a connection without a host header + requestBuilder.Append("\r\nHost: "); + requestBuilder.Append(hostUri.Host); + } + requestBuilder.Append("\r\n\r\n"); + request = requestBuilder.ToString(); + } + #endregion Host header + + #region Hostname and port + port = connect ? 443 : 80; + + if (string.IsNullOrEmpty(hostHeader)) + { + // Host was not found in the host header + string requestTarget = methodLine[1]; + hostname = requestTarget; + int colon = requestTarget.LastIndexOf(':'); + if (colon != -1) + { + if (int.TryParse(requestTarget.Substring(colon + 1), out port)) + { + // A port was specified in the first line (method line) + hostname = requestTarget.Substring(0, colon); + } + else port = connect ? 443 : 80; + } + } + else + { + int colon = hostHeader.LastIndexOf(':'); + if (colon == -1) + { + // Host was found in the header, but we'll still look for a port in the method line + hostname = hostHeader; + string requestTarget = methodLine[1]; + colon = requestTarget.LastIndexOf(':'); + if (colon != -1) + { + if (!int.TryParse(requestTarget.Substring(colon + 1), out port)) + port = connect ? 443 : 80; + } + } + else + { + // Host was found in the header, it could also contain a port + hostname = hostHeader.Substring(0, colon); + if (!int.TryParse(hostHeader.Substring(colon + 1), out port)) + port = connect ? 443 : 80; + } + } + #endregion Hostname and port + + return true; + } + private static bool TryReadHeaders(Socket clientSocket, out string headers, out byte[] overRead) + { + headers = null; + overRead = null; + + var headersBuffer = new byte[8192]; + int received = 0; + int left = 8192; + int offset; + int endOfHeader; + // According to https://stackoverflow.com/a/686243/6845657 even Apache gives up after 8KB + + do + { + if (left == 0) + { + SendError(clientSocket, SocketConnectionResult.InvalidRequest); + return false; + } + offset = received; + int read = clientSocket.Receive(headersBuffer, received, left, SocketFlags.None); + if (read == 0) + { + return false; + } + received += read; + left -= read; + } + // received - 3 is used because we could have read the start of the double new line in the previous read + while (!headersBuffer.ContainsDoubleNewLine(Math.Max(0, offset - 3), received, out endOfHeader)); + + headers = Encoding.ASCII.GetString(headersBuffer, 0, endOfHeader); + + if (received != endOfHeader) + { + int overReadCount = received - endOfHeader; + overRead = new byte[overReadCount]; + Array.Copy(headersBuffer, endOfHeader, overRead, 0, overReadCount); + } + + return true; + } + + private static void SendString(Socket socket, string text) + { + socket.Send(Encoding.UTF8.GetBytes(text)); + } + private static void SendError(Socket socket, SocketConnectionResult error, string httpVersion = "HTTP/1.1 ") + { + SendString(socket, ErrorResponseBuilder.Build(error, httpVersion)); + } + + private static Socket CreateSocket() + { + return new Socket(SocketType.Stream, ProtocolType.Tcp); + } + + private bool Stopped = false; + public void StopInternalServer() + { + if (Stopped) return; + Stopped = true; + InternalServerSocket.Close(); + } + } +} \ No newline at end of file diff --git a/proxy/proxy-server.exe b/proxy/proxy-server.exe new file mode 100644 index 0000000..720183f Binary files /dev/null and b/proxy/proxy-server.exe differ diff --git a/strip-alt/strip-alt-ublock-origin.js b/strip-alt/strip-alt-ublock-origin.js index 11f4949..c22a072 100644 --- a/strip-alt/strip-alt-ublock-origin.js +++ b/strip-alt/strip-alt-ublock-origin.js @@ -141,13 +141,11 @@ twitch-videoad.js application/javascript StreamInfos[channelName] = streamInfo = {}; } streamInfo.ChannelName = channelName; - streamInfo.Urls = []; streamInfo.AdUrlCache = []; streamInfo.IsMidroll = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); StreamInfosByUrl[lines[i]] = streamInfo; } } diff --git a/strip-alt/strip-alt.user.js b/strip-alt/strip-alt.user.js index d3ffa68..6f8aa6d 100644 --- a/strip-alt/strip-alt.user.js +++ b/strip-alt/strip-alt.user.js @@ -152,13 +152,11 @@ StreamInfos[channelName] = streamInfo = {}; } streamInfo.ChannelName = channelName; - streamInfo.Urls = []; streamInfo.AdUrlCache = []; streamInfo.IsMidroll = false; var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); StreamInfosByUrl[lines[i]] = streamInfo; } }