├── extension ├── contentscript.js ├── manifest.json ├── injected.js └── lib.js ├── LICENSE └── README.md /extension/contentscript.js: -------------------------------------------------------------------------------- 1 | function inject(url) { 2 | var s = document.createElement('script'); 3 | s.src = chrome.extension.getURL(url); 4 | s.onload = function() { this.remove(); }; 5 | (document.head || document.documentElement).appendChild(s); 6 | } 7 | inject('lib.js'); 8 | inject('injected.js'); 9 | -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Twitch-HLS-Ad-Block", 3 | "version": "0.6.2", 4 | "author": "InstanceLabs", 5 | "description": "Block Twitch ads that are inserted directly in the HLS stream", 6 | "content_scripts": [{ 7 | "matches": ["*://*.twitch.tv/*"], 8 | "js": ["contentscript.js"], 9 | "run_at": "document_start" 10 | }], 11 | "web_accessible_resources": ["lib.js", "injected.js"], 12 | "manifest_version": 2 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 InstanceLabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extension/injected.js: -------------------------------------------------------------------------------- 1 | console.log("Injected Twitch HLS Adblock."); 2 | 3 | const SUPPORTED_VERSION = "2.9.1"; 4 | const oldWorker = window.Worker; 5 | window.Worker = class Worker extends oldWorker { 6 | constructor(twitchBlobUrl) { 7 | var jsURL = getWasmWorkerUrl(twitchBlobUrl); 8 | var version = jsURL.match(/wasmworker\.min\-(.*)\.js/)[1]; 9 | var usePerformanceFix = true; 10 | 11 | if (version != SUPPORTED_VERSION) { 12 | console.log(`Twitch HLS Adblock found possibly unsupported version: ${version}.`); 13 | console.log(`Current supported version: ${SUPPORTED_VERSION}.`); 14 | console.log("This is most likely fine. Trying upstream wasmworker.."); 15 | usePerformanceFix = false; 16 | } 17 | 18 | var functions = getFuncsForInjection(usePerformanceFix); 19 | 20 | var newBlobStr = ` 21 | var Module = { 22 | WASM_BINARY_URL: '${jsURL.replace('.js', '.wasm')}', 23 | WASM_CACHE_MODE: true 24 | } 25 | 26 | ${ functions } 27 | 28 | importScripts('${jsURL}'); 29 | ` 30 | super(URL.createObjectURL(new Blob([newBlobStr]))); 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | Twitch is playing a 15-30 second advertisement whenever one starts watching a new channel. For people who jump around a bit this is pretty annoying. 4 | 5 | Twitch staff has been fairly quick to remove client side fixes that disable advertisements. Since they're able to inject advertisementst into the HLS stream directly ([SSAI](https://aws.amazon.com/blogs/media/why-is-server-side-ad-insertion-important/), Twitch's [SureStream](http://twitchadvertising.tv/ad-products/surestream/) if you want to research further), I believe such fixes will not always be available. 6 | 7 | This extension monkey patches the web worker (among others) Twitch uses and edits the m3u8 playlist that gets requested every few seconds to simply remove segments that are marked as advertisments with SCTE-35 flags. 8 | 9 | Right now Twitch also makes the actual stream available in those playlist files after a few seconds, which means that after just around 5 seconds the real stream begins, instead of 30 seconds of advertisements. 10 | 11 | # Installation 12 | 13 | To install manually for Chrome: 14 | 15 | 1. Check [releases](https://github.com/instance01/Twitch-HLS-AdBlock/releases) for the latest zip or download the source 16 | 2. Unzip into a directory and keep the directory in mind 17 | 3. Go to chrome://extensions/ and enable Developer Mode 18 | 4. Click on 'Load unpacked' and go to the directory with the extension (see if manifest.json is in the directory) 19 | 20 | To install manually for FireFox: 21 | 22 | 1. Download the latest release (xpi file) 23 | 2. Go to about:addons and load addon from file 24 | 25 | 26 | # Limitations 27 | 28 | Generally it seems to work fine. Whenever one loads up a new channel, if there's an advertisment injected by Twitch, after a few seconds of loading the real stream begins without any indication of an advertisment. 29 | 30 | However I've seen rare instances where the stream breaks, which requires a browser reload. This happened once when the advertisment loaded 2-3 seconds after the stream has started normally. 31 | 32 | Currently this is only tested on the latest stable chromium browser and latest Firefox stable. 33 | 34 | # Contributing 35 | 36 | I appreciate any contributions, be it pull requests or issues. Right now there's no tests however, so make sure to test extensively on Twitch before submitting a pull request. 37 | 38 | -------------------------------------------------------------------------------- /extension/lib.js: -------------------------------------------------------------------------------- 1 | function getFuncsForInjection (usePerformanceFix) { 2 | function str2ab(str) { 3 | var buf = new ArrayBuffer(str.length); 4 | var bufView = new Uint8Array(buf); 5 | for (var i = 0, strLen = str.length; i < strLen; i++) { 6 | bufView[i] = str.charCodeAt(i); 7 | } 8 | return buf; 9 | } 10 | 11 | function getSeqNr(textStr) { 12 | var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(textStr); 13 | if (seqMatch === null) { 14 | return 1; 15 | } 16 | return seqMatch[1]; 17 | } 18 | 19 | function stripAds (textStr) { 20 | var haveAdTags = textStr.includes('#EXT-X-SCTE35-OUT') || textStr.includes('stitched-ad'); 21 | 22 | if (haveAdTags) { 23 | self._wasAd = true; 24 | 25 | if (self._startSeq === undefined) { 26 | self._startSeq = Math.max(0, parseInt(getSeqNr(textStr))); 27 | } 28 | 29 | textStr = textStr.replace(/#EXT-X-SCTE35-OUT(.|\s)*#EXT-X-SCTE35-IN/gmi, ''); 30 | textStr = textStr.replace(/#EXT-X-SCTE35-OUT(.|\s)*/gmi, ''); 31 | textStr = textStr.replace(/#EXTINF:2.00[12](.|\s)*/gmi, ''); 32 | textStr = textStr.replace(/#EXT-X-SCTE35-IN/gi, ''); 33 | textStr = textStr.replace(/#EXT-X-DISCONTINUITY/gi, ''); 34 | textStr = textStr.replace(/#EXT-X-DATERANGE:ID="stitched-ad.*/gi, ''); 35 | 36 | // Get rid of empty lines 37 | textStr = textStr.replace(/^\s*$(?:\n)/gm, ''); 38 | 39 | if (!textStr.includes('#EXTINF')) { 40 | // Playlist currently only includes ads and not the underlying actual stream. 41 | // We stripped the playlist of all ads though, so it's empty. 42 | // This breaks Twitch. 43 | // So let's reuse the last stream chunk. This will most likely buffer/be glitchy. 44 | // We manually increase the sequence number. 45 | // TODO: Find better solution? 46 | if (textStr === undefined) { 47 | textStr = ''; 48 | } else if (self._lastStreamChunk !== undefined) { 49 | textStr = self._lastStreamChunk; 50 | } 51 | var currSeq = parseInt(getSeqNr(textStr)); 52 | if (self._lastSeq === undefined) { 53 | self._lastSeq = 0; 54 | } 55 | self._lastSeq += 1; 56 | textStr = textStr.replace(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/, '#EXT-X-MEDIA-SEQUENCE:' + (currSeq + self._lastSeq)); 57 | } else { 58 | self._lastStreamChunk = textStr; 59 | } 60 | } 61 | 62 | if (!haveAdTags && self._wasAd) { 63 | // No ads anymore (no SCTE35 flags), normal playlist again. 64 | // We have to fix the sequence number though. 65 | // 66 | // Through trial and error it seems the following is most stable. 67 | // Things tried: 68 | // * Option 1: Not changing media sequence 69 | // * Option 2: Subtracting _deltaSeq 70 | // * Optino 3: Adding _deltaSeq (as seen below) 71 | // It seems we can't get around buffering. At least with midroll ads, which the below was tested with. 72 | // Originally Option 2 was used, but only tested with preroll ads, and it seemed to work good for those. 73 | // TODO: More testing needed. 74 | var currSeq = parseInt(getSeqNr(textStr)); 75 | 76 | if (self._deltaSeq === undefined) { 77 | self._deltaSeq = currSeq - self._startSeq - 1; 78 | } 79 | 80 | var newSeq = currSeq + self._deltaSeq - 1; 81 | textStr = textStr.replace(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/, '#EXT-X-MEDIA-SEQUENCE:' + newSeq); 82 | } 83 | 84 | return textStr; 85 | } 86 | 87 | function overrideFilteredArrayBuffer() { 88 | Response.prototype.filteredArrayBuffer = function () { 89 | return this.text().then((text) => { 90 | var ret; 91 | 92 | if (!this.url.endsWith('m3u8')) { 93 | ret = text; 94 | } else { 95 | ret = stripAds(text); 96 | } 97 | 98 | var buf = str2ab(ret); 99 | 100 | return new Promise((resolve, reject) => { 101 | resolve(buf); 102 | }); 103 | }); 104 | }; 105 | } 106 | 107 | function overrideReadableStream() { 108 | // Firefox doesn't have ReadableStream 109 | self.ReadableStream = function () { }; 110 | ReadableStream.prototype.cancel = function () { }; 111 | ReadableStream.prototype.locked = false; 112 | ReadableStream.prototype.getReader = function () { 113 | if (this._dataRead === undefined) { 114 | return; 115 | } 116 | 117 | var ret = Object.create(null); 118 | ret.read = function () { 119 | if (!this._dataRead) { 120 | this._dataRead = true; 121 | return this._data.then((data) => { 122 | return { value: new Uint8Array(data), done: false }} 123 | ); 124 | } else { 125 | return new Promise((resolve, reject) => { 126 | if (performance._now === undefined) { 127 | fixPerformance(); 128 | } 129 | resolve({ value: undefined, done: true }); 130 | }); 131 | } 132 | }.bind(this); 133 | 134 | // TODO For the future 135 | // ret.end = function () { 136 | // }.bind(this); 137 | 138 | ret.cancel = function () { this._dataRead = true; }.bind(this); 139 | ret.releaseLock = function () { }.bind(this); 140 | Object.defineProperty(ret, 'locked', { 141 | get: function () { 142 | if (this._locked === undefined) { 143 | this._locked = false; 144 | } 145 | return this._locked; 146 | }, 147 | set: function (val) { 148 | this._locked = val; 149 | } 150 | }); 151 | 152 | return ret; 153 | } 154 | } 155 | 156 | function overrideBody() { 157 | Object.defineProperty(Response.prototype, 'body', { 158 | get: function() { 159 | if (!this._rs) { 160 | this._rs = new ReadableStream(); 161 | } else { 162 | return this._rs; 163 | } 164 | 165 | if (!this._gotData) { 166 | this._gotData = true; 167 | 168 | if (this.url.endsWith('m3u8')) { 169 | this._rs._data = this.filteredArrayBuffer(); 170 | } else { 171 | this._rs._data = this.arrayBuffer(); 172 | } 173 | this._rs._dataRead = false; 174 | } 175 | 176 | return this._rs; 177 | }, 178 | set: function(val) { 179 | this._rs = val; 180 | } 181 | }); 182 | } 183 | 184 | var applyOverrides = ` 185 | console.log('Applying overrides..'); 186 | overrideReadableStream(); 187 | overrideFilteredArrayBuffer(); 188 | overrideBody(); 189 | ` 190 | 191 | // For now the ReadableStream implementation above doesn't support chunks. 192 | // The current code is too slow for Twitch and after a while there can be rare instances where the stream breaks for a few seconds. 193 | // For now just render their performance checks useless, at least until the code above is more performant. 194 | function fixPerformance() { 195 | performance._now = performance.now; 196 | 197 | // zc() is used by the wasm worker, let's not break anything here 198 | self.zc = function () { 199 | return performance._now() 200 | }; 201 | 202 | performance.now = function () { 203 | return 0; 204 | } 205 | } 206 | 207 | if (!usePerformanceFix) { 208 | function fixPerformance() { 209 | performance._now = performance.now; 210 | } 211 | } 212 | 213 | return ` 214 | ${str2ab.toString()} 215 | ${getSeqNr.toString()} 216 | ${stripAds.toString()} 217 | ${overrideFilteredArrayBuffer.toString()} 218 | ${overrideReadableStream.toString()} 219 | ${overrideBody.toString()} 220 | ${applyOverrides} 221 | ${fixPerformance.toString()} 222 | ` 223 | } 224 | 225 | function getWasmWorkerUrl (twitchBlobUrl) { 226 | var req = new XMLHttpRequest(); 227 | req.open('GET', twitchBlobUrl, false); 228 | req.send(); 229 | return req.responseText.split("'")[1] 230 | } 231 | --------------------------------------------------------------------------------