diff --git a/README.md b/README.md
index 0d31c47..fa5a05c 100644
--- a/README.md
+++ b/README.md
@@ -13,9 +13,9 @@ Proxies are the most reliable way of avoiding ads ([buffering / downtime info](f
Alternatively:
-- `Video Ad-Block, for Twitch` (forked) - [chrome (manual install)](https://github.com/cleanlock/VideoAdBlockForTwitch#installation-steps) / [code](https://github.com/cleanlock/VideoAdBlockForTwitch)
+- `Video Ad-Block, for Twitch` (fork) - [chrome (manual install)](https://github.com/cleanlock/VideoAdBlockForTwitch#installation-steps) / [code](https://github.com/cleanlock/VideoAdBlockForTwitch)
- `Alternate Player for Twitch.tv` - [chrome](https://chrome.google.com/webstore/detail/alternate-player-for-twit/bhplkbgoehhhddaoolmakpocnenplmhf) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/twitch_5/)
-- `notify-strip` - see below
+- `notify-strip` / `notify-swap` / `vaft` - see below
[Read this for a full list and descriptions.](full-list.md)
@@ -27,15 +27,14 @@ Alternatively:
- Ad segments are replaced by low resolution stream segments.
- Notifies Twitch that ads were "watched" (reduces preroll ad frequency).
- *You may experience a small jump in time when the regular stream kicks in*.
-- notify-reload ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js))
- - Notifies that ads were watched, then reloads the player (preroll only, falls back to `notify-strip` on midroll).
- - Repeats this until no ads **(which may never happen ~ infinite reload)**.
- - You should expect 3-10 player reloads (give or take). Once successful you shouldn't see preroll ads for a while on any stream (10+ minutes?).
+- notify-swap ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-swap/notify-swap-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-swap/notify-swap.user.js))
+ - The same as `notify-strip` with a slightly different method to fix freezing issues (especially on Firefox).
+ - *Has a longer jump in time compared to `notify-strip`*.
+- vaft ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js))
+ - `Video Ad-Block, for Twitch` (fork) as a script.
- low-res ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res.user.js))
- No ads.
- The stream is 480p for the duration of the stream.
-- bypass ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/bypass/bypass-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/bypass/bypass.user.js))
- - No ads *(no longer works for many people)*.
## Applying a script (uBlock Origin)
diff --git a/full-list.md b/full-list.md
index 3562e25..889f478 100644
--- a/full-list.md
+++ b/full-list.md
@@ -4,7 +4,7 @@
- Uses a proxy on the main m3u8 file to get a stream without ads.
- `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [code](https://github.com/arthurbolsoni/Purple-adblock/)
- Uses a proxy on the main m3u8 file to get a stream without ads.
-- `Video Ad-Block, for Twitch` (forked) - [chrome (manual install)](https://github.com/cleanlock/VideoAdBlockForTwitch#installation-steps) / [code](https://github.com/cleanlock/VideoAdBlockForTwitch)
+- `Video Ad-Block, for Twitch` (fork) - [chrome (manual install)](https://github.com/cleanlock/VideoAdBlockForTwitch#installation-steps) / [code](https://github.com/cleanlock/VideoAdBlockForTwitch)
- Replaces ad segments with ad-free segments (480p resolution). Afterwards it invokes a pause/play to resync the player which then continues normally (normal resolution).
- `Alternate Player for Twitch.tv` - [chrome](https://chrome.google.com/webstore/detail/alternate-player-for-twit/bhplkbgoehhhddaoolmakpocnenplmhf) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/twitch_5/)
- Removes ad segments (no playback until ad-free stream).
@@ -21,7 +21,7 @@
*Compile from source*
-- `city17` - [server code](https://github.com/AlyoshaVasilieva/city17) / [extension code](https://github.com/AlyoshaVasilieva/city17-ext)
+- `luminous-ttv` - [server code](https://github.com/AlyoshaVasilieva/luminous-ttv) / [extension code](https://github.com/AlyoshaVasilieva/luminous-ttv-ext)
- Uses a proxy on the main m3u8 file to get a stream without ads.
## Web browser scripts (uBlock Origin / userscript)
@@ -40,17 +40,13 @@
## Applications / third party websites
- `streamlink` - [code](https://github.com/streamlink/streamlink) / [website](https://streamlink.github.io/streamlink-twitch-gui/)
- Removes ad segments (no playback until ad-free stream).
-- `multiChat for Twitch` - [android](https://play.google.com/store/apps/details?id=org.mchatty)
- - Unsure how this one blocks ads, but it claims that it does.
+- `Xtra for Twitch` (fork) - [apks](https://github.com/crackededed/Xtra/releases) [code](https://github.com/crackededed/Xtra)
+ - Android app. I think this blocks ads, but I'm not 100% sure. If not maybe try [Twire](https://github.com/twireapp/Twire).
- https://twitchls.com/
- Uses the `embed` player. Purple screen may display every 10-15 mins.
- https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/
- Some countries don't get ads. A simple VPN/VPS could be used to block ads by proxying the m3u8 without having to proxy all your traffic (just the initial m3u8).
-## Additional lists
-
-- https://github.com/saucettv/WorkingTwitchAdBlockers
-
## Proxy issues
Proxy solutions can have downtime and you'll either see ads or error 2000. This isn't Twitch retaliating.
diff --git a/notify-swap/notify-swap-ublock-origin.js b/notify-swap/notify-swap-ublock-origin.js
new file mode 100644
index 0000000..be55fd8
--- /dev/null
+++ b/notify-swap/notify-swap-ublock-origin.js
@@ -0,0 +1,664 @@
+twitch-videoad.js application/javascript
+(function() {
+ if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
+ function declareOptions(scope) {
+ // Options / globals
+ scope.OPT_ROLLING_DEVICE_ID = true;
+ scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
+ scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
+ scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false;
+ scope.OPT_BACKUP_PLAYER_TYPE = 'thunderdome';//'picture-by-picture';'thunderdome';
+ scope.OPT_REGULAR_PLAYER_TYPE = 'site';
+ scope.OPT_INITIAL_M3U8_ATTEMPTS = 1;
+ scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
+ scope.AD_SIGNIFIER = 'stitched-ad';
+ scope.LIVE_SIGNIFIER = ',live';
+ scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
+ // These are only really for Worker scope...
+ scope.StreamInfos = [];
+ scope.StreamInfosByUrl = [];
+ scope.CurrentChannelNameFromM3U8 = null;
+ // Need this in both scopes. Window scope needs to update this to worker scope.
+ scope.gql_device_id = null;
+ scope.gql_device_id_rolling = '';
+ // Rolling device id crap... TODO: improve this
+ var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); }
+ var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC';
+ var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0;
+ for (var i = 0; i < bs.length; i++) {
+ scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length];
+ }
+ }
+ declareOptions(window);
+ var twitchMainWorker = null;
+ const oldWorker = window.Worker;
+ window.Worker = class Worker extends oldWorker {
+ constructor(twitchBlobUrl) {
+ if (twitchMainWorker) {
+ super(twitchBlobUrl);
+ return;
+ }
+ var jsURL = getWasmWorkerUrl(twitchBlobUrl);
+ if (typeof jsURL !== 'string') {
+ super(twitchBlobUrl);
+ return;
+ }
+ var newBlobStr = `
+ ${processM3U8.toString()}
+ ${hookWorkerFetch.toString()}
+ ${declareOptions.toString()}
+ ${getAccessToken.toString()}
+ ${gqlRequest.toString()}
+ ${makeGraphQlPacket.toString()}
+ ${tryNotifyAdsWatchedM3U8.toString()}
+ ${parseAttributes.toString()}
+ declareOptions(self);
+ self.addEventListener('message', function(e) {
+ if (e.data.key == 'UboUpdateDeviceId') {
+ gql_device_id = e.data.value;
+ }
+ });
+ hookWorkerFetch();
+ importScripts('${jsURL}');
+ `
+ super(URL.createObjectURL(new Blob([newBlobStr])));
+ twitchMainWorker = this;
+ this.onmessage = function(e) {
+ // NOTE: Removed adDiv caching as '.video-player' can change between streams?
+ if (e.data.key == 'UboShowAdBanner') {
+ var adDiv = getAdDiv();
+ if (adDiv != null) {
+ adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...';
+ adDiv.style.display = 'block';
+ }
+ }
+ else if (e.data.key == 'UboHideAdBanner') {
+ var adDiv = getAdDiv();
+ if (adDiv != null) {
+ adDiv.style.display = 'none';
+ }
+ }
+ else if (e.data.key == 'UboChannelNameM3U8Changed') {
+ //console.log('M3U8 channel name changed to ' + e.data.value);
+ }
+ else if (e.data.key == 'UboReloadPlayer') {
+ reloadTwitchPlayer();
+ }
+ else if (e.data.key == 'UboPauseResumePlayer') {
+ reloadTwitchPlayer(false, true);
+ }
+ else if (e.data.key == 'UboSeekPlayer') {
+ reloadTwitchPlayer(true);
+ }
+ }
+ function getAdDiv() {
+ var playerRootDiv = document.querySelector('.video-player');
+ var adDiv = null;
+ if (playerRootDiv != null) {
+ adDiv = playerRootDiv.querySelector('.ubo-overlay');
+ if (adDiv == null) {
+ adDiv = document.createElement('div');
+ adDiv.className = 'ubo-overlay';
+ adDiv.innerHTML = '
';
+ adDiv.style.display = 'none';
+ adDiv.P = adDiv.querySelector('p');
+ playerRootDiv.appendChild(adDiv);
+ }
+ }
+ return adDiv;
+ }
+ }
+ }
+ function getWasmWorkerUrl(twitchBlobUrl) {
+ var req = new XMLHttpRequest();
+ req.open('GET', twitchBlobUrl, false);
+ req.send();
+ return req.responseText.split("'")[1];
+ }
+ async function processM3U8(url, textStr, realFetch) {
+ var streamInfo = StreamInfosByUrl[url];
+ if (streamInfo == null) {
+ console.log('Unknown stream url ' + url);
+ //postMessage({key:'UboHideAdBanner'});
+ return textStr;
+ }
+ if (!OPT_MODE_STRIP_AD_SEGMENTS) {
+ return textStr;
+ }
+ var haveAdTags = textStr.includes(AD_SIGNIFIER);
+ if (streamInfo.IsLowResNoAds) {
+ var wasBackupNull = streamInfo.BackupUrl == null;
+ for (var i = 0; i < 2; i++) {
+ try {
+ if (i != 0 && streamInfo.IsMidroll) {
+ // Doesn't work well with midrolls (often wont see the ad until a few requests in, which creates a reload loop)
+ continue;
+ }
+ let index = i;
+ var targetUrl = index == 0 ? streamInfo.BackupUrl : null;
+ if (index != 0 || (streamInfo.BackupUrl == null && !streamInfo.IsRequestingBackup)) {
+ // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this.
+ if (index == 0) {
+ streamInfo.IsRequestingBackup = true;
+ }
+ var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE);
+ if (accessTokenResponse.status === 200) {
+ var accessToken = await accessTokenResponse.json();
+ var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
+ urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
+ urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
+ var encodingsM3u8Response = await realFetch(urlInfo.href);
+ if (encodingsM3u8Response.status === 200) {
+ // TODO: Maybe look for the most optimal m3u8
+ var encodingsM3u8 = await encodingsM3u8Response.text();
+ var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
+ // Maybe this request is a bit unnecessary
+ var streamM3u8Response = await realFetch(streamM3u8Url);
+ if (streamM3u8Response.status == 200) {
+ targetUrl = streamM3u8Url;
+ streamInfo.BackupEncodings[index] = encodingsM3u8;
+ if (index == 0) {
+ streamInfo.BackupUrl = streamM3u8Url;
+ streamInfo.IsRequestingBackup = false;
+ console.log('Fetched backup url: ' + streamInfo.BackupUrl);
+ }
+ } else {
+ console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status);
+ }
+ } else {
+ console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status);
+ }
+ } else {
+ console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status);
+ }
+ }
+ if (targetUrl != null) {
+ var backupM3u8 = null;
+ var backupM3u8Response = await realFetch(targetUrl);
+ if (backupM3u8Response.status == 200) {
+ backupM3u8 = await backupM3u8Response.text();
+ }
+ if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
+ if (streamInfo.LastBackupRequestWithoutAds[index] < Date.now() - 1333) {
+ streamInfo.LastBackupRequestWithoutAds[index] = Date.now();
+ streamInfo.NumBackupRequestWithoutAds[index]++;
+ }
+ } else {
+ // TODO: Throttle this. Currently this sends way too many requests.
+ if (index == 0 && !streamInfo.IsMidroll) {
+ /*await */tryNotifyAdsWatchedM3U8(backupM3u8);
+ }
+ streamInfo.NumBackupRequestWithoutAds[index] = 0;
+ }
+ }
+ } catch (err) {
+ console.log('Fetching backup m3u8 failed');
+ console.log(err);
+ }
+ if (wasBackupNull) {
+ continue;
+ }
+ }
+ if (streamInfo.NumBackupRequestWithoutAds[0] >= 3 || streamInfo.NumBackupRequestWithoutAds[1] >= 4) {
+ console.log('No more ads ' + streamInfo.NumBackupRequestWithoutAds[0] + ' ' + streamInfo.NumBackupRequestWithoutAds[1]);
+ postMessage({key:'UboHideAdBanner'});
+ streamInfo.SwappedEncodings = streamInfo.BackupEncodings[streamInfo.NumBackupRequestWithoutAds[0] >= 3 ? 0 : 1];
+ streamInfo.SwappedEncodingsTime = Date.now();
+ streamInfo.IsLowResNoAds = false;
+ postMessage({key:'UboReloadPlayer'});
+ }
+ if (haveAdTags) {
+ console.log('Double dipping ads?');
+ return '';
+ }
+ } else if (haveAdTags) {
+ console.log('Found ads, switch to low res');
+ streamInfo.IsLowResNoAds = true;
+ streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
+ postMessage({key:'UboReloadPlayer'});
+ return '';
+ } else {
+ postMessage({key:'UboHideAdBanner'});
+ }
+ return textStr;
+ }
+ function hookWorkerFetch() {
+ console.log('hookWorkerFetch');
+ var realFetch = fetch;
+ fetch = async function(url, options) {
+ if (typeof url === 'string') {
+ url = url.trimEnd();
+ if (url.endsWith('m3u8')) {
+ return new Promise(function(resolve, reject) {
+ var processAfter = async function(response) {
+ var str = await processM3U8(url, await response.text(), realFetch);
+ resolve(new Response(str));
+ };
+ var send = function() {
+ return realFetch(url, options).then(function(response) {
+ processAfter(response);
+ })['catch'](function(err) {
+ console.log('fetch hook err ' + err);
+ reject(err);
+ });
+ };
+ send();
+ });
+ }
+ else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) {
+ var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
+ if (CurrentChannelNameFromM3U8 != channelName) {
+ postMessage({
+ key: 'UboChannelNameM3U8Changed',
+ value: channelName
+ });
+ }
+ CurrentChannelNameFromM3U8 = channelName;
+ if (OPT_MODE_STRIP_AD_SEGMENTS) {
+ return new Promise(async function(resolve, reject) {
+ // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
+ // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
+ var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS;
+ var attempts = 0;
+ while(true) {
+ var encodingsM3u8Response = await realFetch(url, options);
+ if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
+ var encodingsM3u8 = await encodingsM3u8Response.text();
+ var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
+ var streamM3u8Response = await realFetch(streamM3u8Url);
+ var streamM3u8 = await streamM3u8Response.text();
+ if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) {
+ if (maxAttempts > 1 && attempts >= maxAttempts) {
+ console.log('max skip ad attempts reached (attempt #' + attempts + ')');
+ }
+ var hasSwappedEncodings = false;
+ var streamInfo = StreamInfos[channelName];
+ if (streamInfo == null) {
+ StreamInfos[channelName] = streamInfo = {};
+ } else if (streamInfo.SwappedEncodings != null && streamInfo.SwappedEncodingsTime >= Date.now() - 10000) {
+ encodingsM3u8 = streamInfo.SwappedEncodings;
+ hasSwappedEncodings = true;
+ }
+ var forcedLowRes = false;
+ var existingStreamInfo = StreamInfos[channelName];
+ if (existingStreamInfo.IsLowResNoAds || (!hasSwappedEncodings && streamM3u8.includes(AD_SIGNIFIER))) {
+ var accessTokenResponse = await getAccessToken(channelName, OPT_BACKUP_PLAYER_TYPE);
+ if (accessTokenResponse.status === 200) {
+ var accessToken = await accessTokenResponse.json();
+ var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8' + (new URL(url)).search);
+ urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
+ urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
+ var lowResEncodingsM3u8Response = await realFetch(urlInfo.href);
+ if (lowResEncodingsM3u8Response != null && lowResEncodingsM3u8Response.status === 200) {
+ var lowResEncodingsM3u8 = await lowResEncodingsM3u8Response.text();
+ var lowResLines = lowResEncodingsM3u8.replace('\r', '').split('\n');
+ var lowResBestUrl = null;
+ for (var i = 0; i < lowResLines.length; i++) {
+ if (lowResLines[i].startsWith('#EXT-X-STREAM-INF')) {
+ var res = parseAttributes(lowResLines[i])['RESOLUTION'];
+ if (res && lowResLines[i + 1].endsWith('.m3u8')) {
+ // Assumes resolutions are correctly ordered
+ lowResBestUrl = lowResLines[i + 1];
+ break;
+ }
+ }
+ }
+ if (lowResBestUrl != null) {
+ var normalEncodingsM3u8 = encodingsM3u8;
+ var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n');
+ for (var i = 0; i < normalLines.length - 1; i++) {
+ if (normalLines[i].startsWith('#EXT-X-STREAM-INF')) {
+ var res = parseAttributes(normalLines[i])['RESOLUTION'];
+ if (res) {
+ lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique
+ normalLines[i + 1] = lowResBestUrl;
+ }
+ }
+ }
+ encodingsM3u8 = normalLines.join('\r\n');
+ } else {
+ encodingsM3u8 = lowResEncodingsM3u8;
+ }
+ forcedLowRes = true;
+ postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
+ }
+ }
+ }
+ // This might potentially backfire... maybe just add the new urls
+ streamInfo.ChannelName = channelName;
+ streamInfo.RootM3U8Url = url;
+ streamInfo.RootM3U8Params = (new URL(url)).search;
+ streamInfo.BackupUrl = null;
+ streamInfo.BackupEncodings = [null,null];
+ streamInfo.IsRequestingBackup = false;
+ streamInfo.NumBackupRequestWithoutAds = [0,0];
+ streamInfo.LastBackupRequestWithoutAds = [0,0];
+ streamInfo.SwappedEncodings = null;
+ streamInfo.SwappedEncodingsTime = 0;
+ streamInfo.IsMidroll = !!streamInfo.IsMidroll;
+ streamInfo.IsLowResNoAds = forcedLowRes;
+ var lines = encodingsM3u8.replace('\r', '').split('\n');
+ for (var i = 0; i < lines.length; i++) {
+ if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
+ StreamInfosByUrl[lines[i].trimEnd()] = streamInfo;
+ }
+ }
+ resolve(new Response(encodingsM3u8));
+ break;
+ }
+ console.log('attempt to skip ad (attempt #' + attempts + ')');
+ } else {
+ // Stream is offline?
+ resolve(encodingsM3u8Response);
+ break;
+ }
+ }
+ });
+ }
+ }
+ }
+ return realFetch.apply(this, arguments);
+ }
+ }
+ function makeGraphQlPacket(event, radToken, payload) {
+ return [{
+ operationName: 'ClientSideAdEventHandling_RecordAdEvent',
+ variables: {
+ input: {
+ eventName: event,
+ eventPayload: JSON.stringify(payload),
+ radToken,
+ },
+ },
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b',
+ },
+ },
+ }];
+ }
+ function getAccessToken(channelName, playerType, realFetch) {
+ var body = null;
+ var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
+ body = {
+ operationName: 'PlaybackAccessToken_Template',
+ query: templateQuery,
+ variables: {
+ 'isLive': true,
+ 'login': channelName,
+ 'isVod': false,
+ 'vodID': '',
+ 'playerType': playerType
+ }
+ };
+ return gqlRequest(body, realFetch);
+ }
+ function gqlRequest(body, realFetch) {
+ var fetchFunc = realFetch ? realFetch : fetch;
+ return fetchFunc('https://gql.twitch.tv/gql', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ headers: {
+ 'client-id': CLIENT_ID,
+ 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id
+ }
+ });
+ }
+ function parseAttributes(str) {
+ return Object.fromEntries(
+ str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/)
+ .filter(Boolean)
+ .map(x => {
+ const idx = x.indexOf('=');
+ const key = x.substring(0, idx);
+ const value = x.substring(idx +1);
+ const num = Number(value);
+ return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]
+ }));
+ }
+ async function tryNotifyAdsWatchedM3U8(streamM3u8) {
+ try {
+ //console.log(streamM3u8);
+ if (!streamM3u8 || !streamM3u8.includes(AD_SIGNIFIER)) {
+ return 1;
+ }
+ var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/);
+ if (matches.length > 1) {
+ const attrString = matches[1];
+ const attr = parseAttributes(attrString);
+ var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1');
+ var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0');
+ var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN'];
+ var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID'];
+ var orderId = attr['X-TV-TWITCH-AD-ORDER-ID'];
+ var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID'];
+ var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID'];
+ var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase();
+ const baseData = {
+ stitched: true,
+ roll_type: rollType,
+ player_mute: false,
+ player_volume: 0.5,
+ visible: true,
+ };
+ for (let podPosition = 0; podPosition < podLength; podPosition++) {
+ if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) {
+ // This is all that's actually required at the moment
+ await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
+ } else {
+ const extendedData = {
+ ...baseData,
+ ad_id: adId,
+ ad_position: podPosition,
+ duration: 30,
+ creative_id: creativeId,
+ total_ads: podLength,
+ order_id: orderId,
+ line_item_id: lineItemId,
+ };
+ await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData));
+ for (let quartile = 0; quartile < 4; quartile++) {
+ await gqlRequest(
+ makeGraphQlPacket('video_ad_quartile_complete', radToken, {
+ ...extendedData,
+ quartile: quartile + 1,
+ })
+ );
+ }
+ await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
+ }
+ }
+ }
+ return 0;
+ } catch (err) {
+ console.log(err);
+ return 0;
+ }
+ }
+ function hookFetch() {
+ var realFetch = window.fetch;
+ window.fetch = function(url, init, ...args) {
+ if (typeof url === 'string') {
+ if (url.includes('gql')) {
+ var deviceId = init.headers['X-Device-Id'];
+ if (typeof deviceId !== 'string') {
+ deviceId = init.headers['Device-ID'];
+ }
+ if (typeof deviceId === 'string') {
+ gql_device_id = deviceId;
+ }
+ if (gql_device_id && twitchMainWorker) {
+ twitchMainWorker.postMessage({
+ key: 'UboUpdateDeviceId',
+ value: gql_device_id
+ });
+ }
+ if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
+ if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
+ const newBody = JSON.parse(init.body);
+ newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
+ init.body = JSON.stringify(newBody);
+ }
+ if (OPT_ROLLING_DEVICE_ID) {
+ if (typeof init.headers['X-Device-Id'] === 'string') {
+ init.headers['X-Device-Id'] = gql_device_id_rolling;
+ }
+ if (typeof init.headers['Device-ID'] === 'string') {
+ init.headers['Device-ID'] = gql_device_id_rolling;
+ }
+ }
+ }
+ }
+ }
+ return realFetch.apply(this, arguments);
+ };
+ }
+ function reloadTwitchPlayer(isSeek, isPausePlay) {
+ // Taken from ttv-tools / ffz
+ // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
+ // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
+ function findReactNode(root, constraint) {
+ if (root.stateNode && constraint(root.stateNode)) {
+ return root.stateNode;
+ }
+ let node = root.child;
+ while (node) {
+ const result = findReactNode(node, constraint);
+ if (result) {
+ return result;
+ }
+ node = node.sibling;
+ }
+ return null;
+ }
+ var reactRootNode = null;
+ var rootNode = document.querySelector('#root');
+ if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) {
+ reactRootNode = rootNode._reactRootContainer._internalRoot.current;
+ }
+ if (!reactRootNode) {
+ console.log('Could not find react root');
+ return;
+ }
+ var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance);
+ player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null;
+ var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings);
+ if (!player) {
+ console.log('Could not find player');
+ return;
+ }
+ if (!playerState) {
+ console.log('Could not find player state');
+ return;
+ }
+ if (player.paused) {
+ return;
+ }
+ if (isSeek) {
+ console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered()));
+ var pos = player.getPosition();
+ player.seekTo(0);
+ player.seekTo(pos);
+ return;
+ }
+ if (isPausePlay) {
+ player.pause();
+ player.play();
+ return;
+ }
+ const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null);
+ if (sink && sink.video && sink.video._ffz_compressor) {
+ const video = sink.video;
+ const volume = video.volume ? video.volume : player.getVolume();
+ const muted = player.isMuted();
+ const newVideo = document.createElement('video');
+ newVideo.volume = muted ? 0 : volume;
+ newVideo.playsInline = true;
+ video.replaceWith(newVideo);
+ player.attachHTMLVideoElement(newVideo);
+ setImmediate(() => {
+ player.setVolume(volume);
+ player.setMuted(muted);
+ });
+ }
+ playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false
+ }
+ window.reloadTwitchPlayer = reloadTwitchPlayer;
+ hookFetch();
+ function onContentLoaded() {
+ // This stops Twitch from pausing the player when in another tab and an ad shows.
+ // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30
+ try {
+ Object.defineProperty(document, 'visibilityState', {
+ get() {
+ return 'visible';
+ }
+ });
+ }catch{}
+ try {
+ Object.defineProperty(document, 'hidden', {
+ get() {
+ return false;
+ }
+ });
+ }catch{}
+ var block = e => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ };
+ document.addEventListener('visibilitychange', block, true);
+ document.addEventListener('webkitvisibilitychange', block, true);
+ document.addEventListener('mozvisibilitychange', block, true);
+ document.addEventListener('hasFocus', block, true);
+ try {
+ if (/Firefox/.test(navigator.userAgent)) {
+ Object.defineProperty(document, 'mozHidden', {
+ get() {
+ return false;
+ }
+ });
+ } else {
+ Object.defineProperty(document, 'webkitHidden', {
+ get() {
+ return false;
+ }
+ });
+ }
+ }catch{}
+ // Hooks for preserving volume / resolution
+ var keysToCache = [
+ 'video-quality',
+ 'video-muted',
+ 'volume',
+ 'lowLatencyModeEnabled',// Low Latency
+ 'persistenceEnabled',// Mini Player
+ ];
+ var cachedValues = new Map();
+ for (var i = 0; i < keysToCache.length; i++) {
+ cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i]));
+ }
+ var realSetItem = localStorage.setItem;
+ localStorage.setItem = function(key, value) {
+ if (cachedValues.has(key)) {
+ cachedValues.set(key, value);
+ }
+ realSetItem.apply(this, arguments);
+ };
+ var realGetItem = localStorage.getItem;
+ localStorage.getItem = function(key) {
+ if (cachedValues.has(key)) {
+ return cachedValues.get(key);
+ }
+ return realGetItem.apply(this, arguments);
+ };
+ }
+ if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
+ onContentLoaded();
+ } else {
+ window.addEventListener("DOMContentLoaded", function() {
+ onContentLoaded();
+ });
+ }
+})();
diff --git a/notify-swap/notify-swap.user.js b/notify-swap/notify-swap.user.js
new file mode 100644
index 0000000..5be929b
--- /dev/null
+++ b/notify-swap/notify-swap.user.js
@@ -0,0 +1,675 @@
+// ==UserScript==
+// @name TwitchAdSolutions (notify-swap)
+// @namespace https://github.com/pixeltris/TwitchAdSolutions
+// @version 1.13
+// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-swap/notify-notify-swap.user.js
+// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-swap/notify-notify-swap.user.js
+// @description Multiple solutions for blocking Twitch ads (notify-swap)
+// @author pixeltris
+// @match *://*.twitch.tv/*
+// @run-at document-start
+// @grant none
+// ==/UserScript==
+(function() {
+ 'use strict';
+ function declareOptions(scope) {
+ // Options / globals
+ scope.OPT_ROLLING_DEVICE_ID = true;
+ scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
+ scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
+ scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false;
+ scope.OPT_BACKUP_PLAYER_TYPE = 'thunderdome';//'picture-by-picture';'thunderdome';
+ scope.OPT_REGULAR_PLAYER_TYPE = 'site';
+ scope.OPT_INITIAL_M3U8_ATTEMPTS = 1;
+ scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
+ scope.AD_SIGNIFIER = 'stitched-ad';
+ scope.LIVE_SIGNIFIER = ',live';
+ scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
+ // These are only really for Worker scope...
+ scope.StreamInfos = [];
+ scope.StreamInfosByUrl = [];
+ scope.CurrentChannelNameFromM3U8 = null;
+ // Need this in both scopes. Window scope needs to update this to worker scope.
+ scope.gql_device_id = null;
+ scope.gql_device_id_rolling = '';
+ // Rolling device id crap... TODO: improve this
+ var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); }
+ var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC';
+ var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0;
+ for (var i = 0; i < bs.length; i++) {
+ scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length];
+ }
+ }
+ declareOptions(window);
+ var twitchMainWorker = null;
+ const oldWorker = window.Worker;
+ window.Worker = class Worker extends oldWorker {
+ constructor(twitchBlobUrl) {
+ if (twitchMainWorker) {
+ super(twitchBlobUrl);
+ return;
+ }
+ var jsURL = getWasmWorkerUrl(twitchBlobUrl);
+ if (typeof jsURL !== 'string') {
+ super(twitchBlobUrl);
+ return;
+ }
+ var newBlobStr = `
+ ${processM3U8.toString()}
+ ${hookWorkerFetch.toString()}
+ ${declareOptions.toString()}
+ ${getAccessToken.toString()}
+ ${gqlRequest.toString()}
+ ${makeGraphQlPacket.toString()}
+ ${tryNotifyAdsWatchedM3U8.toString()}
+ ${parseAttributes.toString()}
+ declareOptions(self);
+ self.addEventListener('message', function(e) {
+ if (e.data.key == 'UboUpdateDeviceId') {
+ gql_device_id = e.data.value;
+ }
+ });
+ hookWorkerFetch();
+ importScripts('${jsURL}');
+ `
+ super(URL.createObjectURL(new Blob([newBlobStr])));
+ twitchMainWorker = this;
+ this.onmessage = function(e) {
+ // NOTE: Removed adDiv caching as '.video-player' can change between streams?
+ if (e.data.key == 'UboShowAdBanner') {
+ var adDiv = getAdDiv();
+ if (adDiv != null) {
+ adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...';
+ adDiv.style.display = 'block';
+ }
+ }
+ else if (e.data.key == 'UboHideAdBanner') {
+ var adDiv = getAdDiv();
+ if (adDiv != null) {
+ adDiv.style.display = 'none';
+ }
+ }
+ else if (e.data.key == 'UboChannelNameM3U8Changed') {
+ //console.log('M3U8 channel name changed to ' + e.data.value);
+ }
+ else if (e.data.key == 'UboReloadPlayer') {
+ reloadTwitchPlayer();
+ }
+ else if (e.data.key == 'UboPauseResumePlayer') {
+ reloadTwitchPlayer(false, true);
+ }
+ else if (e.data.key == 'UboSeekPlayer') {
+ reloadTwitchPlayer(true);
+ }
+ }
+ function getAdDiv() {
+ var playerRootDiv = document.querySelector('.video-player');
+ var adDiv = null;
+ if (playerRootDiv != null) {
+ adDiv = playerRootDiv.querySelector('.ubo-overlay');
+ if (adDiv == null) {
+ adDiv = document.createElement('div');
+ adDiv.className = 'ubo-overlay';
+ adDiv.innerHTML = '
';
+ adDiv.style.display = 'none';
+ adDiv.P = adDiv.querySelector('p');
+ playerRootDiv.appendChild(adDiv);
+ }
+ }
+ return adDiv;
+ }
+ }
+ }
+ function getWasmWorkerUrl(twitchBlobUrl) {
+ var req = new XMLHttpRequest();
+ req.open('GET', twitchBlobUrl, false);
+ req.send();
+ return req.responseText.split("'")[1];
+ }
+ async function processM3U8(url, textStr, realFetch) {
+ var streamInfo = StreamInfosByUrl[url];
+ if (streamInfo == null) {
+ console.log('Unknown stream url ' + url);
+ //postMessage({key:'UboHideAdBanner'});
+ return textStr;
+ }
+ if (!OPT_MODE_STRIP_AD_SEGMENTS) {
+ return textStr;
+ }
+ var haveAdTags = textStr.includes(AD_SIGNIFIER);
+ if (streamInfo.IsLowResNoAds) {
+ var wasBackupNull = streamInfo.BackupUrl == null;
+ for (var i = 0; i < 2; i++) {
+ try {
+ if (i != 0 && streamInfo.IsMidroll) {
+ // Doesn't work well with midrolls (often wont see the ad until a few requests in, which creates a reload loop)
+ continue;
+ }
+ let index = i;
+ var targetUrl = index == 0 ? streamInfo.BackupUrl : null;
+ if (index != 0 || (streamInfo.BackupUrl == null && !streamInfo.IsRequestingBackup)) {
+ // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this.
+ if (index == 0) {
+ streamInfo.IsRequestingBackup = true;
+ }
+ var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE);
+ if (accessTokenResponse.status === 200) {
+ var accessToken = await accessTokenResponse.json();
+ var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
+ urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
+ urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
+ var encodingsM3u8Response = await realFetch(urlInfo.href);
+ if (encodingsM3u8Response.status === 200) {
+ // TODO: Maybe look for the most optimal m3u8
+ var encodingsM3u8 = await encodingsM3u8Response.text();
+ var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
+ // Maybe this request is a bit unnecessary
+ var streamM3u8Response = await realFetch(streamM3u8Url);
+ if (streamM3u8Response.status == 200) {
+ targetUrl = streamM3u8Url;
+ streamInfo.BackupEncodings[index] = encodingsM3u8;
+ if (index == 0) {
+ streamInfo.BackupUrl = streamM3u8Url;
+ streamInfo.IsRequestingBackup = false;
+ console.log('Fetched backup url: ' + streamInfo.BackupUrl);
+ }
+ } else {
+ console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status);
+ }
+ } else {
+ console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status);
+ }
+ } else {
+ console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status);
+ }
+ }
+ if (targetUrl != null) {
+ var backupM3u8 = null;
+ var backupM3u8Response = await realFetch(targetUrl);
+ if (backupM3u8Response.status == 200) {
+ backupM3u8 = await backupM3u8Response.text();
+ }
+ if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
+ if (streamInfo.LastBackupRequestWithoutAds[index] < Date.now() - 1333) {
+ streamInfo.LastBackupRequestWithoutAds[index] = Date.now();
+ streamInfo.NumBackupRequestWithoutAds[index]++;
+ }
+ } else {
+ // TODO: Throttle this. Currently this sends way too many requests.
+ if (index == 0 && !streamInfo.IsMidroll) {
+ /*await */tryNotifyAdsWatchedM3U8(backupM3u8);
+ }
+ streamInfo.NumBackupRequestWithoutAds[index] = 0;
+ }
+ }
+ } catch (err) {
+ console.log('Fetching backup m3u8 failed');
+ console.log(err);
+ }
+ if (wasBackupNull) {
+ continue;
+ }
+ }
+ if (streamInfo.NumBackupRequestWithoutAds[0] >= 3 || streamInfo.NumBackupRequestWithoutAds[1] >= 4) {
+ console.log('No more ads ' + streamInfo.NumBackupRequestWithoutAds[0] + ' ' + streamInfo.NumBackupRequestWithoutAds[1]);
+ postMessage({key:'UboHideAdBanner'});
+ streamInfo.SwappedEncodings = streamInfo.BackupEncodings[streamInfo.NumBackupRequestWithoutAds[0] >= 3 ? 0 : 1];
+ streamInfo.SwappedEncodingsTime = Date.now();
+ streamInfo.IsLowResNoAds = false;
+ postMessage({key:'UboReloadPlayer'});
+ }
+ if (haveAdTags) {
+ console.log('Double dipping ads?');
+ return '';
+ }
+ } else if (haveAdTags) {
+ console.log('Found ads, switch to low res');
+ streamInfo.IsLowResNoAds = true;
+ streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
+ postMessage({key:'UboReloadPlayer'});
+ return '';
+ } else {
+ postMessage({key:'UboHideAdBanner'});
+ }
+ return textStr;
+ }
+ function hookWorkerFetch() {
+ console.log('hookWorkerFetch');
+ var realFetch = fetch;
+ fetch = async function(url, options) {
+ if (typeof url === 'string') {
+ url = url.trimEnd();
+ if (url.endsWith('m3u8')) {
+ return new Promise(function(resolve, reject) {
+ var processAfter = async function(response) {
+ var str = await processM3U8(url, await response.text(), realFetch);
+ resolve(new Response(str));
+ };
+ var send = function() {
+ return realFetch(url, options).then(function(response) {
+ processAfter(response);
+ })['catch'](function(err) {
+ console.log('fetch hook err ' + err);
+ reject(err);
+ });
+ };
+ send();
+ });
+ }
+ else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) {
+ var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
+ if (CurrentChannelNameFromM3U8 != channelName) {
+ postMessage({
+ key: 'UboChannelNameM3U8Changed',
+ value: channelName
+ });
+ }
+ CurrentChannelNameFromM3U8 = channelName;
+ if (OPT_MODE_STRIP_AD_SEGMENTS) {
+ return new Promise(async function(resolve, reject) {
+ // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
+ // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
+ var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS;
+ var attempts = 0;
+ while(true) {
+ var encodingsM3u8Response = await realFetch(url, options);
+ if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
+ var encodingsM3u8 = await encodingsM3u8Response.text();
+ var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
+ var streamM3u8Response = await realFetch(streamM3u8Url);
+ var streamM3u8 = await streamM3u8Response.text();
+ if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) {
+ if (maxAttempts > 1 && attempts >= maxAttempts) {
+ console.log('max skip ad attempts reached (attempt #' + attempts + ')');
+ }
+ var hasSwappedEncodings = false;
+ var streamInfo = StreamInfos[channelName];
+ if (streamInfo == null) {
+ StreamInfos[channelName] = streamInfo = {};
+ } else if (streamInfo.SwappedEncodings != null && streamInfo.SwappedEncodingsTime >= Date.now() - 10000) {
+ encodingsM3u8 = streamInfo.SwappedEncodings;
+ hasSwappedEncodings = true;
+ }
+ var forcedLowRes = false;
+ var existingStreamInfo = StreamInfos[channelName];
+ if (existingStreamInfo.IsLowResNoAds || (!hasSwappedEncodings && streamM3u8.includes(AD_SIGNIFIER))) {
+ var accessTokenResponse = await getAccessToken(channelName, OPT_BACKUP_PLAYER_TYPE);
+ if (accessTokenResponse.status === 200) {
+ var accessToken = await accessTokenResponse.json();
+ var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8' + (new URL(url)).search);
+ urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
+ urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
+ var lowResEncodingsM3u8Response = await realFetch(urlInfo.href);
+ if (lowResEncodingsM3u8Response != null && lowResEncodingsM3u8Response.status === 200) {
+ var lowResEncodingsM3u8 = await lowResEncodingsM3u8Response.text();
+ var lowResLines = lowResEncodingsM3u8.replace('\r', '').split('\n');
+ var lowResBestUrl = null;
+ for (var i = 0; i < lowResLines.length; i++) {
+ if (lowResLines[i].startsWith('#EXT-X-STREAM-INF')) {
+ var res = parseAttributes(lowResLines[i])['RESOLUTION'];
+ if (res && lowResLines[i + 1].endsWith('.m3u8')) {
+ // Assumes resolutions are correctly ordered
+ lowResBestUrl = lowResLines[i + 1];
+ break;
+ }
+ }
+ }
+ if (lowResBestUrl != null) {
+ var normalEncodingsM3u8 = encodingsM3u8;
+ var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n');
+ for (var i = 0; i < normalLines.length - 1; i++) {
+ if (normalLines[i].startsWith('#EXT-X-STREAM-INF')) {
+ var res = parseAttributes(normalLines[i])['RESOLUTION'];
+ if (res) {
+ lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique
+ normalLines[i + 1] = lowResBestUrl;
+ }
+ }
+ }
+ encodingsM3u8 = normalLines.join('\r\n');
+ } else {
+ encodingsM3u8 = lowResEncodingsM3u8;
+ }
+ forcedLowRes = true;
+ postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
+ }
+ }
+ }
+ // This might potentially backfire... maybe just add the new urls
+ streamInfo.ChannelName = channelName;
+ streamInfo.RootM3U8Url = url;
+ streamInfo.RootM3U8Params = (new URL(url)).search;
+ streamInfo.BackupUrl = null;
+ streamInfo.BackupEncodings = [null,null];
+ streamInfo.IsRequestingBackup = false;
+ streamInfo.NumBackupRequestWithoutAds = [0,0];
+ streamInfo.LastBackupRequestWithoutAds = [0,0];
+ streamInfo.SwappedEncodings = null;
+ streamInfo.SwappedEncodingsTime = 0;
+ streamInfo.IsMidroll = !!streamInfo.IsMidroll;
+ streamInfo.IsLowResNoAds = forcedLowRes;
+ var lines = encodingsM3u8.replace('\r', '').split('\n');
+ for (var i = 0; i < lines.length; i++) {
+ if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
+ StreamInfosByUrl[lines[i].trimEnd()] = streamInfo;
+ }
+ }
+ resolve(new Response(encodingsM3u8));
+ break;
+ }
+ console.log('attempt to skip ad (attempt #' + attempts + ')');
+ } else {
+ // Stream is offline?
+ resolve(encodingsM3u8Response);
+ break;
+ }
+ }
+ });
+ }
+ }
+ }
+ return realFetch.apply(this, arguments);
+ }
+ }
+ function makeGraphQlPacket(event, radToken, payload) {
+ return [{
+ operationName: 'ClientSideAdEventHandling_RecordAdEvent',
+ variables: {
+ input: {
+ eventName: event,
+ eventPayload: JSON.stringify(payload),
+ radToken,
+ },
+ },
+ extensions: {
+ persistedQuery: {
+ version: 1,
+ sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b',
+ },
+ },
+ }];
+ }
+ function getAccessToken(channelName, playerType, realFetch) {
+ var body = null;
+ var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
+ body = {
+ operationName: 'PlaybackAccessToken_Template',
+ query: templateQuery,
+ variables: {
+ 'isLive': true,
+ 'login': channelName,
+ 'isVod': false,
+ 'vodID': '',
+ 'playerType': playerType
+ }
+ };
+ return gqlRequest(body, realFetch);
+ }
+ function gqlRequest(body, realFetch) {
+ var fetchFunc = realFetch ? realFetch : fetch;
+ return fetchFunc('https://gql.twitch.tv/gql', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ headers: {
+ 'client-id': CLIENT_ID,
+ 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id
+ }
+ });
+ }
+ function parseAttributes(str) {
+ return Object.fromEntries(
+ str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/)
+ .filter(Boolean)
+ .map(x => {
+ const idx = x.indexOf('=');
+ const key = x.substring(0, idx);
+ const value = x.substring(idx +1);
+ const num = Number(value);
+ return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]
+ }));
+ }
+ async function tryNotifyAdsWatchedM3U8(streamM3u8) {
+ try {
+ //console.log(streamM3u8);
+ if (!streamM3u8 || !streamM3u8.includes(AD_SIGNIFIER)) {
+ return 1;
+ }
+ var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/);
+ if (matches.length > 1) {
+ const attrString = matches[1];
+ const attr = parseAttributes(attrString);
+ var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1');
+ var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0');
+ var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN'];
+ var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID'];
+ var orderId = attr['X-TV-TWITCH-AD-ORDER-ID'];
+ var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID'];
+ var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID'];
+ var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase();
+ const baseData = {
+ stitched: true,
+ roll_type: rollType,
+ player_mute: false,
+ player_volume: 0.5,
+ visible: true,
+ };
+ for (let podPosition = 0; podPosition < podLength; podPosition++) {
+ if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) {
+ // This is all that's actually required at the moment
+ await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
+ } else {
+ const extendedData = {
+ ...baseData,
+ ad_id: adId,
+ ad_position: podPosition,
+ duration: 30,
+ creative_id: creativeId,
+ total_ads: podLength,
+ order_id: orderId,
+ line_item_id: lineItemId,
+ };
+ await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData));
+ for (let quartile = 0; quartile < 4; quartile++) {
+ await gqlRequest(
+ makeGraphQlPacket('video_ad_quartile_complete', radToken, {
+ ...extendedData,
+ quartile: quartile + 1,
+ })
+ );
+ }
+ await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
+ }
+ }
+ }
+ return 0;
+ } catch (err) {
+ console.log(err);
+ return 0;
+ }
+ }
+ function hookFetch() {
+ var realFetch = window.fetch;
+ window.fetch = function(url, init, ...args) {
+ if (typeof url === 'string') {
+ if (url.includes('gql')) {
+ var deviceId = init.headers['X-Device-Id'];
+ if (typeof deviceId !== 'string') {
+ deviceId = init.headers['Device-ID'];
+ }
+ if (typeof deviceId === 'string') {
+ gql_device_id = deviceId;
+ }
+ if (gql_device_id && twitchMainWorker) {
+ twitchMainWorker.postMessage({
+ key: 'UboUpdateDeviceId',
+ value: gql_device_id
+ });
+ }
+ if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
+ if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
+ const newBody = JSON.parse(init.body);
+ newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
+ init.body = JSON.stringify(newBody);
+ }
+ if (OPT_ROLLING_DEVICE_ID) {
+ if (typeof init.headers['X-Device-Id'] === 'string') {
+ init.headers['X-Device-Id'] = gql_device_id_rolling;
+ }
+ if (typeof init.headers['Device-ID'] === 'string') {
+ init.headers['Device-ID'] = gql_device_id_rolling;
+ }
+ }
+ }
+ }
+ }
+ return realFetch.apply(this, arguments);
+ };
+ }
+ function reloadTwitchPlayer(isSeek, isPausePlay) {
+ // Taken from ttv-tools / ffz
+ // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
+ // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
+ function findReactNode(root, constraint) {
+ if (root.stateNode && constraint(root.stateNode)) {
+ return root.stateNode;
+ }
+ let node = root.child;
+ while (node) {
+ const result = findReactNode(node, constraint);
+ if (result) {
+ return result;
+ }
+ node = node.sibling;
+ }
+ return null;
+ }
+ var reactRootNode = null;
+ var rootNode = document.querySelector('#root');
+ if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) {
+ reactRootNode = rootNode._reactRootContainer._internalRoot.current;
+ }
+ if (!reactRootNode) {
+ console.log('Could not find react root');
+ return;
+ }
+ var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance);
+ player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null;
+ var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings);
+ if (!player) {
+ console.log('Could not find player');
+ return;
+ }
+ if (!playerState) {
+ console.log('Could not find player state');
+ return;
+ }
+ if (player.paused) {
+ return;
+ }
+ if (isSeek) {
+ console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered()));
+ var pos = player.getPosition();
+ player.seekTo(0);
+ player.seekTo(pos);
+ return;
+ }
+ if (isPausePlay) {
+ player.pause();
+ player.play();
+ return;
+ }
+ const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null);
+ if (sink && sink.video && sink.video._ffz_compressor) {
+ const video = sink.video;
+ const volume = video.volume ? video.volume : player.getVolume();
+ const muted = player.isMuted();
+ const newVideo = document.createElement('video');
+ newVideo.volume = muted ? 0 : volume;
+ newVideo.playsInline = true;
+ video.replaceWith(newVideo);
+ player.attachHTMLVideoElement(newVideo);
+ setImmediate(() => {
+ player.setVolume(volume);
+ player.setMuted(muted);
+ });
+ }
+ playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false
+ }
+ window.reloadTwitchPlayer = reloadTwitchPlayer;
+ hookFetch();
+ function onContentLoaded() {
+ // This stops Twitch from pausing the player when in another tab and an ad shows.
+ // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30
+ try {
+ Object.defineProperty(document, 'visibilityState', {
+ get() {
+ return 'visible';
+ }
+ });
+ }catch{}
+ try {
+ Object.defineProperty(document, 'hidden', {
+ get() {
+ return false;
+ }
+ });
+ }catch{}
+ var block = e => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ };
+ document.addEventListener('visibilitychange', block, true);
+ document.addEventListener('webkitvisibilitychange', block, true);
+ document.addEventListener('mozvisibilitychange', block, true);
+ document.addEventListener('hasFocus', block, true);
+ try {
+ if (/Firefox/.test(navigator.userAgent)) {
+ Object.defineProperty(document, 'mozHidden', {
+ get() {
+ return false;
+ }
+ });
+ } else {
+ Object.defineProperty(document, 'webkitHidden', {
+ get() {
+ return false;
+ }
+ });
+ }
+ }catch{}
+ // Hooks for preserving volume / resolution
+ var keysToCache = [
+ 'video-quality',
+ 'video-muted',
+ 'volume',
+ 'lowLatencyModeEnabled',// Low Latency
+ 'persistenceEnabled',// Mini Player
+ ];
+ var cachedValues = new Map();
+ for (var i = 0; i < keysToCache.length; i++) {
+ cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i]));
+ }
+ var realSetItem = localStorage.setItem;
+ localStorage.setItem = function(key, value) {
+ if (cachedValues.has(key)) {
+ cachedValues.set(key, value);
+ }
+ realSetItem.apply(this, arguments);
+ };
+ var realGetItem = localStorage.getItem;
+ localStorage.getItem = function(key) {
+ if (cachedValues.has(key)) {
+ return cachedValues.get(key);
+ }
+ return realGetItem.apply(this, arguments);
+ };
+ }
+ if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
+ onContentLoaded();
+ } else {
+ window.addEventListener("DOMContentLoaded", function() {
+ onContentLoaded();
+ });
+ }
+})();
diff --git a/proxy/README.md b/proxy/README.md
deleted file mode 100644
index 2b3c304..0000000
--- a/proxy/README.md
+++ /dev/null
@@ -1,37 +0,0 @@
-This gives an overview of using proxies to avoid Twitch ads (without having to proxy all of your traffic ~ just the initial m3u8 per-stream).
-
-`proxy-server` fetches the m3u8 (hopefully ad-free). `extension` contains a Chrome / Firefox compatible extension for sending the m3u8 request. `proxy-m3u8` (uBlock Origin / userscript) scripts also work as an alternative to the extension.
-
-## Socks5
-
-- Put your socks5 proxy info into `proxy-server-info.txt` and run `proxy-server.exe` (install `Mono` if using Linux/Mac and run via `mono proxy-server.exe`).
-- Load the `extension` folder as an unpacked extension in your browser. Alternatively use `proxy-m3u8` `uBlock Origin` / `userscript` scripts ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8.user.js)). (TODO: more helpful info).
-
-## VPN + VMWare
-
-- Set up `VMWare Workstation` with `Windows` and your desired VPN.
-- In your VM `Settings` under the `Hardware` tab select `Network Adapter` and change the `Network connection` to `Bridged`. This is to simplify connecting to `proxy-server` from your host. You can do it without bridged but it requires additional VMWare network configuration.
-- Add `proxy-server.exe` to your Windows VM Firewall (or disable your Windows Firewall in the VM) and then run `proxy-server.exe`.
-- Modify `extension/background.js` and change the IP to your VM IP (obtained via `ipconfig` inside your VM). If you're using `proxy-m3u8` change the IP there.
-
-NOTE: See "mixed-content" below.
-
-## VPS
-
-- Run `proxy-server.exe` on your VPS which is hosted in an ad-free country (install `Mono` if using Linux and run via `mono proxy-server.exe`).
-- Modify the url in `extension/background.js` to point to your VPS and load the `extension` folder as an unpacked extension. If using `proxy-m3u8` scripts find the equivalent urls there and modify them where applicable (you'll likely need to fork to do this).
-
-NOTE: See "mixed-content" below.
-
-## Notes
-
-- Running the `HttpListener` on https has many convoluted steps. On localhost (127.0.0.1) Chrome / Firefox allow mixed content requests so there aren't any issues there. For other IPs (i.e. in the VPS/VPN example) you'll need to enable "mixed-content" (also known as "Insecure content") for twitch.tv or otherwise you'll get CORS errors.
-- `proxy-server.exe` needs to be ran as Admin to listen on the desired IP/Port.
-- Disable other Twitch ad blocking extensions / scripts as they may interfere.
-- You will likely have to try multiple locations until you find something that works.
-- If you're having problems use Wireshark (or similar) to make sure the m3u8 is being re-routed.
-- To build `proxy-server.cs` yourself run `proxy-server-build.bat`. If you're on Mac/Linux build it with `msbuild` which should come with `Mono` or `.NET Core` (TODO: more helpful info).
-- `proxy-server` should be visible over LAN/WAN assuming correct firewall settings, however if you wish to connect to it from another machine you'll need to edit the IP in `extension/background.js`.
-- TODO: Provide an authenticated option to allow Twitch Turbo to be used to provide streams to non-Turbo users.
-
-I would only really recommend using the info + code here as a starting point for building a more robust solution.
\ No newline at end of file
diff --git a/proxy/extension/README.md b/proxy/extension/README.md
deleted file mode 100644
index dd50edb..0000000
--- a/proxy/extension/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-This folder contains an extension which can be used with both Chrome and Firefox to proxy twitch.tv m3u8 stream requests.
-
-The target url is set to `http://127.0.0.1/`, you'll want to modify that based on your requirements.
\ No newline at end of file
diff --git a/proxy/extension/background.js b/proxy/extension/background.js
deleted file mode 100644
index 0c79c72..0000000
--- a/proxy/extension/background.js
+++ /dev/null
@@ -1,20 +0,0 @@
-var isChrome = typeof chrome !== "undefined" && typeof browser === "undefined";
-var extensionAPI = isChrome ? chrome : browser;
-function onBeforeRequest(details) {
- const match = /hls\/(\w+)\.m3u8/gim.exec(details.url);
- if (match !== null && match.length > 1) {
- return {
- redirectUrl: `http://127.0.0.1/twitch-m3u8/${match[1]}`
- };
- } else {
- return {
- redirectUrl: details.url
- };
- }
-}
-extensionAPI.webRequest.onBeforeRequest.addListener(
- onBeforeRequest, {
- urls: ["https://usher.ttvnw.net/api/channel/hls/*"]
- },
- ["blocking"]
-);
\ No newline at end of file
diff --git a/proxy/extension/manifest.json b/proxy/extension/manifest.json
deleted file mode 100644
index be15e47..0000000
--- a/proxy/extension/manifest.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "name": "Twitch M3U8 Proxy",
- "description": "Twitch M3U8 Proxy",
- "version": "1.0",
- "manifest_version": 2,
- "background": {
- "scripts": ["background.js"],
- "persistent": true
- },
- "permissions": [
- "webRequest",
- "webRequestBlocking",
- "*://*.twitch.tv/*",
- "*://*.ttvnw.net/*"
- ]
-}
\ No newline at end of file
diff --git a/proxy/proxy-server-build.bat b/proxy/proxy-server-build.bat
deleted file mode 100644
index c1cf5de..0000000
--- a/proxy/proxy-server-build.bat
+++ /dev/null
@@ -1 +0,0 @@
-call %WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe -debug proxy-server.cs
\ No newline at end of file
diff --git a/proxy/proxy-server-info.txt b/proxy/proxy-server-info.txt
deleted file mode 100644
index 7452c57..0000000
--- a/proxy/proxy-server-info.txt
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-- If you want to use a socks5 proxy:
-
-The first line MUST be the IP of the socks proxy
-The second line MUST be the port of the socks proxy
-
-- If you need a user / pass:
-
-The third line MUST be the username
-The fourth line MUST be the password
-
-- If you DON'T need a user / pass then the third / fourth line MUST be empty.
-- If you DON'T want to use a socks proxy then leave all four lines empty, or delete this file.
-
-----------------------------
-Example (with user/pass)
-----------------------------
-10.1.1.49
-1080
-myusername
-mypassword
-
-----------------------------
-Example (without user/pass)
-----------------------------
-10.2.2.66
-1080
\ No newline at end of file
diff --git a/proxy/proxy-server.cs b/proxy/proxy-server.cs
deleted file mode 100644
index b3fe981..0000000
--- a/proxy/proxy-server.cs
+++ /dev/null
@@ -1,1651 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Runtime.Serialization;
-using System.Reflection;
-using System.Threading;
-using System.Net;
-using System.Net.Sockets;
-using System.IO;
-using System.Diagnostics;
-using System.Threading.Tasks;
-
-class TwitchProxyServer
-{
- private static string ClientID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
- private static string UserAgentChrome = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36";
- private static string UserAgentFirefox = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0";
- private static string UserAgent = UserAgentChrome;
- private static string Platform = "web";
- private static string PlayerBackend = "mediaplayer";
- private static string playerType = "site";
- private static bool UseFastBread = true;// fast_bread (EXT-X-TWITCH-PREFETCH)
- private static DeviceIdType deviceIdType = DeviceIdType.Normal;
- private Thread thread;
- private HttpListener listener;
- private string deviceId;
-
- private static bool SocksProxyFound { get { return !string.IsNullOrEmpty(SocksProxyIP) && SocksProxyPort > 0; } }
- private static string SocksProxyIP;
- private static int SocksProxyPort;
- private static string SocksProxyUser;
- private static string SocksProxyPass;
- private static MihaZupan.HttpToSocks5Proxy proxy = null;
-
- enum DeviceIdType
- {
- Normal,
- Empty,
- None,
- Unique
- }
-
- public static void Run()
- {
- try
- {
- string file = "proxy-server-info.txt";
- if (File.Exists(file))
- {
- string[] lines = File.ReadAllLines(file);
- if (lines.Length > 1)
- {
- SocksProxyIP = lines[0].Trim();
- if (!string.IsNullOrWhiteSpace(lines[1]))
- {
- SocksProxyPort = int.Parse(lines[1].Trim());
- }
- if (lines.Length > 2)
- {
- SocksProxyUser = lines[2].Trim();
- }
- if (lines.Length > 3)
- {
- SocksProxyPass = lines[3].Trim();
- }
- if (SocksProxyPort > 0)
- {
- if (!string.IsNullOrWhiteSpace(SocksProxyUser))
- {
- proxy = new MihaZupan.HttpToSocks5Proxy(SocksProxyIP, SocksProxyPort, SocksProxyUser, SocksProxyPass);
- }
- else
- {
- proxy = new MihaZupan.HttpToSocks5Proxy(SocksProxyIP, SocksProxyPort);
- }
- }
- }
- }
- }
- catch (Exception e)
- {
- Console.WriteLine(e);
- SocksProxyIP = null;
- SocksProxyPort = 0;
- }
- Console.WriteLine("Socks: " + SocksProxyFound);
-
- ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072;
- TwitchProxyServer server = new TwitchProxyServer();
- server.Start();
- System.Diagnostics.Process.GetCurrentProcess().WaitForExit();
- }
-
- public void Start()
- {
- Stop();
-
- thread = new Thread(delegate()
- {
- listener = new HttpListener();
- listener.Prefixes.Add("http://*:" + 80 + "/");
- listener.Start();
- while (listener != null)
- {
- try
- {
- HttpListenerContext context = listener.GetContext();
- Process(context);
- }
- catch
- {
- }
- }
- });
- thread.SetApartmentState(ApartmentState.STA);
- thread.Start();
- }
-
- public void Stop()
- {
- if (listener != null)
- {
- try
- {
- listener.Stop();
- }
- catch
- {
- }
- listener = null;
- }
-
- if (thread != null)
- {
- try
- {
- thread.Abort();
- }
- catch
- {
- }
- thread = null;
- }
- }
-
- private void Process(HttpListenerContext context)
- {
- try
- {
- string url = context.Request.Url.OriginalString;
- Console.WriteLine("req " + DateTime.Now.TimeOfDay + " - " + url);
-
- byte[] responseBuffer = null;
- string response = string.Empty;
- string contentType = "text/html";
-
- if (url.Contains("favicon.ico"))
- {
- context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
- context.Response.OutputStream.Close();
- return;
- }
-
- if (context.Request.Url.Segments.Length > 2 &&
- context.Request.Url.Segments[1].Trim('/') == "twitch-m3u8" &&
- !string.IsNullOrEmpty(context.Request.Url.Segments[2]))
- {
- string channelName = context.Request.Url.Segments[2].Trim('/');
- response = FetchM3U8(channelName);
- //Console.WriteLine(response);
- }
-
- if (responseBuffer == null)
- {
- responseBuffer = Encoding.UTF8.GetBytes(response.ToString());
- }
- context.Response.Headers["Access-Control-Allow-Origin"] = "*";
- context.Response.ContentType = contentType;
- context.Response.ContentEncoding = Encoding.UTF8;
- context.Response.ContentLength64 = responseBuffer.Length;
- context.Response.OutputStream.Write(responseBuffer, 0, responseBuffer.Length);
- context.Response.OutputStream.Flush();
- context.Response.StatusCode = (int)HttpStatusCode.OK;
- }
- catch
- {
- context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
- }
-
- context.Response.OutputStream.Close();
- }
-
- private string FetchM3U8(string channel)
- {
- if (string.IsNullOrEmpty(deviceId) || deviceIdType == DeviceIdType.Unique)
- {
- UpdateDeviceId(channel);
- }
- using (WebClient wc = new WebClient())
- {
- string response = null, token = null, sig = null;
- wc.Proxy = proxy;
- wc.Headers.Clear();
- wc.Headers["client-id"] = ClientID;
- if (deviceIdType != DeviceIdType.None)
- {
- wc.Headers["Device-ID"] = deviceIdType == DeviceIdType.Empty ? string.Empty : deviceId;
- }
- wc.Headers["accept"] = "*/*";
- wc.Headers["accept-encoding"] = "gzip, deflate, br";
- wc.Headers["accept-language"] = "en-us";
- wc.Headers["content-type"] = "text/plain; charset=UTF-8";
- wc.Headers["origin"] = "https://www.twitch.tv";
- wc.Headers["referer"] = "https://www.twitch.tv/";
- wc.Headers["user-agent"] = UserAgent;
- response = wc.UploadString("https://gql.twitch.tv/gql", @"{""operationName"":""PlaybackAccessToken_Template"",""query"":""query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \""" + Platform + @"\"", playerBackend: \""" + PlayerBackend + @"\"", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \""" + Platform + @"\"", playerBackend: \""" + PlayerBackend + @"\"", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}"",""variables"":{""isLive"":true,""login"":""" + channel + @""",""isVod"":false,""vodID"":"""",""playerType"":""" + playerType + @"""}}");
- if (!string.IsNullOrEmpty(response))
- {
- TwitchAccessToken tokenInfo = JSONSerializer.DeSerialize(response);
- if (tokenInfo != null && tokenInfo.data != null && tokenInfo.data.streamPlaybackAccessToken != null &&
- !string.IsNullOrEmpty(tokenInfo.data.streamPlaybackAccessToken.value) && !string.IsNullOrEmpty(tokenInfo.data.streamPlaybackAccessToken.signature))
- {
- token = tokenInfo.data.streamPlaybackAccessToken.value;
- sig = tokenInfo.data.streamPlaybackAccessToken.signature;
- }
- }
- if (!string.IsNullOrEmpty(token))
- {
- string additionalParams = "";
- if (UseFastBread)
- {
- additionalParams += "&fast_bread=true";
- }
- string url = "https://usher.ttvnw.net/api/channel/hls/" + channel + ".m3u8?allow_source=true" + additionalParams + "&sig=" + sig + "&token=" + System.Web.HttpUtility.UrlEncode(token);
- wc.Headers.Clear();
- wc.Headers["accept"] = "application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain";
- wc.Headers["host"] = "usher.ttvnw.net";
- wc.Headers["cookie"] = "DNT=1;";
- wc.Headers["DNT"] = "1";
- wc.Headers["user-agent"] = UserAgent;
- string encodingsM3u8 = wc.DownloadString(url);
- return encodingsM3u8;
- }
- }
- return null;
- }
-
- private void UpdateDeviceId(string channel)
- {
- using (CookieAwareWebClient wc = new CookieAwareWebClient())
- {
- wc.Proxy = null;
- wc.Headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9";
- wc.DownloadString("https://www.twitch.tv/" + channel);
- ProcessCookies(wc.Cookies, out deviceId);
- Console.WriteLine("deviceId: " + deviceId);
- }
- }
-
- static string ProcessCookies(string str)
- {
- string uniqueId;
- return ProcessCookies(str, out uniqueId);
- }
-
- static string ProcessCookies(string str, out string uniqueId)
- {
- uniqueId = null;
- string result = string.Empty;
- string[] cookies = str.Split(',');
- foreach (string cookie in cookies)
- {
- if (cookie.Split(';')[0].Contains('='))
- {
- string[] splitted = cookie.Split(';')[0].Split('=');
- if (splitted.Length >= 2 && splitted[0] == "unique_id")
- {
- uniqueId = splitted[1];
- }
- result += cookie.Split(';')[0] + ";";
- }
- }
- return result;
- }
-
- class CookieAwareWebClient : WebClient
- {
- public CookieContainer CookieContainer { get; set; }
- public Uri Uri { get; set; }
-
- public string Cookies { get; private set; }
-
- public CookieAwareWebClient()
- : this(new CookieContainer())
- {
- }
-
- public CookieAwareWebClient(CookieContainer cookies)
- {
- this.CookieContainer = new CookieContainer();
- }
-
- protected override WebResponse GetWebResponse(WebRequest request)
- {
- WebResponse response = base.GetWebResponse(request);
- string setCookieHeader = response.Headers.Get("Set-Cookie");
- Cookies = setCookieHeader;
- return response;
- }
- }
-
- [DataContract]
- public class TwitchAccessTokenOld
- {
- [DataMember]
- public string token { get; set; }
- [DataMember]
- public string sig { get; set; }
- }
-
- [DataContract]
- public class TwitchAccessToken
- {
- [DataMember]
- public TwitchAccessToken_data data { get; set; }
- }
-
- [DataContract]
- public class TwitchAccessToken_data
- {
- [DataMember]
- public TwitchAccessToken_streamPlaybackAccessToken streamPlaybackAccessToken { get; set; }
- }
-
- [DataContract]
- public class TwitchAccessToken_streamPlaybackAccessToken
- {
- [DataMember]
- public string value { get; set; }
- [DataMember]
- public string signature { get; set; }
- }
-
- static class JSONSerializer where TType : class
- {
- public static TType DeSerialize(string json)
- {
- return TinyJson.JSONParser.FromJson(json);
- }
- }
-
- static void Main()
- {
- TwitchProxyServer.Run();
- }
-}
-
-namespace TinyJson
-{
- // Really simple JSON parser in ~300 lines
- // - Attempts to parse JSON files with minimal GC allocation
- // - Nice and simple "[1,2,3]".FromJson>() API
- // - Classes and structs can be parsed too!
- // class Foo { public int Value; }
- // "{\"Value\":10}".FromJson()
- // - Can parse JSON without type information into Dictionary and List