// ==UserScript== // @name Twitch Adblock Userscript // @namespace https://github.com/0rpi/twitch-adblock-userscript // @version 37.6.1 // @description Blocks Twitch ads (fork of vaft from pixeltris/TwitchAdSolutions) with a 360p/Source ad fallback toggle and auto 1440p60/1080p60 quality (toggleable) // @author 0rpi, pixeltris/TwitchAdSolutions contributors, VideoAdBlockForTwitch contributors (https://github.com/cleanlock/VideoAdBlockForTwitch#credits) // @license MIT // @homepageURL https://github.com/Wibias/twitch-adblock-userscript // @supportURL https://github.com/Wibias/twitch-adblock-userscript/issues // @updateURL https://raw.githubusercontent.com/Wibias/twitch-adblock-userscript/main/twitch-adblock.user.js // @downloadURL https://raw.githubusercontent.com/Wibias/twitch-adblock-userscript/main/twitch-adblock.user.js // @match *://*.twitch.tv/* // @run-at document-start // @inject-into page // @grant none // ==/UserScript== (function() { 'use strict'; // Versioning note: @version above (37.x) is the public release number used by // Violentmonkey/Tampermonkey for auto-update. Bump it on every user-visible change. // ourTwitchAdSolutionsVersion below is an INTERNAL anti-conflict guard (upstream vaft // compat): only bump it if the conflict protocol with other vaft copies changes. const ourTwitchAdSolutionsVersion = 24;// Used to prevent conflicts with outdated versions of the scripts if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { console.log("skipping vaft as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion); window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; return; } window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; function declareOptions(scope) { scope.AdSignifier = 'stitched'; scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; scope.BackupPlayerTypes = [ 'embed',//Source 'popout',//Source 'autoplay',//360p //'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) ]; scope.FallbackPlayerType = 'embed'; scope.ForceAccessTokenPlayerType = 'popout'; scope.SkipPlayerReloadOnHevc = false;// If true this will skip player reload on streams which have 2k/4k quality (if you enable this and you use the 2k/4k quality setting you'll get error #4000 / #3000 / spinning wheel on chrome based browsers) scope.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads scope.ReloadPlayerAfterAd = false;// After the ad finishes do a player reload instead of pause/play scope.PlayerReloadMinimalRequestsTime = 1500; scope.PlayerReloadMinimalRequestsPlayerIndex = 0; scope.HasTriggeredPlayerReload = false; scope.StreamInfos = []; scope.StreamInfosByUrl = []; scope.GQLDeviceID = null; scope.ClientVersion = null; scope.ClientSession = null; scope.ClientIntegrityHeader = null; scope.AuthorizationHeader = undefined; scope.SimulatedAdsDepth = 0; scope.PlayerBufferingFix = true;// If true this will pause/play the player when it gets stuck buffering scope.PlayerBufferingDelay = 600;// How often should we check the player state (in milliseconds) scope.PlayerBufferingSameStateCount = 3;// How many times of seeing the same player state until we trigger pause/play (it will only trigger it one time until the player state changes again) scope.PlayerBufferingDangerZone = 1;// The buffering time left (in seconds) when we should ignore the players playback position in the player state check scope.PlayerBufferingDoPlayerReload = false;// If true this will do a player reload instead of pause/play (player reloading is better at fixing the playback issues but it takes slightly longer) scope.PlayerBufferingMinRepeatDelay = 8000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems) scope.PlayerBufferingPrerollCheckEnabled = false;// Enable this if you're getting an immediate pause/play/reload as you open a stream (which is causing the stream to take longer to load). One problem with this being true is that it can cause the player to get stuck in some instances requiring the user to press pause/play scope.PlayerBufferingPrerollCheckOffset = 5;// How far the stream need to move before doing the buffering mitigation (depends on PlayerBufferingPrerollCheckEnabled being true) scope.V2API = false; scope.IsAdStrippingEnabled = true; scope.AdSegmentCache = new Map(); scope.AllSegmentsAreAdSegments = false; scope.WorkerFetchTimeoutMs = 15000; scope.BlockCsaiAdRequests = false;// CSAI observation is always on; active blocking is opt-in. } let isActivelyStrippingAds = false; let localStorageHookFailed = false; const nativeHiddenGetter = document.__lookupGetter__('hidden'); const nativeWebkitHiddenGetter = document.__lookupGetter__('webkitHidden'); function isDocumentActuallyHidden() { try { if (nativeHiddenGetter) { return nativeHiddenGetter.call(document) === true; } if (nativeWebkitHiddenGetter) { return nativeWebkitHiddenGetter.call(document) === true; } } catch {} return false; } window.vaftIsDocumentHidden = isDocumentActuallyHidden; const twitchWorkers = new Set(); const csaiRequestStats = { total: 0, blocked: 0, allowed: 0, fetch: 0, xhr: 0, preroll: 0, midroll: 0, unknown: 0 }; function getCsaiRequestType(requestUrl) { if (typeof requestUrl !== 'string' || !requestUrl) return null; try { const parsed = new URL(requestUrl); if (parsed.hostname.toLowerCase() !== 'edge.ads.twitch.tv') return null; const breakPosition = parsed.searchParams.get('bp'); if (breakPosition === 'midroll') return 'midroll'; if (breakPosition === 'preroll') return 'preroll'; return 'unknown'; } catch { return null; } } function recordCsaiRequest(type, transport, blocked) { const normalizedType = type === 'preroll' || type === 'midroll' ? type : 'unknown'; const normalizedTransport = transport === 'xhr' ? 'xhr' : 'fetch'; csaiRequestStats.total++; csaiRequestStats[normalizedType]++; csaiRequestStats[normalizedTransport]++; csaiRequestStats[blocked ? 'blocked' : 'allowed']++; if (csaiRequestStats.total === 1 || csaiRequestStats.total % 10 === 0) { console.debug('[vaft] CSAI request', { type: normalizedType, transport: normalizedTransport, blocked: Boolean(blocked), total: csaiRequestStats.total }); } } function getCsaiStatsSnapshot() { return { total: csaiRequestStats.total, blocked: csaiRequestStats.blocked, allowed: csaiRequestStats.allowed, fetch: csaiRequestStats.fetch, xhr: csaiRequestStats.xhr, preroll: csaiRequestStats.preroll, midroll: csaiRequestStats.midroll, unknown: csaiRequestStats.unknown }; } const vaftRuntimeStatus = { channel: null, showingAd: false, strippingSegments: false, backupPlayerType: null, lastPlayerReload: null, numStrippedAdSegments: 0, isMidroll: false, lastUpdate: null }; function updateVaftRuntimeStatus(nextStatus) { if (!nextStatus || typeof nextStatus !== 'object') { return; } Object.assign(vaftRuntimeStatus, nextStatus); vaftRuntimeStatus.lastUpdate = Date.now(); } const workerStringConflicts = [ 'twitch', 'isVariantA'// TwitchNoSub ]; const workerStringAllow = []; const workerStringReinsert = [ 'isVariantA',// TwitchNoSub (prior to (0.9)) 'besuper/',// TwitchNoSub (0.9) '${patch_url}'// TwitchNoSub (0.9.1) ]; function getCleanWorker(worker) { let root = null; let parent = null; let proto = worker; while (proto) { const workerString = proto.toString(); if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) { if (parent !== null) { Object.setPrototypeOf(parent, Object.getPrototypeOf(proto)); } } else { if (root === null) { root = proto; } parent = proto; } proto = Object.getPrototypeOf(proto); } return root; } function getWorkersForReinsert(worker) { const result = []; let proto = worker; while (proto) { const workerString = proto.toString(); if (workerStringReinsert.some((x) => workerString.includes(x))) { result.push(proto); } proto = Object.getPrototypeOf(proto); } return result; } function reinsertWorkers(worker, reinsert) { let parent = worker; for (let i = 0; i < reinsert.length; i++) { Object.setPrototypeOf(reinsert[i], parent); parent = reinsert[i]; } return parent; } function isValidWorker(worker) { const workerString = worker.toString(); return !workerStringConflicts.some((x) => workerString.includes(x)) || workerStringAllow.some((x) => workerString.includes(x)) || workerStringReinsert.some((x) => workerString.includes(x)); } function hasVaftWorkerHooks(workerSource) { return typeof workerSource === 'string' && workerSource.includes('hookWorkerFetch (vaft)') && workerSource.includes('processM3U8'); } function hookWindowWorker() { const reinsert = getWorkersForReinsert(window.Worker); const newWorker = class Worker extends getCleanWorker(window.Worker) { constructor(twitchBlobUrl, options) { let isTwitchWorker = false; try { isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv'); } catch {} if (!isTwitchWorker) { super(twitchBlobUrl, options); return; } let prefetchedWorkerJs = null; try { prefetchedWorkerJs = getWasmWorkerJs(twitchBlobUrl); } catch (error) { console.debug('[vaft] could not inspect Twitch worker before injection', error); } const alreadyHooked = hasVaftWorkerHooks(prefetchedWorkerJs); if (!prefetchedWorkerJs) { super(twitchBlobUrl, options); } else if (alreadyHooked) { super(twitchBlobUrl, options); console.debug('[vaft] Twitch worker already carries VAFT hooks; skipping reinjection'); } else { const workerSourceLiteral = JSON.stringify(prefetchedWorkerJs); const newBlobStr = ` const pendingFetchRequests = new Map(); ${stripAdSegments.toString()} ${videoCodecFamily.toString()} ${getStreamUrlForResolution.toString()} ${processM3U8.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} ${gqlRequest.toString()} ${parseAttributes.toString()} ${getServerTimeFromM3u8.toString()} ${replaceServerTimeInM3u8.toString()} ${getFirstM3u8Url.toString()} const workerString = ${workerSourceLiteral}; declareOptions(self); BackupPlayerTypes = ${JSON.stringify(BackupPlayerTypes)}; GQLDeviceID = ${GQLDeviceID ? "'" + GQLDeviceID + "'" : null}; AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined}; ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null}; ClientVersion = ${ClientVersion ? "'" + ClientVersion + "'" : null}; ClientSession = ${ClientSession ? "'" + ClientSession + "'" : null}; self.addEventListener('message', function(e) { if (e.data.key == 'UpdateClientVersion') { ClientVersion = e.data.value; } else if (e.data.key == 'UpdateClientSession') { ClientSession = e.data.value; } else if (e.data.key == 'UpdateClientId') { ClientID = e.data.value; } else if (e.data.key == 'UpdateDeviceId') { GQLDeviceID = e.data.value; } else if (e.data.key == 'UpdateClientIntegrityHeader') { ClientIntegrityHeader = e.data.value; } else if (e.data.key == 'UpdateAuthorizationHeader') { AuthorizationHeader = e.data.value; } else if (e.data.key == 'FetchResponse') { const responseData = e.data.value; if (pendingFetchRequests.has(responseData.id)) { const { resolve, reject, timeoutId } = pendingFetchRequests.get(responseData.id); pendingFetchRequests.delete(responseData.id); clearTimeout(timeoutId); if (responseData.error) { reject(new Error(responseData.error)); } else { const response = new Response(responseData.body, { status: responseData.status, statusText: responseData.statusText, headers: responseData.headers }); resolve(response); } } } else if (e.data.key == 'TriggeredPlayerReload') { HasTriggeredPlayerReload = true; } else if (e.data.key == 'SimulateAds') { SimulatedAdsDepth = e.data.value; console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth); } else if (e.data.key == 'AllSegmentsAreAdSegments') { AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments; console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments); } else if (e.data.key == 'UpdateBackupPlayerTypes') { BackupPlayerTypes = e.data.value; } }); hookWorkerFetch(); eval(workerString); `; const workerBlobUrl = URL.createObjectURL(new Blob([newBlobStr])); try { super(workerBlobUrl, options); } finally { URL.revokeObjectURL(workerBlobUrl); } } twitchWorkers.add(this); const realTerminate = this.terminate.bind(this); this.terminate = () => { twitchWorkers.delete(this); realTerminate(); }; this.addEventListener('message', (e) => { if (e.data.key == 'UpdateAdBlockBanner') { updateVaftRuntimeStatus(e.data.runtimeStatus); updateAdblockBanner(e.data); } else if (e.data.key == 'PauseResumePlayer') { doTwitchPlayerTask(true, false); } else if (e.data.key == 'ReloadPlayer') { doTwitchPlayerTask(false, true); } }); this.addEventListener('message', async event => { if (event.data.key == 'FetchRequest') { const fetchRequest = event.data.value; const responseData = await handleWorkerFetchRequest(fetchRequest); this.postMessage({ key: 'FetchResponse', value: responseData }); } }); } }; let workerInstance = reinsertWorkers(newWorker, reinsert); Object.defineProperty(window, 'Worker', { get: function() { return workerInstance; }, set: function(value) { if (isValidWorker(value)) { workerInstance = value; } else { console.log('Attempt to set twitch worker denied'); } } }); } function getWasmWorkerJs(twitchBlobUrl) { const req = new XMLHttpRequest(); req.open('GET', twitchBlobUrl, false); req.overrideMimeType("text/javascript"); req.send(); return req.responseText; } function hookWorkerFetch() { console.log('hookWorkerFetch (vaft)'); const realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { const adCacheEntry = AdSegmentCache.get(url); if (adCacheEntry !== undefined) { if (adCacheEntry.enhanced) { return new Response('', { status: 403, statusText: 'ad segment blocked' }); } return new Promise(function(resolve, reject) { const send = function() { return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) { resolve(response); })['catch'](function(err) { reject(err); }); }; send(); }); } url = url.trimEnd(); if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { const processAfter = async function(response) { if (response.status === 200) { resolve(new Response(await processM3U8(url, await response.text(), realFetch))); } else { resolve(response); } }; const send = function() { return realFetch(url, options).then(processAfter)['catch'](function(err) { reject(err); }); }; send(); }); } else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) { V2API = url.includes('/api/v2/'); const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; if (ForceAccessTokenPlayerType) { const tempUrl = new URL(url); tempUrl.searchParams.delete('parent_domains'); url = tempUrl.toString(); } return new Promise(function(resolve, reject) { const processAfter = async function(response) { if (response.status == 200) { const encodingsM3u8 = await response.text(); const serverTime = getServerTimeFromM3u8(encodingsM3u8); let streamInfo = StreamInfos[channelName]; if (streamInfo != null && streamInfo.EncodingsM3U8 != null) { const prevM3u8Url = getFirstM3u8Url(streamInfo.EncodingsM3U8); if (!prevM3u8Url || (await realFetch(prevM3u8Url)).status !== 200) { streamInfo = null; } } if (streamInfo == null || streamInfo.EncodingsM3U8 == null) { StreamInfos[channelName] = streamInfo = { ChannelName: channelName, IsShowingAd: false, LastPlayerReload: 0, EncodingsM3U8: encodingsM3u8, ModifiedM3U8: null, IsUsingModifiedM3U8: false, UsherParams: (new URL(url)).search, RequestedAds: new Set(), Urls: [], ResolutionList: [], BackupEncodingsM3U8Cache: [], ActiveBackupPlayerType: null, IsMidroll: false, IsStrippingAdSegments: false, NumStrippedAdSegments: 0 }; const lines = encodingsM3u8.replaceAll('\r', '').split('\n'); for (let i = 0; i < lines.length - 1; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { const attributes = parseAttributes(lines[i]); const resolution = attributes['RESOLUTION']; if (resolution) { const resolutionInfo = { Resolution: resolution, FrameRate: attributes['FRAME-RATE'], Codecs: attributes['CODECS'] || '', Url: lines[i + 1] }; streamInfo.Urls[lines[i + 1]] = resolutionInfo; streamInfo.ResolutionList.push(resolutionInfo); } StreamInfosByUrl[lines[i + 1]] = streamInfo; } } const decodableResolutionList = streamInfo.ResolutionList.filter((element) => videoCodecFamily(element.Codecs) === 'avc'); const hasEnhancedVariant = streamInfo.ResolutionList.some((element) => { const family = videoCodecFamily(element.Codecs); return family === 'hevc' || family === 'av1'; }); if (AlwaysReloadPlayerOnAd || (decodableResolutionList.length > 0 && hasEnhancedVariant && !SkipPlayerReloadOnHevc)) { if (decodableResolutionList.length > 0) { for (let i = 0; i < lines.length - 1; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF')) { const resSettings = parseAttributes(lines[i].substring(lines[i].indexOf(':') + 1)); const codecsKey = 'CODECS'; const codecs = resSettings[codecsKey] || ''; const codecFamily = videoCodecFamily(codecs); if (codecFamily === 'hevc' || codecFamily === 'av1') { const oldResolution = resSettings['RESOLUTION']; const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); const newResolutionInfo = decodableResolutionList.slice().sort((a, b) => { const [streamWidthA, streamHeightA] = a.Resolution.split('x').map(Number); const [streamWidthB, streamHeightB] = b.Resolution.split('x').map(Number); return Math.abs((streamWidthA * streamHeightA) - (targetWidth * targetHeight)) - Math.abs((streamWidthB * streamHeightB) - (targetWidth * targetHeight)); })[0]; console.log('ModifiedM3U8 swap ' + resSettings[codecsKey] + ' to ' + newResolutionInfo.Codecs + ' oldRes:' + oldResolution + ' newRes:' + newResolutionInfo.Resolution); lines[i] = lines[i].replace(/CODECS="[^"]+"/, `CODECS="${newResolutionInfo.Codecs}"`); lines[i + 1] = newResolutionInfo.Url + ' '.repeat(i + 1); } } } } if (decodableResolutionList.length > 0 || AlwaysReloadPlayerOnAd) { streamInfo.ModifiedM3U8 = lines.join('\n'); } } } streamInfo.LastPlayerReload = Date.now(); resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); } else { resolve(response); } }; const send = function() { return realFetch(url, options).then(processAfter)['catch'](function(err) { reject(err); }); }; send(); }); } } return realFetch.apply(this, arguments); }; } function getServerTimeFromM3u8(encodingsM3u8) { if (V2API) { const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/); return matches ? matches[1] : null; } const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"'); return matches ? matches[1] : null; } function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { if (V2API) { return newServerTime ? encodingsM3u8.replace(/(#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE=")[^"]+(")/, `$1${newServerTime}$2`) : encodingsM3u8; } return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; } function getFirstM3u8Url(encodingsM3u8) { if (typeof encodingsM3u8 !== 'string') { return null; } const lines = encodingsM3u8.replaceAll('\r', '').split('\n'); for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; try { const parsed = new URL(line); if (parsed.protocol === 'https:' && parsed.pathname.endsWith('.m3u8')) { return line; } } catch {} } return null; } function videoCodecFamily(codecs) { if (typeof codecs !== 'string' || !codecs.trim()) { return 'unknown'; } const entries = codecs.toLowerCase().split(',').map((entry) => entry.trim()); for (const codec of entries) { if (codec.startsWith('avc1') || codec.startsWith('avc3')) return 'avc'; if (codec.startsWith('hev1') || codec.startsWith('hvc1')) return 'hevc'; if (codec.startsWith('av01') || codec.startsWith('av0')) return 'av1'; } return 'unknown'; } function stripAdSegments(textStr, stripAllSegments, streamInfo, isEnhancedPlaylist = false) { let hasStrippedAdSegments = false; const lines = textStr.replaceAll('\r', '').split('\n'); const newAdUrl = 'https://twitch.tv'; for (let i = 0; i < lines.length; i++) { let line = lines[i]; line = line .replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`) .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); lines[i] = line; if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { const segmentUrl = lines[i + 1]; if (!AdSegmentCache.has(segmentUrl)) { streamInfo.NumStrippedAdSegments++; } AdSegmentCache.set(segmentUrl, { timestamp: Date.now(), enhanced: Boolean(isEnhancedPlaylist) }); hasStrippedAdSegments = true; } if (line.includes(AdSignifier)) { hasStrippedAdSegments = true; } } if (hasStrippedAdSegments) { for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { lines[i] = ''; } } } else { streamInfo.NumStrippedAdSegments = 0; } streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; AdSegmentCache.forEach((value, key, map) => { if ((value?.timestamp ?? value) < Date.now() - 120000) { map.delete(key); } }); return lines.join('\n'); } function getStreamUrlForResolution(encodingsM3u8, resolutionInfo, selectionOut = null) { const encodingsLines = encodingsM3u8.replaceAll('\r', '').split('\n'); const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); const targetFamily = videoCodecFamily(resolutionInfo.Codecs); let bestSameFamily = null; let bestSameFamilyScore = Infinity; let bestAny = null; let bestAnyScore = Infinity; for (let i = 0; i < encodingsLines.length - 1; i++) { if (!encodingsLines[i].startsWith('#EXT-X-STREAM-INF') || !encodingsLines[i + 1].includes('.m3u8')) continue; const attributes = parseAttributes(encodingsLines[i]); const resolution = attributes['RESOLUTION']; if (!resolution) continue; const frameRate = attributes['FRAME-RATE']; const codecs = attributes['CODECS'] || ''; const family = videoCodecFamily(codecs); const [width, height] = resolution.split('x').map(Number); const difference = Math.abs((width * height) - (targetWidth * targetHeight)); const exactResolutionPenalty = resolution === resolutionInfo.Resolution ? 0 : 1e12; const frameRatePenalty = frameRate == resolutionInfo.FrameRate ? 0 : 1; const score = exactResolutionPenalty + difference + frameRatePenalty; const candidate = { url: encodingsLines[i + 1], codecs, resolution, frameRate, family }; if (score < bestAnyScore) { bestAny = candidate; bestAnyScore = score; } if (targetFamily !== 'unknown' && family === targetFamily && score < bestSameFamilyScore) { bestSameFamily = candidate; bestSameFamilyScore = score; } } const selected = bestSameFamily || bestAny; if (selectionOut && selected) { selectionOut.url = selected.url; selectionOut.codecs = selected.codecs; selectionOut.resolution = selected.resolution; selectionOut.frameRate = selected.frameRate; selectionOut.family = selected.family; } return selected?.url ?? null; } async function processM3U8(url, textStr, realFetch) { const streamInfo = StreamInfosByUrl[url]; if (!streamInfo) { return textStr; } if (HasTriggeredPlayerReload) { HasTriggeredPlayerReload = false; streamInfo.LastPlayerReload = Date.now(); } const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; if (haveAdTags) { streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); if (!streamInfo.IsShowingAd) { streamInfo.IsShowingAd = true; postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: false, runtimeStatus: { channel: streamInfo.ChannelName ?? null, showingAd: streamInfo.IsShowingAd ?? false, strippingSegments: false, backupPlayerType: streamInfo.ActiveBackupPlayerType ?? null, lastPlayerReload: streamInfo.LastPlayerReload ?? null, numStrippedAdSegments: streamInfo.NumStrippedAdSegments ?? 0, isMidroll: streamInfo.IsMidroll ?? false } }); } if (!streamInfo.IsMidroll) { const lines = textStr.replaceAll('\r', '').split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('#EXTINF') && lines.length > i + 1) { if (!line.includes(',live') && !streamInfo.RequestedAds.has(lines[i + 1])) { streamInfo.RequestedAds.add(lines[i + 1]); if (streamInfo.RequestedAds.size > 100) { streamInfo.RequestedAds.delete(streamInfo.RequestedAds.values().next().value); } fetch(lines[i + 1]).then((response)=>{response.blob()}); break; } } } } const currentResolution = streamInfo.Urls[url]; if (!currentResolution) { console.log('Ads will leak due to missing resolution info for ' + url); return textStr; } const currentCodecs = currentResolution.Codecs || ''; const currentCodecFamily = videoCodecFamily(currentCodecs); const isEnhanced = currentCodecFamily === 'hevc' || currentCodecFamily === 'av1'; if (((isEnhanced && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { streamInfo.IsUsingModifiedM3U8 = true; streamInfo.LastPlayerReload = Date.now(); postMessage({ key: 'ReloadPlayer' }); } let backupPlayerType = null; let backupM3u8 = null; let fallbackM3u8 = null; let backupCodecFamily = 'unknown'; let fallbackCodecFamily = 'unknown'; let startIndex = 0; let isDoingMinimalRequests = false; if (streamInfo.LastPlayerReload > Date.now() - PlayerReloadMinimalRequestsTime) { startIndex = PlayerReloadMinimalRequestsPlayerIndex; isDoingMinimalRequests = true; } for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) { const playerType = BackupPlayerTypes[playerTypeIndex]; const realPlayerType = playerType.replace('-CACHED', ''); const isFullyCachedPlayerType = playerType != realPlayerType; for (let i = 0; i < 2; i++) { let isFreshM3u8 = false; let encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType]; if (!encodingsM3u8) { isFreshM3u8 = true; try { const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, realPlayerType); if (accessTokenResponse.status === 200) { const accessToken = await accessTokenResponse.json(); const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); const encodingsM3u8Response = await realFetch(urlInfo.href); if (encodingsM3u8Response.status === 200) { encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); } } } catch (err) {} } if (encodingsM3u8) { try { const selectedBackup = {}; const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution, selectedBackup); const streamM3u8Response = await realFetch(streamM3u8Url); if (streamM3u8Response.status == 200) { const m3u8Text = await streamM3u8Response.text(); if (m3u8Text) { if (playerType == FallbackPlayerType) { fallbackM3u8 = m3u8Text; fallbackCodecFamily = videoCodecFamily(selectedBackup.codecs); } if ((!m3u8Text.includes(AdSignifier) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) || (!fallbackM3u8 && playerTypeIndex >= BackupPlayerTypes.length - 1)) { backupPlayerType = playerType; backupM3u8 = m3u8Text; backupCodecFamily = videoCodecFamily(selectedBackup.codecs); break; } if (isFullyCachedPlayerType) { break; } if (isDoingMinimalRequests) { backupPlayerType = playerType; backupM3u8 = m3u8Text; backupCodecFamily = videoCodecFamily(selectedBackup.codecs); break; } } } } catch (err) {} } streamInfo.BackupEncodingsM3U8Cache[playerType] = null; if (isFreshM3u8) { break; } } } if (!backupM3u8 && fallbackM3u8) { backupPlayerType = FallbackPlayerType; backupM3u8 = fallbackM3u8; backupCodecFamily = fallbackCodecFamily; } if (backupM3u8) { textStr = backupM3u8; if (streamInfo.ActiveBackupPlayerType != backupPlayerType) { streamInfo.ActiveBackupPlayerType = backupPlayerType; console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType})`); } } const effectiveCodecFamily = backupM3u8 ? backupCodecFamily : currentCodecFamily; const effectiveIsEnhanced = effectiveCodecFamily === 'hevc' || effectiveCodecFamily === 'av1'; const stripEnhanced = isEnhanced && streamInfo.ModifiedM3U8; if (IsAdStrippingEnabled || stripEnhanced) { textStr = stripAdSegments(textStr, stripEnhanced, streamInfo, effectiveIsEnhanced); } } else if (streamInfo.IsShowingAd) { console.log('Finished blocking ads'); const wasStrippingAdSegments = streamInfo.IsStrippingAdSegments; streamInfo.IsShowingAd = false; streamInfo.IsStrippingAdSegments = false; streamInfo.NumStrippedAdSegments = 0; streamInfo.ActiveBackupPlayerType = null; if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd || wasStrippingAdSegments) { streamInfo.IsUsingModifiedM3U8 = false; streamInfo.LastPlayerReload = Date.now(); postMessage({ key: 'ReloadPlayer' }); } else { postMessage({ key: 'PauseResumePlayer' }); } } postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: streamInfo.IsStrippingAdSegments, numStrippedAdSegments: streamInfo.NumStrippedAdSegments, runtimeStatus: { channel: streamInfo.ChannelName ?? null, showingAd: streamInfo.IsShowingAd ?? false, strippingSegments: streamInfo.IsStrippingAdSegments ?? false, backupPlayerType: streamInfo.ActiveBackupPlayerType ?? null, lastPlayerReload: streamInfo.LastPlayerReload ?? null, numStrippedAdSegments: streamInfo.NumStrippedAdSegments ?? 0, isMidroll: streamInfo.IsMidroll ?? false } }); return textStr; } function parseAttributes(str) { return Object.fromEntries( str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) .filter(Boolean) .map(x => { const idx = x.indexOf('='); const key = x.substring(0, idx); const value = x.substring(idx + 1); const num = Number(value); return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]; })); } function getAccessToken(channelName, playerType) { const body = { operationName: 'PlaybackAccessToken', variables: { isLive: true, login: channelName, isVod: false, vodID: "", playerType: playerType, platform: playerType == 'autoplay' ? 'android' : 'web' }, extensions: { persistedQuery: { version:1, sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9" } } }; return gqlRequest(body, playerType); } function gqlRequest(body, playerType) { if (!GQLDeviceID) { GQLDeviceID = ''; const dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; const dcharactersLength = dcharacters.length; for (let i = 0; i < 32; i++) { GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength)); } } let headers = { 'Client-ID': ClientID, 'X-Device-Id': GQLDeviceID, 'Authorization': AuthorizationHeader, ...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}), ...(ClientVersion && {'Client-Version': ClientVersion}), ...(ClientSession && {'Client-Session-Id': ClientSession}) }; return new Promise((resolve, reject) => { const requestId = Math.random().toString(36).substring(2, 15); const fetchRequest = { id: requestId, url: 'https://gql.twitch.tv/gql', options: { method: 'POST', body: JSON.stringify(body), headers } }; const timeoutId = setTimeout(() => { const pending = pendingFetchRequests.get(requestId); if (!pending) return; pendingFetchRequests.delete(requestId); pending.reject(new Error('VAFT worker fetch request timed out')); }, WorkerFetchTimeoutMs); pendingFetchRequests.set(requestId, { resolve, reject, timeoutId }); postMessage({ key: 'FetchRequest', value: fetchRequest }); }); } let playerForMonitoringBuffering = null; const playerBufferState = { channelName: null, hasStreamStarted: false, position: 0, bufferedPosition: 0, bufferDuration: 0, numSame: 0, lastFixTime: 0, isLive: true }; function isStandaloneTwitchAdVideo(video) { if (!video) return false; const videoSrc = video.currentSrc || video.getAttribute?.('src') || ''; if (!videoSrc || videoSrc.startsWith('blob:')) return false; try { const host = new URL(videoSrc, document.location.href).hostname.toLowerCase(); return host === 'media-amazon.com' || host.endsWith('.media-amazon.com'); } catch { return false; } } function getPlayerVideoElement() { const knownPlayerVideo = playerForMonitoringBuffering?.player?.getHTMLVideoElement?.(); if (knownPlayerVideo && !isStandaloneTwitchAdVideo(knownPlayerVideo)) { return knownPlayerVideo; } const videos = document.getElementsByTagName('video'); for (let i = 0; i < videos.length; i++) { const video = videos[i]; if (!video.dataset.vaftAdHidden && !isStandaloneTwitchAdVideo(video)) { return video; } } return null; } window.vaftGetPlayerVideoElement = getPlayerVideoElement; function hideStandaloneTwitchVideoAds() { const primaryVideo = playerForMonitoringBuffering?.player?.getHTMLVideoElement?.(); const videos = document.getElementsByTagName('video'); for (let i = 0; i < videos.length; i++) { const video = videos[i]; const isAdVideo = isStandaloneTwitchAdVideo(video); if (isAdVideo && video !== primaryVideo) { if (!video.dataset.vaftAdHidden) { video.dataset.vaftAdHidden = '1'; video.dataset.vaftAdWasMuted = video.muted ? '1' : '0'; } video.style.setProperty('display', 'none', 'important'); try { video.muted = true; if (!video.paused) video.pause(); } catch {} } else if (video.dataset.vaftAdHidden && !isAdVideo) { const wasMuted = video.dataset.vaftAdWasMuted === '1'; delete video.dataset.vaftAdHidden; delete video.dataset.vaftAdWasMuted; video.style.removeProperty('display'); try { video.muted = wasMuted; } catch {} } } } function monitorPlayerBuffering() { hideStandaloneTwitchVideoAds(); if (playerForMonitoringBuffering) { try { const player = playerForMonitoringBuffering.player; const state = playerForMonitoringBuffering.state; if (!player.core) { playerForMonitoringBuffering = null; } else if (state.props?.content?.type === 'live' && !player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { const m3u8Url = player.core?.state?.path; if (m3u8Url) { const fileName = new URL(m3u8Url).pathname.split('/').pop(); if (fileName?.endsWith('.m3u8')) { const channelName = fileName.slice(0, -5); if (playerBufferState.channelName != channelName) { playerBufferState.channelName = channelName; playerBufferState.hasStreamStarted = false; playerBufferState.numSame = 0; } } } if (player.getState() === 'Playing') { playerBufferState.hasStreamStarted = true; } const position = player.core?.state?.position; const bufferedPosition = player.core?.state?.bufferedPosition; const bufferDuration = player.getBufferDuration(); if (position !== undefined && bufferedPosition !== undefined) { if (playerBufferState.hasStreamStarted && (!PlayerBufferingPrerollCheckEnabled || position > PlayerBufferingPrerollCheckOffset) && (playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) && playerBufferState.bufferedPosition == bufferedPosition && playerBufferState.bufferDuration >= bufferDuration && (position != 0 || bufferedPosition != 0 || bufferDuration != 0) ) { playerBufferState.numSame++; if (playerBufferState.numSame == PlayerBufferingSameStateCount) { console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration); const isPausePlay = !PlayerBufferingDoPlayerReload; const isReload = PlayerBufferingDoPlayerReload; doTwitchPlayerTask(isPausePlay, isReload); playerBufferState.lastFixTime = Date.now(); playerBufferState.numSame = 0; } } else { playerBufferState.numSame = 0; } playerBufferState.position = position; playerBufferState.bufferedPosition = bufferedPosition; playerBufferState.bufferDuration = bufferDuration; } else { playerBufferState.numSame = 0; } } } catch (err) { console.error('error when monitoring player for buffering: ' + err); playerForMonitoringBuffering = null; } } if (!playerForMonitoringBuffering && getPlayerVideoElement()) { const playerAndState = getPlayerAndState(); if (playerAndState && playerAndState.player && playerAndState.state) { playerForMonitoringBuffering = { player: playerAndState.player, state: playerAndState.state }; } } const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; if (playerBufferState.isLive && !isLive) { updateAdblockBanner({ hasAds: false }); } playerBufferState.isLive = isLive; setTimeout(monitorPlayerBuffering, PlayerBufferingDelay); } function updateAdblockBanner(data) { const playerRootDiv = document.querySelector('.video-player'); if (playerRootDiv != null) { let adBlockDiv = null; adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); if (adBlockDiv == null) { adBlockDiv = document.createElement('div'); adBlockDiv.className = 'adblock-overlay'; adBlockDiv.innerHTML = '