Skip to content

Instantly share code, notes, and snippets.

@pixeltris
Last active April 7, 2021 19:34
Show Gist options
  • Save pixeltris/77c676ef65b0b76e7aa56d8a0e0b3897 to your computer and use it in GitHub Desktop.
Save pixeltris/77c676ef65b0b76e7aa56d8a0e0b3897 to your computer and use it in GitHub Desktop.
twitch-videoad.js application/javascript
(function() {
if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
function declareOptions(scope) {
// Options / globals
scope.OPT_INITIAL_M3U8_ATTEMPTS = 1;
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = "";//'embed';
scope.AD_SIGNIFIER = 'stitched-ad';
scope.LIVE_SIGNIFIER = ',live';
scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
// These are only really for Worker scope...
scope.StreamInfos = [];
scope.StreamInfosByUrl = [];
}
// Worker injection by instance01 (https://github.com/instance01/Twitch-HLS-AdBlock)
const oldWorker = window.Worker;
window.Worker = class Worker extends oldWorker {
constructor(twitchBlobUrl) {
var jsURL = getWasmWorkerUrl(twitchBlobUrl);
var version = jsURL.match(/wasmworker\.min\-(.*)\.js/)[1];
var newBlobStr = `
var Module = {
WASM_BINARY_URL: '${jsURL.replace('.js', '.wasm')}',
WASM_CACHE_MODE: true
}
${stripAds.toString()}
${getSegmentTimes.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
declareOptions(self);
hookWorkerFetch();
importScripts('${jsURL}');
`
super(URL.createObjectURL(new Blob([newBlobStr])));
var adDiv = null;
this.onmessage = function(e) {
if (e.data.key == 'UboShowAdBanner') {
if (adDiv == null) { adDiv = getAdDiv(); }
adDiv.style.display = 'block';
}
else if (e.data.key == 'UboHideAdBanner') {
if (adDiv == null) { adDiv = getAdDiv(); }
adDiv.style.display = 'none';
}
}
function getAdDiv() {
var msg = 'uBlock Origin is waiting for ads to finish...';
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 = '<div class="player-ad-notice" style="color: white; background-color: rgba(0, 0, 0, 0.8); position: absolute; top: 0px; left: 0px; padding: 10px;"><p>' + msg + '</p></div>';
adDiv.style.display = 'none';
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 stripAds(url, textStr, realFetch) {
var haveAdTags = textStr.includes(AD_SIGNIFIER);
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url!');
return textStr;
}
if (haveAdTags && !textStr.includes(LIVE_SIGNIFIER)) {
postMessage({key:'UboShowAdBanner'});
} else {
postMessage({key:'UboHideAdBanner'});
}
if (haveAdTags) {
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 realFetch('https://api.twitch.tv/api/channels/' + streamInfo.ChannelName + '/access_token?oauth_token=undefined&need_https=true&platform=web&player_type=picture-by-picture&player_backend=mediaplayer', {headers:{'client-id':CLIENT_ID}});
if (accessTokenResponse.status === 200) {
var accessToken = JSON.parse(await accessTokenResponse.text());
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
urlInfo.searchParams.set('sig', accessToken.sig);
urlInfo.searchParams.set('token', accessToken.token);
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)) {
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')) {
// Based on https://github.com/jpillora/xhook
return new Promise(function(resolve, reject) {
var processAfter = async function(response) {
var str = await stripAds(url, await response.text(), realFetch);
var modifiedResponse = new Response(str);
resolve(modifiedResponse);
};
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')) {
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 channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
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);
}
}
// This hooks fetch in the global scope (which is different to the Worker scope, and therefore different to the Worker fetch hook)
function hookFetch() {
var realFetch = window.fetch;
window.fetch = function(url, init, ...args) {
if (typeof url === 'string') {
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);
}
}
}
return realFetch.apply(this, arguments);
}
}
declareOptions(window);
hookFetch();
})();
@AeonFX
Copy link

AeonFX commented Nov 22, 2020

I think the current twitch changes also broke this script here. I do get frequently pre-roll ads now :-/

@pixeltris
Copy link
Author

pixeltris commented Nov 22, 2020

I haven't been using this, but did check it out earlier today and observed that it wasn't blocking the ads. Annoyingly after making some slight changes to add some logging in, I haven't been able to re-produce it. I didn't notice anything in the m3u8 when it was happening, but I'd guess it's possibly some parsing error.

@gwarser
Copy link

gwarser commented Nov 22, 2020

If you still want to use this scriptlet [edit: in uBO userResourcesLocation], add twitch.tv##+js(twitch-videoad) to My filters.

I removed the filter from uBO filters to not use the default packaged scriptlet anymore: uBlockOrigin/uAssets@6e654d3

@AeonFX
Copy link

AeonFX commented Nov 22, 2020

If you still want to use this scriptlet, add twitch.tv##+js(twitch-videoad) to My filters.

I removed the filter from uBO filters to not use the default packaged scriptlet anymore: uBlockOrigin/uAssets@6e654d3

Thx for the hint, but I disabled uBO on twitch since I used this scriptlet here as I assumed they would interfere with each other in some weird way. So I disabled uBO on twitch and only enabled the scriptlet here via Tampermonkey, which was fine for the past few weeks (apart from the above mentioned minor problems).

@pixeltris. It was actually just a short period til noon today where I saw ads. They seem to be gone now. I can only assume twitch is back on experimenting some more anti-ad stuff on Prod... and finally decided they should revert it, for now^^

@AeonFX
Copy link

AeonFX commented Nov 23, 2020

Well it's happening again. I had a look into the m3u8 list and apparently the completely got rid of the old twitch-stitched-ad IDs and classes. All the segments in the m3u8 now look exactly the same as they would on the actual live stream :-/ So much for my idea that this approach would be more stable than the workarounds...

@pixeltris
Copy link
Author

I still haven't been able to get it to occur again, though I'm getting starting segments being played multiple times which didn't happen before (it used to just freeze until a new segment came, now it seems to loop the last known segment).

As far as I'm aware they don't have to tag the individual segments, just the date ranges (which comes along with the click-through link that makes them money). The current implementation looks for the verbose tags.

I'm curious if they will go down the route of obfuscation where they obfuscate the date range values, and combine it with generated obfuscated js/wasm which changes on a daily basis. I think this type of thing would be end-game for them, as ultimately they always need to tag things for the click-through events.

They are still struggling with the trivial case of basic workarounds though, so it's probably a long way off needing to use this type of solution. The embed solution might be hard for Twitch to combat without negatively impacting their customers.

@adeFuLoDgu
Copy link

@AeonFX You may try add // @run-at document-end before // ==/UserScript==.

@AeonFX
Copy link

AeonFX commented Nov 24, 2020

I think it was a simple ad iframe on top of the player. So I actually do need to have uBO running for the cosmetic filters.
It is actually hard to reproduce as this ad iframe has pretty long intervals before showing you another ad. I actually wasn't aware that they show other ads apart from the SSAI. Maybe it is also a weird interaction when they are able to detect an activated uBO.

@viocar
Copy link

viocar commented Nov 26, 2020

This seems to no longer work as of today.

@AeonFX
Copy link

AeonFX commented Nov 27, 2020

This seems to no longer work as of today.

It is still working fine here using Tampermonkey: v4.11 , Vivaldi 3.4.2066.99
Tested with uBO(1.31.1b6) disabled and enabled, also tested logged out in private window. Script seems to be working fine on my side.
Edit: Streamer just tested manual mid-rolls after viewer complained about automated ads in the middle of stream. Both times this script here also worked, only the little info "waiting for ads to finish" didn't show up.

@pixeltris
Copy link
Author

pixeltris commented Dec 22, 2020

I've now made a repo for this (and other solutions) here https://github.com/pixeltris/TwitchAdSolutions

I currently only uploaded uBlock Origin scripts. Pull requests for UserScript alternatives are welcome, or I'll probably get around to it at some point...

@adeFuLoDgu
Copy link

@pixeltris I made a userscript base on the gist for my own use. You may refer here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment