twitch-videoad.js text/javascript (function() { if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } // Skip injection in nested frames that aren't legitimate Twitch embed contexts. // Twitch's main channel page has 5+ hidden cross-origin iframes (auth, analytics, // ad SDK, etc.) and uBO injects into all matching ones. Each becomes a racing vaft // instance that fights for player control. Only the top frame hosts the player on // twitch.tv/CHANNEL; nested auxiliary frames are noise. // Allow-list for nested-frame injection: Twitch's three documented embed contexts // (https://dev.twitch.tv/docs/embed/video-and-clips/) — preserves Twitch streams // embedded on third-party sites where vaft runs in an iframe whose parent is on // a different origin. // Use window.frameElement to detect nested frames — null on top frame, the iframe // element on a same-origin nested frame, throws on a cross-origin nested frame. // More reliable than 'window !== window.top' because Tampermonkey wraps window in a // proxy where the strict comparison can return true even on the top frame. let _isNested = false; try { _isNested = window.frameElement !== null; } catch (_e) { _isNested = true; } if (_isNested) { const _host = document.location.hostname; const _isEmbedContext = _host === 'player.twitch.tv' || _host === 'embed.twitch.tv' || document.location.pathname.startsWith('/embed/'); if (!_isEmbedContext) { console.log('[AD DEBUG] vaft skipped — nested frame on ' + _host + document.location.pathname + ' (not a Twitch embed). If you see this on twitch.tv/CHANNEL top frame, please report.'); return; } } // Skip injection on the Twitch clip editor — clips.twitch.tv host or //clip/ path. // Our fetch/Worker hooks and buffer monitor are aimed at the live channel player; on the // clip editor's seekable preview they have no ads to act on and have caused the preview // to freeze when the user drags the trim range. (Sync'd with TTV-AB v6.4.9.) { const _clipHost = document.location.hostname; const _clipPath = document.location.pathname || ''; if (_clipHost === 'clips.twitch.tv' || /^\/[^/]+\/clip\/[^/]+/.test(_clipPath)) { console.log('[AD DEBUG] vaft skipped — clip editor page (' + _clipHost + _clipPath + ').'); return; } } 'use strict'; const ourTwitchAdSolutionsVersion = 74;// Used to prevent conflicts with outdated versions of the scripts console.log('[AD DEBUG] TwitchAdSolutions vaft v' + ourTwitchAdSolutionsVersion + ' loading'); if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { console.log('[AD DEBUG] CONFLICT: vaft v' + ourTwitchAdSolutionsVersion + ' skipped — another script already active (v' + window.twitchAdSolutionsVersion + '). Remove duplicate scripts.'); return; } window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; // Configuration and state shared between window and worker scopes function declareOptions(scope) { // 'twitch-stitched' catches the twitch-stitched-* DATERANGE class family // (-ad, -mid, -pod, etc.) without requiring an exact -ad suffix. Twitch- // prefixed so we don't re-introduce the PR #120 false-positive from bare // 'stitched' substring match. The specific twitch-stitched-ad DATERANGE // marker is a subset of this prefix. scope.AdSignifiers = ['stitched-ad', 'EXT-X-CUE-OUT', 'twitch-stitched', 'EXT-X-DATERANGE:CLASS="twitch-maf-ad"', 'EXT-X-DATERANGE:CLASS="twitch-trigger"']; scope.AdSegmentURLPatterns = ['/adsquared/', '/_404/', '/processing']; // Precompiled regexes shared across the stripAdSegments hot path. Declared // here (serialized into the worker blob with declareOptions) so literals // inside the per-line strip loop don't recompile on every iteration — for // a 100-line m3u8 at ~2 polls/sec during an ad break, hoisting the URL // rewrite alone saves ~200 regex compilations per second. scope.TwitchAdUrlRewriteRegex = /(X-TV-TWITCH-AD(?:-[A-Z]+)*-URLS?=")[^"]*(")/g; scope.UriAttributeRegex = /URI="([^"]+)"/; scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; scope.BackupPlayerTypes = [ // Order matters: first clean type wins. 'embed' moved to end — field-observed // Twitch returns GQL 'server error' for streamPlaybackAccessToken on embed when // requested from twitch.tv origin, wasting ~200-400ms per break as first-try. // Kept in case it ever succeeds on some channel/user combo. 'site',//Source 'popout',//Source 'mobile_web',//Mobile 'embed',//Source (unreliable — see note above) // 'autoplay' (360p) removed: when committed as cycle backup, the player gets stuck // in an endless loading circle after the CSAI-only path releases the backup — // autoplay variants don't transition cleanly back to main stream variants. //'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) ]; scope.FallbackPlayerType = 'site';// was 'embed' — site is more reliable when all Source types end up ad-laden scope.ForceAccessTokenPlayerType = 'popout'; scope.PreferLowQualityBackup = true;// Hybrid safety net for SSAI-heavy breaks: sticky escape hatch (fires after ~8s stuck in all-stripped state) + autoplay (360p) as last-resort backup when all Source types are ad-laden. Default on; set twitchAdSolutions_preferLowQualityBackup=false to disable. scope.FastAutoplayFirstTry = false;// On SSAI-uniform channels, prepend autoplay to the iteration when the prior break exhausted all 4 Source types. Saves ~1.5s of probe buffering. Auto-resets on Source-tier recovery. Opt-in: twitchAdSolutions_fastAutoplayFirstTry=true. scope.BackupSwapFirst = true;// On ad detect, immediately swap to a backup player-type m3u8 (TTV-AB-style). Avoids MediaSource mixing from strip activity — fewer loading circles in field. Cost: extra fetches on every ad break. Default on; set twitchAdSolutions_backupSwapFirst=false to disable. 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 = true;// After the ad finishes do a player reload instead of pause/play scope.ReloadCooldownSeconds = 30;// Minimum seconds between reloads — breaks CSAI cascades triggered by reload scope.DisableReloadCap = false;// If true, buffer monitor reloads unlimited times (pre-v47 behavior, risk of cascade) scope.DriftCorrectionRate = 1.1;// Playback rate for catching up to live edge after reload (0 = disable drift correction) scope.EarlyReloadPollThreshold = 3;// Number of consecutive all-stripped polls before triggering early reload (each poll ~2s, so 3 = ~6s, 5 = ~10s, 10 = ~20s; 0 = disable). Lowered from 5 to 3 to match the testing variant. Override via localStorage twitchAdSolutions_earlyReloadPollThreshold. scope.PinBackupPlayerType = true;// Remember which backup player type worked and try it first on next ad break scope.PlayerReloadMinimalRequestsTime = 1500; scope.PlayerReloadMinimalRequestsPlayerIndex = 2;//autoplay scope.HasTriggeredPlayerReload = false; scope.StreamInfos = Object.create(null); scope.StreamInfosByUrl = Object.create(null); 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 = 0.5;// Lowered 1 → 0.5: avoids cascade fires on thin-but-functional buffer at live edge where pause/play interacts badly with Twitch's playback-monitor. 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.StreamInfoMaxAgeMs = 30 * 60 * 1000; } function pruneStreamInfos() { const now = Date.now(); for (const channelName in StreamInfos) { const streamInfo = StreamInfos[channelName]; if (!streamInfo || !streamInfo.LastSeenAt || (now - streamInfo.LastSeenAt) > StreamInfoMaxAgeMs) { if (streamInfo && streamInfo.Urls) { for (const url in streamInfo.Urls) { delete StreamInfosByUrl[url]; } } delete StreamInfos[channelName]; } } } function createStreamInfo(channelName, encodingsM3u8, usherParams) { return { ChannelName: channelName, LastSeenAt: Date.now(), EncodingsM3U8: encodingsM3u8, UsherParams: usherParams, Urls: Object.create(null), ResolutionList: [], RequestedAds: new Set(), ModifiedM3U8: null, IsUsingModifiedM3U8: false, IsShowingAd: false, IsMidroll: false, AdBreakStartedAt: 0, PodLength: 1, HasConfirmedAdAttrs: false, CleanPlaylistCount: 0, PendingAdEndAt: 0, AdEndBounceCount: 0, ConsecutiveZeroStripBreaks: 0, CsaiOnlyThisBreak: false, IsStrippingAdSegments: false, NumStrippedAdSegments: 0, RecoverySegments: [], RecoveryStartSeq: undefined, FreezeStartedAt: 0, ConsecutiveAllStrippedPolls: 0, TotalAllStrippedPolls: 0, LastCleanNativeM3U8: null, LastCleanNativePlaylistAt: 0, BackupEncodingsM3U8Cache: [], ActiveBackupPlayerType: null, PinnedBackupPlayerType: null, LastCommittedBackupPlayerType: null, FailedBackupPlayerTypes: new Map(), LoggedBackupAdsByType: null, CycleRescuedThisBreak: false, EarlyReloadCount: 0, EarlyReloadAtPoll: 0, EarlyReloadTriggered: false, EarlyReloadAwaitingResult: false, EscapeHatchFired: false, LastBreakUsedEscapeHatch: false,// FastAutoplayFirstTry signal — set when a break commits autoplay via PreferLowQualityBackup escape hatch. Reset when a Source-tier type wins. LastPlayerReload: 0, ReloadTimestamps: [], HasCheckedUnknownTags: false, HasLoggedAdAttributes: false, HasLoggedUnknownSignifiers: false, }; } function maskAsNative(fn, name) { fn.toString = () => 'function ' + name + '() { [native code] }'; return fn; } const loggedCsaiTypes = new Set(); let isActivelyStrippingAds = false; let localStorageHookFailed = false; const twitchWorkers = []; let cachedRootNode = null;// Cached #root DOM element (never changes in React SPAs) let cachedPlayerRootDiv = null;// Cached .video-player element // One-shot flags for overlay-hide logs. Twitch's React tree re-mounts SDA // wrappers and ad-break cards constantly during an ad break, so the // hide-and-log fires hundreds of times. Log the first occurrence of each // hide type per page load, then stay silent — the hide itself still runs // on every tick via dataset-based dedup. let loggedSdaHide = false; // Strings used to detect and handle conflicting Twitch worker overrides (e.g. TwitchNoSub) const workerStringConflicts = [ 'twitch', 'isVariantA'// TwitchNoSub ]; const workerStringReinsert = [ 'isVariantA',// TwitchNoSub (prior to (0.9)) 'besuper/',// TwitchNoSub (0.9) '${patch_url}'// TwitchNoSub (0.9.1) ]; // Walk the Worker prototype chain and remove conflicting overrides 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))) { if (parent !== null) { // Foreign extension may have frozen Worker.prototype; per-link try-catch. try { Object.setPrototypeOf(parent, Object.getPrototypeOf(proto)); } catch {} } } 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++) { // Per-link try-catch: foreign-frozen entry shouldn't abort the chain. try { Object.setPrototypeOf(reinsert[i], parent); } catch {} parent = reinsert[i]; } return parent; } function isValidWorker(worker) { const workerString = worker.toString(); const hasConflict = workerStringConflicts.some((x) => workerString.includes(x)); const hasReinsert = workerStringReinsert.some((x) => workerString.includes(x)); if (hasConflict && !hasReinsert) { console.log('[AD DEBUG] Worker rejected — conflict string found: ' + workerStringConflicts.filter((x) => workerString.includes(x)).join(', ')); } return !hasConflict || hasReinsert; } // Replace window.Worker to intercept Twitch's video worker and inject ad-blocking logic let injectedBlobUrl = null; let originalRevokeObjectURL = null; function hookWindowWorker() { // Prevent Twitch from revoking our injected worker blob URL if (!URL.revokeObjectURL.__tasMasked) { originalRevokeObjectURL = URL.revokeObjectURL; URL.revokeObjectURL = maskAsNative(function(url) { if (url === injectedBlobUrl) return; return originalRevokeObjectURL.call(this, url); }, 'revokeObjectURL'); URL.revokeObjectURL.__tasMasked = true; } const reinsert = getWorkersForReinsert(window.Worker); const cleanWorker = getCleanWorker(window.Worker) || window.Worker; const newWorker = class Worker extends cleanWorker { constructor(twitchBlobUrl, options) { let isTwitchWorker = false; try { isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv'); } catch {} if (!isTwitchWorker) { super(twitchBlobUrl, options); console.log('[AD DEBUG] Non-Twitch worker skipped: ' + twitchBlobUrl); return; } // Pre-check: verify we can fetch the worker JS before injecting let prefetchedWorkerJs = null; try { prefetchedWorkerJs = getWasmWorkerJs(twitchBlobUrl); } catch {} if (!prefetchedWorkerJs) { super(twitchBlobUrl, options); console.log('[AD DEBUG] Failed to fetch worker JS — falling back to unmodified worker'); return; } console.log('[AD DEBUG] Worker intercepted — injecting ad-block hooks'); const newBlobStr = ` const pendingFetchRequests = new Map(); ${hasAdTags.toString()} ${getMatchedAdSignifiers.toString()} ${stripAdSegments.toString()} ${getStreamUrlForResolution.toString()} ${processM3U8.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} ${gqlRequest.toString()} ${parseAttributes.toString()} ${getWasmWorkerJs.toString()} ${getServerTimeFromM3u8.toString()} ${replaceServerTimeInM3u8.toString()} ${pruneStreamInfos.toString()} ${createStreamInfo.toString()} const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); declareOptions(self); if (!self.__tasPruneInterval) { self.__tasPruneInterval = setInterval(pruneStreamInfos, 5 * 60 * 1000); } ReloadPlayerAfterAd = ${ReloadPlayerAfterAd}; ReloadCooldownSeconds = ${ReloadCooldownSeconds}; DisableReloadCap = ${DisableReloadCap}; EarlyReloadPollThreshold = ${EarlyReloadPollThreshold}; PinBackupPlayerType = ${PinBackupPlayerType}; PreferLowQualityBackup = ${PreferLowQualityBackup}; FastAutoplayFirstTry = ${FastAutoplayFirstTry}; BackupSwapFirst = ${BackupSwapFirst}; ForceAccessTokenPlayerType = '${ForceAccessTokenPlayerType}'; 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); clearTimeout(timeoutId); pendingFetchRequests.delete(responseData.id); if (responseData.error) { reject(new Error(responseData.error)); } else { // Create a Response object from the response data. // Response constructor only takes status/statusText/headers — url/redirected/type // must be defined on the instance. IVS WASM validates these (Spade/tracking // requests) and throws NetworkError if they're missing — TTV-AB v6.3.5 fix. const response = new Response(responseData.body, { status: responseData.status, statusText: responseData.statusText, headers: responseData.headers }); try { Object.defineProperty(response, 'url', { value: responseData.url || '', configurable: true }); Object.defineProperty(response, 'redirected', { value: !!responseData.redirected, configurable: true }); Object.defineProperty(response, 'type', { value: responseData.type || 'basic', configurable: true }); } catch {} 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); } }); hookWorkerFetch(); // Guard the eval — malformed workerString shouldn't silently break // Twitch's player logic without a diagnostic. try { eval(workerString); } catch (e) { console.error('[AD DEBUG] Worker eval failed — Twitch player logic not loaded:', e); } `; if (injectedBlobUrl && originalRevokeObjectURL) { try { originalRevokeObjectURL.call(URL, injectedBlobUrl); } catch {} } injectedBlobUrl = URL.createObjectURL(new Blob([newBlobStr])); super(injectedBlobUrl, options); twitchWorkers.length = 0; twitchWorkers.push(this); this.addEventListener('message', (e) => { if (e.data.key == 'UpdateAdBlockBanner') { updateAdblockBanner(e.data); // Track backup stream switches (start and end of ad break) if (e.data.hasAds !== !!playerBufferState.inAdBreak) { playerBufferState.lastBackupSwitchAt = Date.now(); // Reset position tracking on ad-end so the stream switch gap isn't detected as a jump if (!e.data.hasAds) { playerBufferState.position = 0; } } playerBufferState.inAdBreak = !!e.data.hasAds; // Clear drift catch-up when ads start — don't run 1.1x during ad handling if (e.data.hasAds && (driftCatchUpInterval || driftCatchUpTimeout)) { if (driftCatchUpInterval) { clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; } if (driftCatchUpTimeout) { clearTimeout(driftCatchUpTimeout); driftCatchUpTimeout = null; } try { document.querySelector('video').playbackRate = 1.0; } catch {} } } else if (e.data.key == 'PauseResumePlayer') { doTwitchPlayerTask(true, false); } else if (e.data.key == 'ReloadPlayer') { doTwitchPlayerTask(false, true, e.data.kind); } }); 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 }); } }); // Worker crash recovery — IVS WASM worker can fire RuntimeError // (e.g. "index out of bounds") and die. A single crash fires multiple // error events; dedupe via a local flag. On first error, trigger a // hard reload via the main reload path — Twitch re-spawns the worker // as part of the new player instance, and existing reload cooldown // prevents runaway restart loops. let crashed = false; this.addEventListener('error', (e) => { if (crashed) return; crashed = true; console.log('[AD DEBUG] IVS WASM worker crashed: ' + ((e && e.message) || 'unknown error') + ' — triggering hard reload to recover'); try { doTwitchPlayerTask(false, true, 'early'); } catch (err) { console.log('[AD DEBUG] Worker crash recovery failed: ' + err.message); } }); } }; 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) { if (!getWasmWorkerJs.cache) { getWasmWorkerJs.cache = Object.create(null); } if (getWasmWorkerJs.cache[twitchBlobUrl]) { return getWasmWorkerJs.cache[twitchBlobUrl]; } const req = new XMLHttpRequest(); req.open('GET', twitchBlobUrl, false); req.overrideMimeType("text/javascript"); req.send(); const text = req.responseText; getWasmWorkerJs.cache[twitchBlobUrl] = text; return text; } // Hook fetch() in the worker scope to intercept m3u8 playlist requests and ad segments function hookWorkerFetch() { console.log('hookWorkerFetch (vaft)'); const BLANK_MP4 = new Blob([Uint8Array.from(atob('AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA'), c => c.charCodeAt(0))], {type: 'video/mp4'}); const realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { if (AdSegmentCache.has(url)) { return new Response(BLANK_MP4); } 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); } }; realFetch(url, options).then(function(response) { processAfter(response); })['catch'](function(err) { reject(err); }); }); } else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) { V2API = url.includes('/api/v2/'); const parsedUrl = new URL(url); const channelName = parsedUrl.pathname.match(/([^\/]+)(?=\.\w+$)/)?.[0]; if (ForceAccessTokenPlayerType) { // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads parsedUrl.searchParams.delete('parent_domains'); url = parsedUrl.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 && (await realFetch(streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)?.[0])).status !== 200) { // The cached encodings are dead (the stream probably restarted) streamInfo = null; } if (streamInfo == null || streamInfo.EncodingsM3U8 == null) { // Clear reload-pending flag from a prior stream session — without this, // a reload triggered on the previous channel bleeds into the new channel's // cooldown calculation, blocking legitimate end-of-break reloads. HasTriggeredPlayerReload = false; console.log('[AD DEBUG] New stream session — channel: ' + channelName + ', API: ' + (V2API ? 'v2' : 'v1')); StreamInfos[channelName] = streamInfo = createStreamInfo(channelName, encodingsM3u8, parsedUrl.search); const lines = encodingsM3u8.split(/\r?\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'], // AUDIO/VIDEO/SUBTITLES groups copied onto the rewritten STREAM-INF line // during HEVC→AVC fallback (TTV-AB v6.7.5 parser fix). Audio: attributes['AUDIO'] || '', Video: attributes['VIDEO'] || '', Subtitles: attributes['SUBTITLES'] || '', Url: lines[i + 1] }; streamInfo.Urls[lines[i + 1]] = resolutionInfo; streamInfo.ResolutionList.push(resolutionInfo); } StreamInfosByUrl[lines[i + 1]] = streamInfo; } } if (streamInfo.ResolutionList.length === 0) { console.log('[AD DEBUG] No resolutions parsed from encodings m3u8 — Twitch may have changed the format'); } const nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0')); if (AlwaysReloadPlayerOnAd || (nonHevcResolutionList.length > 0 && streamInfo.ResolutionList.some((element) => element.Codecs.startsWith('hev') || element.Codecs.startsWith('hvc')) && !SkipPlayerReloadOnHevc)) { const replaceOrAppendStreamInfAttr = (line, key, value) => { if (typeof value !== 'string' || !value) return line; const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const next = key + '="' + escaped + '"'; const pattern = new RegExp('(^|,)' + key + '=("[^"]*"|[^,]*)'); return pattern.test(line) ? line.replace(pattern, '$1' + next) : line + ',' + next; }; if (nonHevcResolutionList.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'; if (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) { const oldResolution = resSettings['RESOLUTION']; const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); const targetArea = targetWidth * targetHeight; let newResolutionInfo = null; let closestDiff = Infinity; for (let j = 0; j < nonHevcResolutionList.length; j++) { const candidate = nonHevcResolutionList[j]; const [streamWidth, streamHeight] = candidate.Resolution.split('x').map(Number); const diff = Math.abs((streamWidth * streamHeight) - targetArea); if (diff < closestDiff) { closestDiff = diff; newResolutionInfo = candidate; } } console.log('ModifiedM3U8 swap ' + resSettings[codecsKey] + ' to ' + newResolutionInfo.Codecs + ' oldRes:' + oldResolution + ' newRes:' + newResolutionInfo.Resolution); lines[i] = lines[i].replace(/CODECS="[^"]+"/, `CODECS="${newResolutionInfo.Codecs}"`); // Copy AUDIO/VIDEO/SUBTITLES groups (TTV-AB v6.7.5 parser fix). lines[i] = replaceOrAppendStreamInfAttr(lines[i], 'AUDIO', newResolutionInfo.Audio); lines[i] = replaceOrAppendStreamInfAttr(lines[i], 'VIDEO', newResolutionInfo.Video); lines[i] = replaceOrAppendStreamInfAttr(lines[i], 'SUBTITLES', newResolutionInfo.Subtitles); lines[i + 1] = newResolutionInfo.Url + ' '.repeat(i + 1);// The stream doesn't load unless each url line is unique } } } } if (nonHevcResolutionList.length > 0 || AlwaysReloadPlayerOnAd) { streamInfo.ModifiedM3U8 = lines.join('\n'); } } } streamInfo.LastSeenAt = Date.now(); // Note: do NOT set streamInfo.LastPlayerReload here. It was previously // set unconditionally on new stream session creation, which caused the // first end-of-break reload of every new channel to be blocked by // cooldown — the cooldown check treated the session-creation timestamp // as a recent reload, even though no reload had actually occurred. resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); } else { resolve(response); } }; realFetch(url, options).then(function(response) { processAfter(response); })['catch'](function(err) { reject(err); }); }); } } 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.length > 1 ? matches[1] : null; } const matches = encodingsM3u8.match(/SERVER-TIME="([0-9.]+)"/); return matches && matches.length > 1 ? 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(/(SERVER-TIME=")[0-9.]+"/, `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; } function hasAdTags(textStr) { return AdSignifiers.some((s) => textStr.includes(s)); } function getMatchedAdSignifiers(textStr) { return AdSignifiers.filter((s) => textStr.includes(s)); } // Remove ad segments from an m3u8 playlist and cache their URLs for replacement function stripAdSegments(textStr, stripAllSegments, streamInfo) { let hasStrippedAdSegments = false; let inCueOut = false; const liveSegments = []; const lines = textStr.split(/\r?\n/); const newAdUrl = 'https://twitch.tv'; // Log ad tracking attribute names once per stream (helps identify new beacons) if (!streamInfo.HasLoggedAdAttributes) { const adAttrs = textStr.match(/X-TV-TWITCH-AD[A-Z-]*(?==")/g); if (adAttrs && adAttrs.length > 0) { streamInfo.HasLoggedAdAttributes = true; console.log('[AD DEBUG] Ad tracking attributes seen: ' + [...new Set(adAttrs)].join(', ')); } } // Log potential ad markers that aren't in AdSignifiers (candidates for future inclusion) if (!streamInfo.HasLoggedUnknownSignifiers) { const candidates = new Set(); let sm; const classRe = /EXT-X-DATERANGE:[^\n]*CLASS="(twitch-[^"]+)"/g; while ((sm = classRe.exec(textStr)) !== null) { candidates.add('EXT-X-DATERANGE:CLASS="' + sm[1] + '"'); } const tagRe = /(SCTE35-[A-Z-]+|EXT-X-CUE-[A-Z-]+)/g; while ((sm = tagRe.exec(textStr)) !== null) { candidates.add(sm[1]); } // Substring check (not exact): a candidate is "known" if any AdSignifier // appears within it. This handles prefix signifiers like 'twitch-stitched' // covering 'EXT-X-DATERANGE:CLASS="twitch-stitched-ad"' etc. const unknown = [...candidates].filter(c => !AdSignifiers.some(s => c.includes(s))); if (unknown.length > 0) { streamInfo.HasLoggedUnknownSignifiers = true; console.log('[AD DEBUG] Potential ad markers seen but not in AdSignifiers: ' + unknown.join(', ') + ' (candidates for future inclusion)'); } } for (let i = 0; i < lines.length; i++) { let line = lines[i]; // Track SCTE-35 CUE-OUT/CUE-IN ad boundaries if (line.includes('EXT-X-CUE-OUT')) { if (!inCueOut) { console.log('[AD DEBUG] SCTE-35 CUE-OUT — ad boundary entered'); } inCueOut = true; } else if (line.includes('EXT-X-CUE-IN')) { if (inCueOut) { console.log('[AD DEBUG] SCTE-35 CUE-IN — ad boundary exited'); } inCueOut = false; } // Remove tracking urls which appear in the overlay UI lines[i] = line.replaceAll(TwitchAdUrlRewriteRegex, `$1${newAdUrl}$2`); const isLiveSegment = line.includes(',live'); if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!isLiveSegment || stripAllSegments || AllSegmentsAreAdSegments || inCueOut)) { const segmentUrl = lines[i + 1]; if (!AdSegmentCache.has(segmentUrl)) { streamInfo.NumStrippedAdSegments++; } AdSegmentCache.set(segmentUrl, Date.now()); hasStrippedAdSegments = true; } else if (i < lines.length - 1 && line.startsWith('#EXTINF') && AdSegmentURLPatterns.some((p) => lines[i + 1].includes(p))) { console.log('[AD DEBUG] Ad segment detected via URL pattern: ' + lines[i + 1]); AdSegmentCache.set(lines[i + 1], Date.now()); hasStrippedAdSegments = true; streamInfo.NumStrippedAdSegments++; } else if (i < lines.length - 1 && line.startsWith('#EXTINF') && isLiveSegment) { liveSegments.push({ extinf: line, url: lines[i + 1] }); } else if (line.startsWith('#EXT-X-PART:')) { // LL-HLS part: URI is inline as an attribute. Strip if it matches a known // ad URL (already in cache from a parallel EXTINF strip, or matches a URL pattern). // Without this, the player may use the parts path to fetch ad media via low-latency. const partUriMatch = line.match(UriAttributeRegex); const partUri = partUriMatch ? partUriMatch[1] : ''; if (partUri && (AdSegmentCache.has(partUri) || AdSegmentURLPatterns.some((p) => partUri.includes(p)))) { AdSegmentCache.set(partUri, Date.now()); lines[i] = ''; hasStrippedAdSegments = true; } } else if (line.startsWith('#EXT-X-TWITCH-PREFETCH:') || line.startsWith('#EXT-X-PRELOAD-HINT:')) { // LL-HLS prefetch/preload hints can point at upcoming ad segments before any // EXTINF line or ad signifier has materialized in the playlist. If we only // strip prefetch hints AFTER hasStrippedAdSegments is set, the first poll of // an ad break can leak a prefetch hint pointing at an ad URL — the player // then pre-fetches ad media via the LL-HLS path before our usual strip catches // up, producing an ad flash. Detect the ad URL here so hasStrippedAdSegments // flips on the first poll and the post-loop unconditional prefetch strip fires. // Ported from TTV-AB 52b41b4. // Format: '#EXT-X-TWITCH-PREFETCH:https://url/here.ts' (raw URL after the colon) // or: '#EXT-X-PRELOAD-HINT:TYPE=PART,URI="url"' (URI attribute) let hintUrl = ''; if (line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { hintUrl = line.substring('#EXT-X-TWITCH-PREFETCH:'.length).trim(); } else { const hintMatch = line.match(/URI="([^"]+)"/); hintUrl = hintMatch ? hintMatch[1] : ''; } if (hintUrl && (AdSegmentCache.has(hintUrl) || AdSegmentURLPatterns.some((p) => hintUrl.includes(p)))) { AdSegmentCache.set(hintUrl, Date.now()); hasStrippedAdSegments = true; } } } // Moved out of the per-line loop: a per-line scan for any signifier is // semantically equivalent to a single full-text scan, since the check has // no line-level state — it just flips hasStrippedAdSegments = true on first // match. One scan instead of N_lines * N_signifiers scans (~100x fewer // includes() calls on a typical 100-line m3u8). if (!hasStrippedAdSegments && AdSignifiers.some((s) => textStr.includes(s))) { hasStrippedAdSegments = true; } if (hasStrippedAdSegments) { for (let i = 0; i < lines.length; i++) { // No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments) if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:') || lines[i].startsWith('#EXT-X-PRELOAD-HINT:')) { lines[i] = ''; } } } else { streamInfo.NumStrippedAdSegments = 0; } // Cache live segments for recovery (plus the MEDIA-SEQUENCE of the oldest cached segment, // so the player accepts injected recovery segments as the correct position in the stream) if (liveSegments.length > 0) { streamInfo.RecoverySegments = liveSegments.slice(-6); const seq = parseInt((textStr.match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/) || [])[1]); if (!isNaN(seq)) { streamInfo.RecoveryStartSeq = seq + Math.max(0, liveSegments.length - streamInfo.RecoverySegments.length); } } // If all segments were stripped, try to prevent black screen via recovery content. // Prefer the full-playlist snapshot from a recent non-ad poll (mirrors TTV-AB // LastCleanNativeM3U8 approach) — gives the player 4-6 live segments worth of // content vs the thin per-segment recovery cache. Falls back to the per-segment // cache if the snapshot is stale or missing. if (hasStrippedAdSegments && liveSegments.length === 0) { streamInfo.ConsecutiveAllStrippedPolls = (streamInfo.ConsecutiveAllStrippedPolls || 0) + 1; streamInfo.TotalAllStrippedPolls = (streamInfo.TotalAllStrippedPolls || 0) + 1; if (!streamInfo.FreezeStartedAt) streamInfo.FreezeStartedAt = Date.now(); // Primary: fresh full-playlist snapshot (< 1.5s old, must not itself contain ad markers) const snapshotAge = streamInfo.LastCleanNativePlaylistAt ? (Date.now() - streamInfo.LastCleanNativePlaylistAt) : Infinity; if (streamInfo.LastCleanNativeM3U8 && snapshotAge <= 1500 && !hasAdTags(streamInfo.LastCleanNativeM3U8)) { console.log('[AD DEBUG] All segments stripped — reusing last clean native playlist (' + snapshotAge + 'ms old)'); streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; return streamInfo.LastCleanNativeM3U8; } // Fallback: per-segment recovery cache (existing behavior) if (streamInfo.RecoverySegments && streamInfo.RecoverySegments.length > 0) { console.log('[AD DEBUG] All segments stripped — restoring ' + streamInfo.RecoverySegments.length + ' recovery segments'); if (streamInfo.RecoveryStartSeq !== undefined) { for (let j = 0; j < lines.length; j++) { if (lines[j].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { lines[j] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.RecoveryStartSeq; break; } } } for (let j = 0; j < streamInfo.RecoverySegments.length; j++) { lines.push(streamInfo.RecoverySegments[j].extinf); lines.push(streamInfo.RecoverySegments[j].url); } } } else if (liveSegments.length > 0) { // Reset freeze counter when live segments are available streamInfo.ConsecutiveAllStrippedPolls = 0; } streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; const now = Date.now(); // Throttle cache prune to once per 60s. The 120s TTL gives plenty of headroom // and scanning the full cache on every m3u8 poll adds up during heavy ad break // sequences (LL-HLS can poll multiple times per second). Ported from TTV-AB. if (!streamInfo.LastAdCachePruneAt || now - streamInfo.LastAdCachePruneAt > 60000) { streamInfo.LastAdCachePruneAt = now; AdSegmentCache.forEach((value, key, map) => { if (value < now - 120000) { map.delete(key); } }); // Bound the cache to prevent unbounded growth on long sessions. When size > 1000, // evict the oldest 200 entries (Map iteration order is insertion order, so FIFO). if (AdSegmentCache.size > 1000) { let evicted = 0; for (const url of AdSegmentCache.keys()) { AdSegmentCache.delete(url); if (++evicted >= 200) break; } if (!streamInfo.LoggedAdCacheSize1k) { streamInfo.LoggedAdCacheSize1k = true; console.log('[AD DEBUG] AdSegmentCache exceeded 1000 entries — evicted oldest ' + evicted + ' (now ' + AdSegmentCache.size + ')'); } } } return lines.join('\n'); } // Find the closest matching stream URL for a given resolution from a master m3u8 function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { const encodingsLines = encodingsM3u8.split(/\r?\n/); const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); let matchedResolutionUrl = null; let matchedFrameRate = false; let closestResolutionUrl = null; let closestResolutionDifference = Infinity; for (let i = 0; i < encodingsLines.length - 1; i++) { // Accept v2 API variant URLs which are raw CDN URLs without '.m3u8' in the path. // v1 API: next line is '...index-.m3u8?...' // v2 API: next line is a raw CDN URL like 'https://video-edge-...net/v1/.../chunked/...' // without '.m3u8'. Matching only on '.m3u8' would skip v2 variants entirely, // causing getStreamUrlForResolution to return null and backup selection to fail. const nextLine = encodingsLines[i + 1]?.trim(); if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && nextLine && !nextLine.startsWith('#') && (nextLine.includes('.m3u8') || nextLine.includes('://'))) { const attributes = parseAttributes(encodingsLines[i]); const resolution = attributes['RESOLUTION']; const frameRate = attributes['FRAME-RATE']; if (resolution) { if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) { matchedResolutionUrl = encodingsLines[i + 1]; matchedFrameRate = frameRate == resolutionInfo.FrameRate; if (matchedFrameRate) { return matchedResolutionUrl; } } const [width, height] = resolution.split('x').map(Number); const difference = Math.abs((width * height) - (targetWidth * targetHeight)); if (difference < closestResolutionDifference) { closestResolutionUrl = encodingsLines[i + 1]; closestResolutionDifference = difference; } } } } return closestResolutionUrl; } // Core ad-blocking logic: detect ads in m3u8, fetch backup streams, strip ad segments async function processM3U8(url, textStr, realFetch) { const streamInfo = StreamInfosByUrl[url]; if (!streamInfo) { return textStr; } streamInfo.LastSeenAt = Date.now(); if (HasTriggeredPlayerReload) { HasTriggeredPlayerReload = false; streamInfo.LastPlayerReload = Date.now(); } if (!streamInfo.HasCheckedUnknownTags) { streamInfo.HasCheckedUnknownTags = true; const unknownAdTags = textStr.match(/#EXT[^:\n]*(?:ad|cue|scte|sponsor)[^:\n]*/gi); if (unknownAdTags) { const unknown = unknownAdTags.filter(t => !AdSignifiers.some(s => t.includes(s))); if (unknown.length > 0) { console.log('[AD DEBUG] Unknown ad-related tags found: ' + [...new Set(unknown)].join(', ')); } } } const haveAdTags = hasAdTags(textStr) || SimulatedAdsDepth > 0; // Cache the clean main stream m3u8 for all-stripped recovery fallback. // Updated during non-ad polls (outside of any ad break), so by the time an ad // break starts, streamInfo.LastCleanNativeM3U8 holds a snapshot ~1-2 seconds old // with several live segments. When heavy SSAI breaks leave the main playlist // entirely stripped, stripAdSegments replays this snapshot instead of the thin // RecoverySegments array — typically gives the player 4-6 live segments of // content to chew on vs the 1-2 cached individual segments. // Mirrors TTV-AB src/modules/processor.ts:733-736. if (!haveAdTags && !streamInfo.IsShowingAd && textStr.indexOf('#EXTINF') !== -1) { streamInfo.LastCleanNativeM3U8 = textStr; streamInfo.LastCleanNativePlaylistAt = Date.now(); } if (haveAdTags) { // Bounce-tolerant reset: keep PendingAdEndAt alive across short flips back to ad-marked // so the slow-path max-wait gate can still fire when bouncing markers prevent // CleanPlaylistCount from reaching threshold. Mirrors TTV-AB v6.6.7 #1. const adEndStalenessMs = 12000; if (streamInfo.PendingAdEndAt && (Date.now() - streamInfo.PendingAdEndAt) < adEndStalenessMs) { streamInfo.AdEndBounceCount = (streamInfo.AdEndBounceCount || 0) + 1; } else { streamInfo.PendingAdEndAt = 0; streamInfo.AdEndBounceCount = 0; } streamInfo.CleanPlaylistCount = 0; streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); if (!streamInfo.IsShowingAd) { streamInfo.IsShowingAd = true; streamInfo.AdBreakStartedAt = Date.now(); const podLengthMatch = textStr.match(/X-TV-TWITCH-AD-POD-LENGTH="(\d+)"/); const podLength = podLengthMatch ? parseInt(podLengthMatch[1], 10) : 1; // Reset early-reload state for new ad break; allow up to one early reload per ad in pod streamInfo.PodLength = podLength; streamInfo.EarlyReloadTriggered = false; streamInfo.EarlyReloadCount = 0; streamInfo.EarlyReloadAtPoll = 0; // Track high-confidence ad markers to distinguish real ads from false-positive signifier matches streamInfo.HasConfirmedAdAttrs = textStr.includes('X-TV-TWITCH-AD-AD-SESSION-ID') || textStr.includes('X-TV-TWITCH-AD-RADS-TOKEN'); streamInfo.CycleRescuedThisBreak = false; streamInfo.LastCommittedBackupPlayerType = null; streamInfo.FreezeStartedAt = 0; streamInfo.CsaiOnlyThisBreak = false;// Reset sticky CSAI flag for new break console.log('[AD DEBUG] Ad detected — type: ' + (streamInfo.IsMidroll ? 'midroll' : 'preroll') + ', channel: ' + streamInfo.ChannelName + ', pod: ' + podLength + ' ad(s) (~' + (podLength * 30) + 's expected), signifiers: ' + getMatchedAdSignifiers(textStr).join(', ')); postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: false }); } if (!streamInfo.IsMidroll) { const lines = textStr.split(/\r?\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])) { // Only request one .ts file per .m3u8 request to avoid making too many requests //console.log('Fetch ad .ts file'); streamInfo.RequestedAds.add(lines[i + 1]); fetch(lines[i + 1]).then((response) => response.blob()).catch(() => {}); break; } } } } const currentResolution = streamInfo.Urls[url]; if (!currentResolution) { console.log('Ads will leak due to missing resolution info for ' + url); return stripAdSegments(textStr, false, streamInfo); } const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc'); // Post-ad reload-loop guard: skip the HEVC reload if a player reload already // fired within the last 8s. End-of-break clears IsUsingModifiedM3U8; without // this guard, post-ad continuation markers would re-fire the reload. const postAdReentryGuardMs = 8000; const recentlyReloaded = streamInfo.LastPlayerReload && (Date.now() - streamInfo.LastPlayerReload) < postAdReentryGuardMs; if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8 && !recentlyReloaded) { streamInfo.IsUsingModifiedM3U8 = true; streamInfo.LastPlayerReload = Date.now(); postMessage({ key: 'ReloadPlayer' }); } // Sticky CSAI fast path: if a prior poll in THIS break already confirmed the break // is CSAI-only (all segments live on poll 1), stay on the fast path for the rest // of the break. stripAdSegments still handles any real EXTINF ad segments that // show up on later polls (they get cached and the fetch hook returns BLANK_MP4), // so ads are blocked even without the backup switch. Skipping backup search for // the whole CSAI break saves ~20 wasted fetches per break — the backup wouldn't // help anyway since every player type has the same CSAI ads. Flag is cleared // only at break end (IsShowingAd=false path). // Sticky CSAI escape hatch (PreferLowQualityBackup): after ~8s stuck, fall through to backup search. if (PreferLowQualityBackup && streamInfo.CsaiOnlyThisBreak && (streamInfo.ConsecutiveAllStrippedPolls || 0) >= 4) { const stuckPolls = streamInfo.ConsecutiveAllStrippedPolls; const recoveryCacheSize = streamInfo.RecoverySegments?.length || 0; const earlyReloadInfo = (streamInfo.EarlyReloadCount || 0) + '/' + Math.max(1, streamInfo.PodLength || 1); console.log('[AD DEBUG] Sticky CSAI escape hatch — stuck ' + stuckPolls + ' polls (~' + (stuckPolls * 2) + 's), EarlyReloadCount=' + earlyReloadInfo + ', recovery cache=' + recoveryCacheSize + ' segments, falling through to backup search'); streamInfo.CsaiOnlyThisBreak = false; streamInfo.EscapeHatchFired = true; } if (streamInfo.CsaiOnlyThisBreak && !streamInfo.IsUsingModifiedM3U8) { if (IsAdStrippingEnabled) { textStr = stripAdSegments(textStr, false, streamInfo); } // Early reload during prolonged freeze — mirrors the check in the normal // backup-search path which we'd otherwise skip entirely by returning early // from the sticky path. Without this, heavy SSAI breaks on CSAI-confirmed // streams leave the player replaying the thin recovery cache for the full // break duration (observed: 35.9s freeze on pod-1 break, 3 all-stripped // polls, 1-segment recovery cache). // Bounded to maxEarlyReloads per ad in pod so reload loops are impossible. if (streamInfo.EarlyReloadAwaitingResult) { streamInfo.EarlyReloadAwaitingResult = false; console.log('[AD DEBUG] Early reload result (sticky path): still ads — continuing recovery loop'); streamInfo.EarlyReloadTriggered = false; } const stickyRecoveryThin = (streamInfo.RecoverySegments?.length || 0) < 3; const stickyMaxEarlyReloads = stickyRecoveryThin ? Math.max(2, streamInfo.PodLength || 1) : Math.max(1, streamInfo.PodLength || 1); const stickyEffectiveThreshold = stickyRecoveryThin ? 1 : EarlyReloadPollThreshold; if (EarlyReloadPollThreshold > 0 && (streamInfo.ConsecutiveAllStrippedPolls || 0) >= stickyEffectiveThreshold && !streamInfo.EarlyReloadTriggered && (streamInfo.EarlyReloadCount || 0) < stickyMaxEarlyReloads) { streamInfo.EarlyReloadTriggered = true; streamInfo.EarlyReloadAwaitingResult = true; streamInfo.EarlyReloadCount = (streamInfo.EarlyReloadCount || 0) + 1; streamInfo.EarlyReloadAtPoll = streamInfo.TotalAllStrippedPolls || streamInfo.ConsecutiveAllStrippedPolls; const stickyReason = stickyRecoveryThin ? ' (thin recovery cache: ' + (streamInfo.RecoverySegments?.length || 0) + ' segments)' : ''; console.log('[AD DEBUG] Early reload triggered (sticky path) — ' + streamInfo.ConsecutiveAllStrippedPolls + ' consecutive all-stripped polls' + stickyReason + ' [' + streamInfo.EarlyReloadCount + '/' + stickyMaxEarlyReloads + ']'); postMessage({ key: 'ReloadPlayer', kind: 'early' }); } postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: streamInfo.IsStrippingAdSegments, numStrippedAdSegments: streamInfo.NumStrippedAdSegments, activeBackupPlayerType: null }); return textStr; } // CSAI fast path: if all segments in the main stream are live, skip backup search. // CSAI ads are delivered outside the m3u8 — the main stream segments are clean. // Just strip tracking URLs and return the main stream directly, avoiding the // backup stream switch that causes a 20-40s rebuffer gap. const mainStreamLines = textStr.split(/\r?\n/); let hasNonLiveSegment = false; for (let i = 0; i < mainStreamLines.length; i++) { if (mainStreamLines[i].startsWith('#EXTINF') && !mainStreamLines[i].includes(',live')) { hasNonLiveSegment = true; break; } } // BackupSwapFirst (opt-in): skip sticky CSAI path entirely, always fall through to // backup search on ad detect. Mimics TTV-AB's backup-swap-first flow — avoids // MediaSource mixing from strip activity (no BLANK_MP4 injection, no recovery // segment replay), which users report produces fewer loading circles. Cost: extra // fetches on every ad break (token requests for each backup type tried). if (!hasNonLiveSegment && !streamInfo.IsUsingModifiedM3U8 && !BackupSwapFirst) { streamInfo.CsaiOnlyThisBreak = true;// Mark break as confirmed CSAI so subsequent polls stay on the fast path console.log('[AD DEBUG] CSAI fast path — all segments live, skipping backup search'); if (IsAdStrippingEnabled) { textStr = stripAdSegments(textStr, false, streamInfo); } postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: streamInfo.IsStrippingAdSegments, numStrippedAdSegments: streamInfo.NumStrippedAdSegments, activeBackupPlayerType: null }); return textStr; } const backupSearchStart = Date.now(); let backupPlayerType = null; let backupM3u8 = null; let fallbackM3u8 = null; let startIndex = 0; let isDoingMinimalRequests = false; if (streamInfo.LastPlayerReload > Date.now() - PlayerReloadMinimalRequestsTime) { // When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using a single version to prevent long delays startIndex = PlayerReloadMinimalRequestsPlayerIndex; isDoingMinimalRequests = true; } // Try pinned backup player type first if available const playerTypesToTry = PreferLowQualityBackup ? [...BackupPlayerTypes, 'autoplay'] : [...BackupPlayerTypes]; if (streamInfo.PinnedBackupPlayerType) { const pinnedIndex = playerTypesToTry.indexOf(streamInfo.PinnedBackupPlayerType); if (pinnedIndex > 0) { playerTypesToTry.splice(pinnedIndex, 1); playerTypesToTry.unshift(streamInfo.PinnedBackupPlayerType); } } // FastAutoplayFirstTry: prepend autoplay when prior break exhausted Source-tier // (SSAI-uniform signal). Saves ~1.5s of probe buffering. if (FastAutoplayFirstTry && streamInfo.LastBreakUsedEscapeHatch && PreferLowQualityBackup) { const autoplayIdx = playerTypesToTry.indexOf('autoplay'); if (autoplayIdx > 0) { playerTypesToTry.splice(autoplayIdx, 1); playerTypesToTry.unshift('autoplay'); if (!streamInfo.LoggedFastAutoplayThisBreak) { streamInfo.LoggedFastAutoplayThisBreak = true; console.log('[AD DEBUG] Fast-autoplay first-try — prior break exhausted Source-tier; probing autoplay first'); } } } // Real-time contamination reorder: on poll 2+ of a break, move types that were // already logged as ad-laden earlier in the same break to the end of iteration. // Lets untried/clean types (typically autoplay on SSAI-heavy channels like warn) // get tried first instead of re-checking types we already know are contaminated. // LoggedBackupAdsByType is populated below at the "also has ads" log site and // cleared at end-of-break, so this is per-break adaptive. if (streamInfo.LoggedBackupAdsByType && streamInfo.LoggedBackupAdsByType.size > 0) { const clean = []; const contam = []; for (const t of playerTypesToTry) { if (streamInfo.LoggedBackupAdsByType.has(t)) contam.push(t); else clean.push(t); } if (contam.length > 0 && clean.length > 0) { playerTypesToTry.length = 0; playerTypesToTry.push(...clean, ...contam); if (!streamInfo.LoggedContamReorderThisBreak) { streamInfo.LoggedContamReorderThisBreak = true; console.log('[AD DEBUG] Contamination-aware reorder — trying [' + clean.join(', ') + '] before known-contaminated [' + contam.join(', ') + ']'); } } } for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < playerTypesToTry.length; playerTypeIndex++) { const playerType = playerTypesToTry[playerTypeIndex]; const realPlayerType = playerType.replace('-CACHED', ''); const failedAt = streamInfo.FailedBackupPlayerTypes.get(realPlayerType); if (failedAt && (Date.now() - failedAt) < 15000) { continue; } const isFullyCachedPlayerType = playerType != realPlayerType; for (let i = 0; i < 2; i++) { // This caches the m3u8 if it doesn't have ads. If the already existing cache has ads it fetches a new version (second loop) 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(); // Twitch returns streamPlaybackAccessToken in two observed shapes: // { data: { streamPlaybackAccessToken: {...} } } (most player types) // { streamPlaybackAccessToken: {...} } (flatter, observed for 'embed') // Accept either. Field-observed silently dropping embed backup otherwise. const spat = accessToken?.data?.streamPlaybackAccessToken || accessToken?.streamPlaybackAccessToken; if (!spat) { const errInfo = accessToken?.errors ? ' errors: ' + JSON.stringify(accessToken.errors).substring(0, 300) : ''; console.log('[AD DEBUG] GQL response missing streamPlaybackAccessToken for ' + realPlayerType + '. Response keys: ' + JSON.stringify(Object.keys(accessToken || {})) + errInfo); // Lockout for 15s so we don't re-hit this known-broken type every poll. // Parity with the HTTP-error and exception paths above — this GQL-error path // was the only failure mode that didn't mark FailedBackupPlayerTypes, causing // ~34 identical retries per break on channels where embed/autoplay GQL // consistently returns "server error" (field-observed on warn). streamInfo.FailedBackupPlayerTypes.set(realPlayerType, Date.now()); continue; } const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); urlInfo.searchParams.set('sig', spat.signature); urlInfo.searchParams.set('token', spat.value); const encodingsM3u8Response = await realFetch(urlInfo.href); if (encodingsM3u8Response.status === 200) { encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); } else { console.log('[AD DEBUG] Usher HTTP ' + encodingsM3u8Response.status + ' for ' + realPlayerType); } } else { let errorBody = ''; try { errorBody = ' — ' + (await accessTokenResponse.text()).substring(0, 200); } catch {} console.log('[AD DEBUG] Access token HTTP ' + accessTokenResponse.status + ' for ' + realPlayerType + (accessTokenResponse.status === 403 ? ' (integrity: ' + (ClientIntegrityHeader ? 'present' : 'missing') + ')' : '') + errorBody); streamInfo.FailedBackupPlayerTypes.set(realPlayerType, Date.now()); } } catch (err) { console.log('[AD DEBUG] Access token failed for ' + realPlayerType + ': ' + err.message); streamInfo.FailedBackupPlayerTypes.set(realPlayerType, Date.now()); } } if (encodingsM3u8) { try { const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution); const streamM3u8Response = await realFetch(streamM3u8Url); if (streamM3u8Response.status == 200) { const m3u8Text = await streamM3u8Response.text(); if (m3u8Text) { if (playerType == FallbackPlayerType) { fallbackM3u8 = m3u8Text; } if ((!hasAdTags(m3u8Text) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) || (!fallbackM3u8 && playerTypeIndex >= playerTypesToTry.length - 1)) { if ((streamInfo.ConsecutiveAllStrippedPolls || 0) >= 1 && !hasAdTags(m3u8Text)) { const prevType = streamInfo.LastCommittedBackupPlayerType; if (prevType && prevType !== playerType) { console.log('[AD DEBUG] Cycle switched to different clean type (' + playerType + ', was ' + prevType + ') during freeze — recovered without reload'); // Only mark as cycle-rescued when we ACTUALLY switched player types. // Natural recovery (same type became clean) still needs the end-of-break // reload to refresh the player buffer — skipping it leaves the player // stuck with low buffer and the buffer monitor unable to recover. streamInfo.CycleRescuedThisBreak = true; } else { console.log('[AD DEBUG] Same backup type (' + playerType + ') became clean during freeze — natural recovery'); } } backupPlayerType = playerType; backupM3u8 = m3u8Text; break; } if (hasAdTags(m3u8Text)) { if (!streamInfo.LoggedBackupAdsByType) streamInfo.LoggedBackupAdsByType = new Set(); if (!streamInfo.LoggedBackupAdsByType.has(playerType)) { streamInfo.LoggedBackupAdsByType.add(playerType); console.log('[AD DEBUG] Backup stream (' + playerType + ') also has ads'); } } if (isFullyCachedPlayerType || isDoingMinimalRequests) { backupPlayerType = playerType; backupM3u8 = m3u8Text; break; } // Cycle through all player types looking for a clean backup. Only commit // an ad-laden backup as a last resort when we've exhausted all options. // PR #89 previously committed the first ad-laden type immediately — that // caused the v58 freeze regression (issue #112) because the strip+recovery // loop would engage even when a clean alternate was available on another // player type. if (hasAdTags(m3u8Text) && playerTypeIndex >= playerTypesToTry.length - 1) { console.log('[AD DEBUG] All backup player types ad-laden — taking ' + playerType + ' as last-resort fallback (strip+recovery path will engage)'); backupPlayerType = playerType; backupM3u8 = m3u8Text; break; } } } else { console.log('[AD DEBUG] Backup stream fetch failed for ' + playerType + ' (status ' + streamM3u8Response.status + ')'); } } catch (err) { console.log('[AD DEBUG] Backup stream error for ' + playerType + ': ' + err.message); } } streamInfo.BackupEncodingsM3U8Cache[playerType] = null; if (isFreshM3u8) { break; } } } if (!backupM3u8 && fallbackM3u8) { // Don't fall back to a type we've already marked contaminated this break. // Without this guard, when all Source types go ad-laden mid-break the iteration // ends with no clean commit AND fallbackM3u8 still pointing at FallbackPlayerType's // ad-laden m3u8 — we'd silently re-commit the same contaminated site on every poll // (no "Blocking ads" log because ActiveBackupPlayerType unchanged), and the user // sees the full ad pod with no indication of failure. Better to leave backupM3u8 // null and let the "No ad-free backup stream found" log fire so it's visible. if (streamInfo.LoggedBackupAdsByType && streamInfo.LoggedBackupAdsByType.has(FallbackPlayerType)) { console.log('[AD DEBUG] Skipping fallback to ' + FallbackPlayerType + ' — marked contaminated this break (' + [...streamInfo.LoggedBackupAdsByType].join(', ') + ' all ad-laden)'); } else { backupPlayerType = FallbackPlayerType; backupM3u8 = fallbackM3u8; } } // Stale-commit guard: multiple processM3U8 calls can be in flight concurrently for // the same streamInfo (one per m3u8 poll). If this backup search started during the // ad break but completed AFTER a later poll already ran the end-of-break reset // (IsShowingAd = false, ActiveBackupPlayerType = null), committing the backup here // would overwrite the cleared state and feed stale playlist data to the player, // causing buffer reconciliation failures and a forced reload. Check IsShowingAd // here to discard stale results. if (backupM3u8 && streamInfo.IsShowingAd) { textStr = backupM3u8; streamInfo.LastCommittedBackupPlayerType = backupPlayerType; if (streamInfo.ActiveBackupPlayerType != backupPlayerType) { streamInfo.ActiveBackupPlayerType = backupPlayerType; const sourceQualityTypes = ['embed', 'site', 'popout']; // Never pin 'autoplay' — must stay at the last position in playerTypesToTry // so the iteration-end last-resort branch commits it when all Source types // are ad-laden (intended 360p clean fallback). Pinning would move it to // position 0 and a different ad-laden Source type would be committed instead. if ((PinBackupPlayerType && backupPlayerType !== 'autoplay') || sourceQualityTypes.includes(backupPlayerType)) { streamInfo.PinnedBackupPlayerType = backupPlayerType; } console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType}) — backup found in ${Date.now() - backupSearchStart}ms`); if (streamInfo.EscapeHatchFired) { const qualityTier = backupPlayerType === 'autoplay' ? '360p' : 'Source'; console.log('[AD DEBUG] Post-escape backup: ' + backupPlayerType + ' (' + qualityTier + ') — recovered from sticky-path freeze'); } else if (backupPlayerType === 'autoplay' && PreferLowQualityBackup) { const sourceTried = streamInfo.LoggedBackupAdsByType?.size || 0; if (sourceTried === 0) { console.log('[AD DEBUG] Autoplay backup committed — 360p pinned from prior break (PreferLowQualityBackup)'); } else { console.log('[AD DEBUG] Autoplay backup committed — 360p fallback after ' + sourceTried + ' Source type(s) ad-laden (PreferLowQualityBackup)'); } // FastAutoplayFirstTry signal: only flag SSAI-uniform when 4 Source types // were probed and all contaminated. Pinned-from-prior-break (sourceTried==0) // doesn't add new info. if (FastAutoplayFirstTry && sourceTried >= 4) { streamInfo.LastBreakUsedEscapeHatch = true; } } else if (FastAutoplayFirstTry && backupPlayerType !== 'autoplay') { // Source-tier won — channel recovered, reset signal. streamInfo.LastBreakUsedEscapeHatch = false; } } } else if (backupM3u8 && !streamInfo.IsShowingAd) { console.log('[AD DEBUG] Discarded stale backup commit (' + backupPlayerType + ', ' + (Date.now() - backupSearchStart) + 'ms) — break ended during search'); } else { console.log('[AD DEBUG] No ad-free backup stream found — ads may leak. Tried: ' + playerTypesToTry.slice(startIndex).join(', ')); } // TODO: Improve hevc stripping. It should always strip when there is a codec mismatch (both ways) const stripHevc = isHevc && streamInfo.ModifiedM3U8; if (IsAdStrippingEnabled || stripHevc) { textStr = stripAdSegments(textStr, stripHevc, streamInfo); } else if (!backupM3u8) { console.log('[AD DEBUG] Ad stripping disabled and no backup — ads WILL show'); } // Log reload outcome on the poll after early reload triggered if (streamInfo.EarlyReloadAwaitingResult) { streamInfo.EarlyReloadAwaitingResult = false; if (textStr.includes(',live') && streamInfo.IsStrippingAdSegments) { console.log('[AD DEBUG] Early reload result: partial — some live segments returned'); } else if (!streamInfo.IsStrippingAdSegments) { console.log('[AD DEBUG] Early reload result: clean — freeze ended'); // Reset trigger flag so subsequent freezes within the same pod can re-fire (bounded by EarlyReloadCount/PodLength) streamInfo.EarlyReloadTriggered = false; } else { console.log('[AD DEBUG] Early reload result: still ads — continuing recovery loop'); streamInfo.EarlyReloadTriggered = false; } } // Early reload during prolonged freeze: if we've been looping recovery segments // for N+ polls (~Nx2s), trigger a reload to attempt fresh content. Bounded to one // reload per ad in the pod (e.g. 2-ad pod = up to 2 early reloads). const recoveryThin = (streamInfo.RecoverySegments?.length || 0) < 3; const maxEarlyReloads = recoveryThin ? Math.max(2, streamInfo.PodLength || 1) : Math.max(1, streamInfo.PodLength || 1); const effectiveThreshold = recoveryThin ? 1 : EarlyReloadPollThreshold; if (EarlyReloadPollThreshold > 0 && (streamInfo.ConsecutiveAllStrippedPolls || 0) >= effectiveThreshold && !streamInfo.EarlyReloadTriggered && (streamInfo.EarlyReloadCount || 0) < maxEarlyReloads) { streamInfo.EarlyReloadTriggered = true; streamInfo.EarlyReloadAwaitingResult = true; streamInfo.EarlyReloadCount = (streamInfo.EarlyReloadCount || 0) + 1; streamInfo.EarlyReloadAtPoll = streamInfo.TotalAllStrippedPolls || streamInfo.ConsecutiveAllStrippedPolls; const reason = recoveryThin ? ' (thin recovery cache: ' + (streamInfo.RecoverySegments?.length || 0) + ' segments)' : ''; console.log('[AD DEBUG] Early reload triggered — ' + streamInfo.ConsecutiveAllStrippedPolls + ' consecutive all-stripped polls' + reason + ' [' + streamInfo.EarlyReloadCount + '/' + maxEarlyReloads + ']'); postMessage({ key: 'ReloadPlayer', kind: 'early' }); } } else if (streamInfo.IsShowingAd) { // Mark first candidate-end timestamp on the first clean poll seen this break, // so the slow-path max-wait gate below can fire even if subsequent polls bounce // back to ad-marked. The bounce-tolerant haveAdTags reset keeps this alive // across short flips. Mirrors TTV-AB v6.6.7 #1. if (!streamInfo.PendingAdEndAt) { streamInfo.PendingAdEndAt = Date.now(); } streamInfo.CleanPlaylistCount++; // Check if the current playlist has live segments — if not, backup stream is dead const hasLiveSegments = textStr.includes(',live'); // Independent slow-path max-wait escalation — ends the visible ad cycle even when // marker bouncing keeps CleanPlaylistCount below threshold. Without this, the player // could be wedged on backup indefinitely on channels where Twitch flips markers // in/out faster than 3 consecutive clean polls can land. Mirrors TTV-AB v6.6.7 #4 // ("Decoupled Slow-Path Recovery from Clean-Count"). const adEndMaxWaitMs = 12000; const elapsedSinceCandidate = Date.now() - streamInfo.PendingAdEndAt; const slowPathReady = streamInfo.PendingAdEndAt > 0 && elapsedSinceCandidate >= adEndMaxWaitMs; // Require 3 consecutive clean polls before declaring ad-end. Previously only 1 // when NumStrippedAdSegments === 0 (CSAI-only / backup-swap path) and 2 otherwise, // which let brief clean windows during ongoing breaks flip IsShowingAd false // prematurely on SSAI-uniform channels. TTV-AB hit the same false-positive at 2 // probes and bumped to 3 in v6.6.7 ("Ad-End Re-Entry Stability") — Twitch can // serve a clean playlist mid-break before re-injecting markers, and 2 polls // (~4s) wasn't always enough to ride out the bounce. if (streamInfo.CleanPlaylistCount >= 3 || !hasLiveSegments || slowPathReady) { if (slowPathReady && streamInfo.CleanPlaylistCount < 3) { console.log('[AD DEBUG] Slow-path ad-end escalation — ' + (streamInfo.AdEndBounceCount || 0) + ' marker bounces, ' + (elapsedSinceCandidate / 1000).toFixed(1) + 's since first clean poll'); } if (!hasLiveSegments) { console.log('[AD DEBUG] Backup stream has no live segments — forcing immediate reload'); } const adBreakDurationSec = streamInfo.AdBreakStartedAt ? ((Date.now() - streamInfo.AdBreakStartedAt) / 1000).toFixed(1) : '?'; console.log('Finished blocking ads — stripped ' + streamInfo.NumStrippedAdSegments + ' ad segments, duration: ' + adBreakDurationSec + 's'); if (streamInfo.TotalAllStrippedPolls > 0) { const reloadInfo = streamInfo.EarlyReloadAtPoll ? ', early reload at poll ' + streamInfo.EarlyReloadAtPoll : ''; const wallClockFreeze = streamInfo.FreezeStartedAt ? ((Date.now() - streamInfo.FreezeStartedAt) / 1000).toFixed(1) + 's wall-clock' : 'unknown'; console.log('[AD DEBUG] Ad break stats: ' + streamInfo.TotalAllStrippedPolls + ' all-stripped polls, freeze duration: ' + wallClockFreeze + reloadInfo); } const hadStrippedSegments = streamInfo.NumStrippedAdSegments > 0; // Only count toward false-positive guard if the m3u8 lacked high-confidence ad markers. // Confirmed ads (with X-TV-TWITCH-AD-AD-SESSION-ID etc.) that produce 0 strips are real ads // we successfully avoided via clean backup — not false positives. if (!hadStrippedSegments && !streamInfo.HasConfirmedAdAttrs) { streamInfo.ConsecutiveZeroStripBreaks++; if (streamInfo.ConsecutiveZeroStripBreaks >= 3) { console.log('[AD DEBUG] Warning: ' + streamInfo.ConsecutiveZeroStripBreaks + ' consecutive unconfirmed ad breaks with 0 segments stripped — possible false positive from ad signifiers'); } } else if (hadStrippedSegments || streamInfo.HasConfirmedAdAttrs) { // Reset is symmetric with the increment guard above — any positive "break // was handled cleanly" signal resets the false-positive history. Previously // only stripped>0 reset the counter, which let stale suspicious history // bleed across legitimately-handled backup-swap breaks (0 stripped + real // ad attrs) and trigger the warning on partially-stale state. streamInfo.ConsecutiveZeroStripBreaks = 0; } streamInfo.IsShowingAd = false; streamInfo.IsStrippingAdSegments = false; streamInfo.NumStrippedAdSegments = 0; streamInfo.ActiveBackupPlayerType = null; streamInfo.RequestedAds?.clear?.(); streamInfo.FailedBackupPlayerTypes?.clear?.(); if (streamInfo.LoggedBackupAdsByType) streamInfo.LoggedBackupAdsByType.clear(); streamInfo.LoggedContamReorderThisBreak = false; streamInfo.CleanPlaylistCount = 0; streamInfo.PendingAdEndAt = 0; streamInfo.AdEndBounceCount = 0; streamInfo.ConsecutiveAllStrippedPolls = 0; streamInfo.EarlyReloadTriggered = false; streamInfo.EarlyReloadAwaitingResult = false; streamInfo.EarlyReloadAtPoll = 0; streamInfo.TotalAllStrippedPolls = 0; streamInfo.CsaiOnlyThisBreak = false; streamInfo.EscapeHatchFired = false; streamInfo.HasLoggedAdAttributes = false; streamInfo.HasLoggedUnknownSignifiers = false; streamInfo.LoggedFastAutoplayThisBreak = false; // CSAI-only ad break: no segments were stripped — skip reload entirely. if (!hadStrippedSegments) { console.log('[AD DEBUG] CSAI-only ad break (stripped 0) — clearing backup without player action'); streamInfo.IsUsingModifiedM3U8 = false; // Exception: if ANY backup was committed during this break (escape hatch // or cycle rescue that didn't meet cycleRescuedCleanly criteria), the // MediaSource buffer has accumulated mixed-source segments (backup-fetched // via alternate player-type access token + native-fetched). Mixing can // cause audio/video track timestamps to diverge, and without a reload the // drift compounds across subsequent escape-hatch breaks. Force a hard reload // to flush the MediaSource buffer + refresh the access token. // For autoplay (360p) specifically, the reload also restores Source // quality (autoplay-scoped token only serves 360p variant ladder). if (streamInfo.LastCommittedBackupPlayerType) { const isAutoplay = streamInfo.LastCommittedBackupPlayerType === 'autoplay'; const reason = isAutoplay ? 'autoplay (360p) — restoring Source quality' : streamInfo.LastCommittedBackupPlayerType + ' — flushing MediaSource to prevent A/V desync accumulation'; console.log('[AD DEBUG] Post-escape reload: ' + reason); streamInfo.LastPlayerReload = Date.now(); if (!streamInfo.ReloadTimestamps) streamInfo.ReloadTimestamps = []; streamInfo.ReloadTimestamps.push(Date.now()); postMessage({ key: 'ReloadPlayer', kind: 'early' }); } } else { // Auto-escalate cooldown: if 3+ reloads in last 5 min, triple the cooldown if (!streamInfo.ReloadTimestamps) streamInfo.ReloadTimestamps = []; streamInfo.ReloadTimestamps = streamInfo.ReloadTimestamps.filter(t => Date.now() - t < 300000); const recentReloads = streamInfo.ReloadTimestamps.filter(t => Date.now() - t < 300000).length; const effectiveCooldown = recentReloads >= 3 ? ReloadCooldownSeconds * 3 : ReloadCooldownSeconds; const tooSoonSinceLastReload = streamInfo.LastPlayerReload && (Date.now() - streamInfo.LastPlayerReload) < (effectiveCooldown * 1000); // Skip end-of-break reload when cycle rescue handled the break cleanly: // a freeze of ≤2 polls (~4s) was resolved by switching to a clean backup, // and no early reload was needed. The player is on a healthy backup stream // — reloading just to return to the canonical player type causes an unnecessary // ~1-2s loading circle. const cycleRescuedCleanly = streamInfo.CycleRescuedThisBreak && (streamInfo.TotalAllStrippedPolls || 0) <= 2 && (streamInfo.EarlyReloadCount || 0) === 0; if (cycleRescuedCleanly) { console.log('[AD DEBUG] Cycle rescue handled the break cleanly — skipping end-of-break reload'); } // Post-ad reload bypasses cooldown: it's a buffer flush tied to natural break // end, not a cascade-risk retry. The ad break cycle itself rate-limits this // path (once per break). Cooldown still gates buffer-monitor and other // cascade-risk paths that can fire repeatedly in-break. const shouldReload = streamInfo.IsUsingModifiedM3U8 || (ReloadPlayerAfterAd && hadStrippedSegments && !cycleRescuedCleanly); if (shouldReload) { streamInfo.ReloadTimestamps.push(Date.now()); streamInfo.IsUsingModifiedM3U8 = false; streamInfo.LastPlayerReload = Date.now(); postMessage({ key: 'ReloadPlayer', kind: 'early' }); } else { if (tooSoonSinceLastReload) { console.log('[AD DEBUG] Skipping reload — last reload was ' + ((Date.now() - streamInfo.LastPlayerReload) / 1000).toFixed(0) + 's ago (cooldown: ' + effectiveCooldown + 's' + (recentReloads >= 3 ? ', auto-escalated from ' + recentReloads + ' reloads in 5min' : '') + ')'); } postMessage({ key: 'PauseResumePlayer' }); } }// end else (non-CSAI path) } } postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: streamInfo.IsStrippingAdSegments, numStrippedAdSegments: streamInfo.NumStrippedAdSegments, activeBackupPlayerType: streamInfo.ActiveBackupPlayerType }); return textStr; } function parseAttributes(str) { if (!str) return {}; // Normalize: always pass only attribute section if (str.charCodeAt(0) === 35) { // '#' const idx = str.indexOf(':'); if (idx !== -1) str = str.slice(idx + 1); } 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]; })); } // Request a playback access token from Twitch GQL using the given player type 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); } // Send a GQL request to Twitch via the main thread (workers can't make credentialed requests) 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(() => { if (pendingFetchRequests.has(requestId)) { pendingFetchRequests.delete(requestId); reject(new Error('FetchRequest timed out')); } }, 15000); pendingFetchRequests.set(requestId, { resolve, reject, timeoutId }); postMessage({ key: 'FetchRequest', value: fetchRequest }); }); } let playerForMonitoringBuffering = null; let driftCatchUpInterval = null; let driftCatchUpTimeout = null; function startDriftCorrection(videoElement) { if (DriftCorrectionRate <= 1) return; if (driftCatchUpInterval) { clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; } if (driftCatchUpTimeout) { clearTimeout(driftCatchUpTimeout); driftCatchUpTimeout = null; } videoElement.playbackRate = DriftCorrectionRate; console.log('[AD DEBUG] Drift correction: catching up at ' + DriftCorrectionRate + 'x'); driftCatchUpInterval = setInterval(() => { try { const vid = document.querySelector('video'); if (vid && vid.buffered.length > 0) { if (vid.buffered.end(vid.buffered.length - 1) - vid.currentTime <= 1) { vid.playbackRate = 1.0; console.log('[AD DEBUG] Drift correction complete — resumed normal playback speed'); clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; if (driftCatchUpTimeout) { clearTimeout(driftCatchUpTimeout); driftCatchUpTimeout = null; } } } } catch { clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; } }, 500); driftCatchUpTimeout = setTimeout(() => { try { videoElement.playbackRate = 1.0; } catch {} if (driftCatchUpInterval) { clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; } driftCatchUpTimeout = null; }, 30000); } const playerBufferState = { channelName: null, hasStreamStarted: false, position: 0, bufferedPosition: 0, bufferDuration: 0, numSame: 0, fixAttempts: 0, lastFixTime: 0, isLive: true, lastBackupSwitchAt: 0, lastReloadAt: 0, recoveryReloadUsed: false, userPauseIntent: false, loggedPauseIntent: false, weJustPaused: 0, inAdBreak: false }; // Poll the player state to detect and fix buffering caused by ad stream switching function monitorPlayerBuffering() { // Fresh player lookup every tick (avoids stale ref when Twitch restarts its own player) playerForMonitoringBuffering = null; { const playerAndState = getPlayerAndState(); if (playerAndState && playerAndState.player && playerAndState.state) { playerForMonitoringBuffering = { player: playerAndState.player, state: playerAndState.state }; const video = playerAndState.player.getHTMLVideoElement?.(); if (video && !video.__tasIntentHooked) { video.__tasIntentHooked = true; video.addEventListener('pause', () => { if (!playerBufferState.weJustPaused || (Date.now() - playerBufferState.weJustPaused) > 2000) { playerBufferState.userPauseIntent = true; } }); video.addEventListener('play', () => { playerBufferState.userPauseIntent = false; playerBufferState.loggedPauseIntent = false; }); } } } 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 && (player.getHTMLVideoElement()?.readyState ?? 0) >= 1 && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds && !playerBufferState.inAdBreak && (!playerBufferState.lastReloadAt || Date.now() - playerBufferState.lastReloadAt >= 15000) && (!playerBufferState.lastBackupSwitchAt || Date.now() - playerBufferState.lastBackupSwitchAt >= 10000)) { const m3u8Url = player.core?.state?.path; if (m3u8Url) { const lastSlash = m3u8Url.lastIndexOf('/'); const queryStart = m3u8Url.indexOf('?', lastSlash); const fileName = m3u8Url.substring(lastSlash + 1, queryStart !== -1 ? queryStart : undefined); if (fileName?.endsWith('.m3u8')) { const channelName = fileName.slice(0, -5); if (playerBufferState.channelName != channelName) { playerBufferState.channelName = channelName; playerBufferState.hasStreamStarted = false; playerBufferState.numSame = 0; playerBufferState.fixAttempts = 0; playerBufferState.recoveryReloadUsed = false; playerBufferState.userPauseIntent = false; playerBufferState.loggedPauseIntent = false; //console.log('Channel changed to ' + channelName); } } } if (player.getState() === 'Playing') { playerBufferState.hasStreamStarted = true; } const position = player.core?.state?.position; const bufferedPosition = player.core?.state?.bufferedPosition; const bufferDuration = player.getBufferDuration(); // video.currentTime is the source of truth for actual playback progress — // state.position updates in batches on reload-heavy channels and can // appear frozen for ~12s while the video element advances smoothly. const videoEl = player.getHTMLVideoElement?.(); const videoCurrentTime = videoEl?.currentTime; if (position !== undefined && bufferedPosition !== undefined) { //console.log('position:' + position + ' bufferDuration:' + bufferDuration + ' bufferPosition:' + bufferedPosition + ' state: ' + player.core?.state?.state + ' started: ' + playerBufferState.hasStreamStarted); // NOTE: This could be improved. It currently lets the player fully eat the full buffer before it triggers pause/play // Skip the buffer-stall check while the