From 9ce9071dbc93528cd43cf1b178b28ec0bc648655 Mon Sep 17 00:00:00 2001 From: pixeltris <6952411+pixeltris@users.noreply.github.com> Date: Sat, 12 Jun 2021 06:05:35 +0100 Subject: [PATCH] Add missing base.user.js changes --- base/base.user.js | 775 +++++++++++----------------------------------- 1 file changed, 189 insertions(+), 586 deletions(-) diff --git a/base/base.user.js b/base/base.user.js index 745345d..39580a3 100644 --- a/base/base.user.js +++ b/base/base.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.4 +// @version 1.5 // @description Multiple solutions for blocking Twitch ads // @author pixeltris // @match *://*.twitch.tv/* @@ -12,66 +12,36 @@ 'use strict'; function declareOptions(scope) { // Options / globals - scope.OPT_MODE_MUTE_BLACK = false; - scope.OPT_MODE_VIDEO_SWAP = false; - scope.OPT_MODE_LOW_RES = false; - scope.OPT_MODE_EMBED = false; + scope.OPT_ROLLING_DEVICE_ID = false; scope.OPT_MODE_STRIP_AD_SEGMENTS = false; scope.OPT_MODE_NOTIFY_ADS_WATCHED = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false; scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true; scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false; - scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0; - scope.OPT_MODE_PROXY_M3U8 = ''; - scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false; - scope.OPT_MODE_PROXY_M3U8_FULL_URL = false; - scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false; - scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture'; - scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture'; + scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome'; scope.OPT_REGULAR_PLAYER_TYPE = 'site'; scope.OPT_INITIAL_M3U8_ATTEMPTS = 1; scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = ''; - scope.OPT_ACCESS_TOKEN_TEMPLATE = true; scope.AD_SIGNIFIER = 'stitched-ad'; scope.LIVE_SIGNIFIER = ',live'; scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - // Modify options based on mode - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p - //scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p - } - if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) { - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed'; - } - if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) { - var newStr = ''; - scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8); - for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) { - newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length)); - } - scope.OPT_MODE_PROXY_M3U8 = newStr; - } // These are only really for Worker scope... scope.StreamInfos = []; scope.StreamInfosByUrl = []; scope.CurrentChannelNameFromM3U8 = null; // Need this in both scopes. Window scope needs to update this to worker scope. scope.gql_device_id = null; + scope.gql_device_id_rolling = ''; + // Rolling device id crap... TODO: improve this + var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); } + var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC'; + var di = (new Date()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0; + for (var i = 0; i < bs.length; i++) { + scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length]; + } } declareOptions(window); - //////////////////////////////////// - // stream swap / stream mute - //////////////////////////////////// - var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads - var disabledVideo = null;// The original video element (disabled for the duration of the ad) - var originalVolume = 0;// The volume of the original video element - var foundAdContainer = false;// Have ad containers been found (the clickable ad) - var foundAdBanner = false;// Is the ad banner visible (top left of screen) - //////////////////////////////////// - var notifyAdsWatchedReloadNextTime = 0; var twitchMainWorker = null; const oldWorker = window.Worker; window.Worker = class Worker extends oldWorker { @@ -87,9 +57,6 @@ } var newBlobStr = ` ${processM3U8.toString()} - ${getSegmentInfos.toString()} - ${getSegmentInfosLines.toString()} - ${getFinalSegUrl.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} @@ -108,28 +75,34 @@ ` super(URL.createObjectURL(new Blob([newBlobStr]))); twitchMainWorker = this; - var adDiv = null; this.onmessage = function(e) { + // NOTE: Removed adDiv caching as '.video-player' can change between streams? if (e.data.key == 'UboShowAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...'; - adDiv.style.display = 'block'; + var adDiv = getAdDiv(); + if (adDiv != null) { + adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...'; + adDiv.style.display = 'block'; + } } else if (e.data.key == 'UboHideAdBanner') { - if (adDiv == null) { adDiv = getAdDiv(); } - adDiv.style.display = 'none'; + var adDiv = getAdDiv(); + if (adDiv != null) { + adDiv.style.display = 'none'; + } } else if (e.data.key == 'UboFoundAdSegment') { - onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8); + onFoundAd(e.data.isMidroll, e.data.streamM3u8); } else if (e.data.key == 'UboChannelNameM3U8Changed') { //console.log('M3U8 channel name changed to ' + e.data.value); - notifyAdsWatchedReloadNextTime = 0; } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(); } else if (e.data.key == 'UboPauseResumePlayer') { + reloadTwitchPlayer(false, true); + } + else if (e.data.key == 'UboSeekPlayer') { reloadTwitchPlayer(true); } } @@ -157,164 +130,33 @@ req.send(); return req.responseText.split("'")[1]; } - function getSegmentInfosLines(streamInfo, lines) { - var result = {}; - result.segs = []; - result.targetDuration = 0; - result.elapsedSecs = 0; - result.totalSecs = 0; - result.hasPrefetch = false; - result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments) - var hasLive = false; - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (line.startsWith('#EXT-X-TARGETDURATION')) { - result.targetDuration = parseInt(line.split(':')[1]); - } - if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) { - result.elapsedSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) { - result.totalSecs = line.split(':')[1]; - } - if (line.startsWith('#EXT-X-DATERANGE')) { - var attr = parseAttributes(line); - if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) { - streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL'; - } - } - if (line.startsWith('http')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i]; - segInfo.isPrefetch = false; - if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) { - //#EXTINF:2.002,DCM|2435256 - //#EXTINF:2.002,Amazon|8493257483 - //#EXTINF:2.000,live - var splitted = lines[i - 1].split(':')[1].split(','); - segInfo.extInfLineIndex = i - 1; - segInfo.extInfLine = lines[i - 1]; - segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?) - segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM - segInfo.isAd = segInfo.extInfType != 'live'; - if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) { - result.hasLiveBeforeAd = false; - } - hasLive = !segInfo.isAd; - } - if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) { - segInfo.dateTimeLineIndex = i - 2; - segInfo.dateTimeLine = lines[i - 2]; - segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':'))); - } - result.segs.push(segInfo); - } - if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { - var segInfo = {}; - segInfo.urlLineIndex = i; - segInfo.urlLine = lines[i]; - segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1); - segInfo.isPrefetch = true; - result.hasPrefetch = true; - result.segs.push(segInfo); - } - } - return result; - } - function getSegmentInfos(streamInfo, lines, backupLines) { - var result = {}; - result.segs = []; - result.main = getSegmentInfosLines(streamInfo, lines); - result.backup = getSegmentInfosLines(streamInfo, backupLines); - // Push all backup segments first - for (var i = 0; i < result.backup.segs.length; i++) { - var seg = {}; - seg.backup = result.backup.segs[i]; - result.segs.push(seg); - } - // Insert any live main segments - // NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache) - // NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls. - for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) { - while (result.main.segs[i].isPrefetch) { - if (result.segs[j].backup.isPrefetch) { - result.segs[j].main = result.main.segs[i]; - j--; - } - i--; - } - if (!result.main.segs[i].isAd) { - result.segs[j].main = result.main.segs[i]; - } else { - break; - } - } - // Set the segment cache (currently unused) - streamInfo.SegmentCache.length = 0; - for (var i = 0; i < result.segs.length; i++) { - if (result.segs[i].main != null) { - streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i]; - } - if (result.segs[i].backup != null) { - streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i]; - } - } - return result; - } - function getFinalSegUrl(lines) { - for (var i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith("http")) { - return lines[i]; - } - } - return null; - } async function processM3U8(url, textStr, realFetch) { - var haveAdTags = textStr.includes(AD_SIGNIFIER); - if (OPT_MODE_STRIP_AD_SEGMENTS) { - var si = StreamInfosByUrl[url]; - if (si != null) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { - var oldRealSeq = si.RealSeqNumber; - si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]); - if (!haveAdTags && si.FakeSeqNumber > 0) { - /*// We have some sequencing issues... for now lets pause/play and stop modifying sequence. - // TODO: Improve sequencing (determine if the m3u8 urls have actually changed) - si.FakeSeqNumber = 0; - si.BackupSeqNumber = -1; - postMessage({key:'UboPauseResumePlayer'});*/ - // We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better - var finalSegUrl = getFinalSegUrl(lines); - if (finalSegUrl != si.FinalSegUrl) { - si.FinalSegUrl = finalSegUrl; - // TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests) - var jump = Math.max(0, si.RealSeqNumber - oldRealSeq); - if (jump <= 3) { - si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq); - } else if (jump > 0) { - si.FakeSeqNumber++; - } - } - lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber; - console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber); - } - break; - } - } - textStr = lines.join('\n'); - } + var streamInfo = StreamInfosByUrl[url]; + if (streamInfo == null) { + console.log('Unknown stream url ' + url); + postMessage({key:'UboHideAdBanner'}); + return textStr; } + if (!OPT_MODE_STRIP_AD_SEGMENTS) { + return textStr; + } + var haveAdTags = textStr.includes(AD_SIGNIFIER); if (haveAdTags) { - var si = StreamInfosByUrl[url]; - if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) { - // We only really know it's fully processed when we no loger see ads - // NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration? + var currentResolution = null; + for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) { + if (resUrl == url) { + currentResolution = resName; + //console.log(resName); + break; + } + } + streamInfo.HadAds = true; + streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); + // Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early. + if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) { var noAds = false; - var encodingsM3u8Response = await realFetch(si.RootM3U8Url); + var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url); if (encodingsM3u8Response.status === 200) { var encodingsM3u8 = await encodingsM3u8Response.text(); var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; @@ -324,147 +166,135 @@ console.log('Notify ad watched. Response has ads: ' + !noAds); } } - if (si.NotifyFirstTime == 0) { - si.NotifyFirstTime = Date.now(); + if (streamInfo.NotifyFirstTime == 0) { + streamInfo.NotifyFirstTime = Date.now(); } - if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - si.NotifyObservedNoAds = true; - } - if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { - console.log('Reload player'); - postMessage({key:'UboHideAdBanner'}); - postMessage({key:'UboReloadPlayer'}); - return ''; + if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) { + streamInfo.NotifyObservedNoAds = true; } } postMessage({ key: 'UboFoundAdSegment', - hasLiveSeg: textStr.includes(LIVE_SIGNIFIER), + isMidroll: streamInfo.IsMidroll, streamM3u8: textStr }); - } - if (!OPT_MODE_STRIP_AD_SEGMENTS) { - return textStr; - } - if (haveAdTags) { - var streamInfo = StreamInfosByUrl[url]; - if (streamInfo == null) { - console.log('Unknown stream url ' + url); - postMessage({key:'UboHideAdBanner'}); - return textStr; + if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) { + return ''; } - if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { - // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. - streamInfo.BackupFailed = true; - var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - streamInfo.BackupFailed = false; - streamInfo.BackupUrl = streamM3u8Url; - console.log('Fetched backup url: ' + streamInfo.BackupUrl); + // NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form. + // See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request) + try { + if (streamInfo.BackupRegRes != currentResolution) { + streamInfo.BackupRegRes = null; + streamInfo.BackupRegUrl = null; + } + if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) { + var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE); + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + var encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + var encodingsM3u8 = await encodingsM3u8Response.text(); + var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); + for (var i = 0; i < encodingsLines.length; i++) { + if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) { + if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) { + var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION']; + if (res && res == currentResolution) { + streamInfo.BackupRegUrl = encodingsLines[i]; + streamInfo.BackupRegRes = currentResolution; + break; + } + } + } + } + } + } + } + if (streamInfo.BackupRegUrl != null) { + var backupM3u8 = null; + var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl); + if (backupM3u8Response.status == 200) { + backupM3u8 = await backupM3u8Response.text(); + } + if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { + return backupM3u8; + } else { + //console.log('Try use regular resolution failed'); + streamInfo.BackupRegRes = null; + streamInfo.BackupRegUrl = null; + } + } + } catch (err) { + streamInfo.BackupRegRes = null; + streamInfo.BackupRegUrl = null; + console.log('Fetching backup (regular resolution) m3u8 failed'); + console.log(err); + } + // Fetch backup url + try { + if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) { + // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this. + streamInfo.BackupFailed = true; + var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE); + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + var encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + // TODO: Maybe look for the most optimal m3u8 + var encodingsM3u8 = await encodingsM3u8Response.text(); + var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; + // Maybe this request is a bit unnecessary + var streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + streamInfo.BackupFailed = false; + streamInfo.BackupUrl = streamM3u8Url; + console.log('Fetched backup url: ' + streamInfo.BackupUrl); + } else { + console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); + } } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); + console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); } } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); + console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); } - } - var backupM3u8 = null; - if (streamInfo.BackupUrl != null) { + var backupM3u8 = null; var backupM3u8Response = await realFetch(streamInfo.BackupUrl); if (backupM3u8Response.status == 200) { backupM3u8 = await backupM3u8Response.text(); + } + if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) { + return backupM3u8; } else { console.log('Backup m3u8 failed with ' + backupM3u8Response.status); } + } catch (err) { + console.log('Fetching backup m3u8 failed'); + console.log(err); } - var lines = textStr.replace('\r', '').split('\n'); - var newLines = []; - if (backupM3u8 != null) { - var backupLines = backupM3u8.replace('\r', '').split('\n'); - var segInfos = getSegmentInfos(streamInfo, lines, backupLines); - newLines.push('#EXTM3U'); - newLines.push('#EXT-X-VERSION:3'); - newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration); - newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber); - // The following will could cause issues when we stop stripping segments - //newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs); - //newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs); - var pushedLiveSegs = 0; - var pushedBackupSegs = 0; - var pushedPrefetchSegs = 0; - for (var i = 0; i < segInfos.segs.length; i++) { - var seg = segInfos.segs[i]; - var segData = null; - if (seg.main != null && !seg.main.isAd) { - pushedLiveSegs++; - segData = seg.main; - } else if (seg.backup != null) { - pushedBackupSegs++; - segData = seg.backup; - } - if (segData != null) { - if (segData.isPrefetch) { - pushedPrefetchSegs++; - newLines.push(segData.urlLine); - } else { - //newLines.push(segData.dateTimeLine); - newLines.push(segData.extInfLine); - newLines.push(segData.urlLine); - } - } - } - var finalSegUrl = getFinalSegUrl(newLines); - if (finalSegUrl != streamInfo.FinalSegUrl) { - streamInfo.FinalSegUrl = finalSegUrl; - streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq? - } - newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber; - if (pushedLiveSegs > 0 || pushedBackupSegs > 0) { - console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber); - } else { - console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)'); - } - } - textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n'); - //console.log(textStr); + // Backups failed. Return nothing (this will likely result in spam or player error 2000?). + console.log('Ad blocking failed. Stream might break.'); + return ''; } + if (streamInfo.HadAds) { + postMessage({key:'UboSeekPlayer'}); + streamInfo.HadAds = false; + } + postMessage({key:'UboHideAdBanner'}); return textStr; } function hookWorkerFetch() { var realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { - if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) { - var shownAdBanner = false; - for (const [channelName, streamInfo] of Object.entries(StreamInfos)) { - var seg = streamInfo.SegmentCache[url]; - if (seg && !seg.isPrefetch) { - if (seg.main == null && seg.backup != null) { - shownAdBanner = true; - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); - } - break; - } - } - if (!shownAdBanner) { - postMessage({key:'UboHideAdBanner'}); - } - } if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { var processAfter = async function(response) { @@ -491,26 +321,7 @@ }); } CurrentChannelNameFromM3U8 = channelName; - if (OPT_MODE_PROXY_M3U8) { - if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) { - if (OPT_MODE_PROXY_M3U8_FULL_URL) { - url = OPT_MODE_PROXY_M3U8 + url; - } else { - url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0]; - } - if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) { - console.log('proxy-m3u8: ' + url); - } - var opt2 = {}; - opt2.headers = []; - opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy - return realFetch(url, opt2); - } else { - url = OPT_MODE_PROXY_M3U8 + channelName; - console.log('proxy-m3u8: ' + url); - } - } - else if (OPT_MODE_STRIP_AD_SEGMENTS) { + if (OPT_MODE_STRIP_AD_SEGMENTS) { return new Promise(async function(resolve, reject) { // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. @@ -533,13 +344,15 @@ } // This might potentially backfire... maybe just add the new urls streamInfo.ChannelName = channelName; - streamInfo.Urls = []; + streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution) streamInfo.RootM3U8Url = url; streamInfo.RootM3U8Params = (new URL(url)).search; streamInfo.BackupUrl = null; streamInfo.BackupFailed = false; - streamInfo.SegmentCache = []; + streamInfo.BackupRegUrl = null; + streamInfo.BackupRegRes = null; streamInfo.IsMidroll = false; + streamInfo.HadAds = false; streamInfo.NotifyFirstTime = 0; streamInfo.NotifyObservedNoAds = false; streamInfo.RealSeqNumber = -1; @@ -549,7 +362,13 @@ var lines = encodingsM3u8.replace('\r', '').split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls.push(lines[i]); + streamInfo.Urls[lines[i]] = -1; + if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) { + var res = parseAttributes(lines[i - 1])['RESOLUTION']; + if (res) { + streamInfo.Urls[lines[i]] = res; + } + } StreamInfosByUrl[lines[i]] = streamInfo; } } @@ -590,37 +409,18 @@ } function getAccessToken(channelName, playerType, realFetch) { var body = null; - if (OPT_ACCESS_TOKEN_TEMPLATE) { - var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; - body = { - operationName: 'PlaybackAccessToken_Template', - query: templateQuery, - variables: { - 'isLive': true, - 'login': channelName, - 'isVod': false, - 'vodID': '', - 'playerType': playerType - } - }; - } else { - body = { - operationName: 'PlaybackAccessToken', - variables: { - isLive: true, - login: channelName, - isVod: false, - vodID: '', - playerType: playerType - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712', - } - } - }; - } + var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; + body = { + operationName: 'PlaybackAccessToken_Template', + query: templateQuery, + variables: { + 'isLive': true, + 'login': channelName, + 'isVod': false, + 'vodID': '', + 'playerType': playerType + } + }; return gqlRequest(body, realFetch); } function gqlRequest(body, realFetch) { @@ -630,7 +430,7 @@ body: JSON.stringify(body), headers: { 'client-id': CLIENT_ID, - 'X-Device-Id': gql_device_id + 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id } }); } @@ -700,33 +500,6 @@ } return 0; } - async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) { - var tokInfo = JSON.parse(token); - var channelName = tokInfo.channel; - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); - urlInfo.searchParams.set('sig', sig); - urlInfo.searchParams.set('token', token); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); - var streamM3u8 = await streamM3u8Response.text(); - var res = await tryNotifyAdsWatchedM3U8(streamM3u8); - if (i >= 0) { - if (res == 1) { - console.log("no ad at req " + i); - } else { - console.log('ad at req ' + i); - } - } - return res; - } else { - // http error - return 2; - } - return 0; - } function hookFetch() { var realFetch = window.fetch; window.fetch = function(url, init, ...args) { @@ -752,32 +525,12 @@ value: gql_device_id }); } - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) { - var tok = null, sig = null; - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - return new Promise(async function(resolve, reject) { - var response = await realFetch(url, init); - if (response.status === 200) { - for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) { - var cloned = response.clone(); - var responseStr = await cloned.text(); - var responseData = JSON.parse(responseStr); - if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { - if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) { - resolve(new Response(responseStr)); - return; - } - } else { - console.log('malformed'); - console.log(responseData); - break; - } - } - resolve(response); - } else { - resolve(response); - } - }); + if (OPT_ROLLING_DEVICE_ID) { + if (typeof init.headers['X-Device-Id'] === 'string') { + init.headers['X-Device-Id'] = gql_device_id_rolling; + } + if (typeof init.headers['Device-ID'] === 'string') { + init.headers['Device-ID'] = gql_device_id_rolling; } } } @@ -785,148 +538,16 @@ return realFetch.apply(this, arguments); } } - function onFoundAd(hasLiveSeg, streamM3u8) { - if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) { + function onFoundAd(isMidroll, streamM3u8) { + if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) { console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT'); - notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY; if (streamM3u8) { tryNotifyAdsWatchedM3U8(streamM3u8); } reloadTwitchPlayer(); - return; - } - if (hasLiveSeg) { - return; - } - if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - return; - } - if (!foundAdContainer) { - // hide ad contianers - var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]'); - for (var i = 0; i < adContainers.length; i++) { - adContainers[i].style.display = "none"; - } - foundAdContainer = adContainers.length > 0; - } - if (disabledVideo) { - disabledVideo.volume = 0; - } else { - //get livestream video element - var liveVid = document.getElementsByTagName("video"); - if (liveVid.length) { - disabledVideo = liveVid = liveVid[0]; - if (!disabledVideo) { - return; - } - //mute - originalVolume = liveVid.volume; - liveVid.volume = 0; - //black out - liveVid.style.filter = "brightness(0%)"; - if (OPT_MODE_VIDEO_SWAP) { - var createTempStream = async function() { - // Create new video stream TODO: Do this with callbacks - var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name - var tempM3u8Url = null; - var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true'); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await fetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - // TODO: Maybe look for the most optimal m3u8 - var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - // Maybe this request is a bit unnecessary - var streamM3u8Response = await fetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - tempM3u8Url = streamM3u8Url; - } else { - console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status); - } - } else { - console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status); - } - } else { - console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status); - } - if (tempM3u8Url != null) { - tempVideo = document.createElement('video'); - tempVideo.autoplay = true; - tempVideo.volume = originalVolume; - console.log(disabledVideo); - disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling); - if (Hls.isSupported()) { - tempVideo.hls = new Hls(); - tempVideo.hls.loadSource(tempM3u8Url); - tempVideo.hls.attachMedia(tempVideo); - } - console.log(tempVideo); - console.log(tempM3u8Url); - } - }; - createTempStream(); - } - } } } - function pollForAds() { - //console.log('pollForAds ' + new Date(Date.now())); - //check ad by looking for text banner - var adBanner = document.querySelectorAll("span.tw-c-text-overlay"); - var foundAd = false; - for (var i = 0; i < adBanner.length; i++) { - if (adBanner[i].attributes["data-test-selector"]) { - foundAd = true; - foundAdBanner = true; - break; - } - } - if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) { - if (disabledVideo.paused) { - tempVideo.pause(); - } else { - tempVideo.play();//TODO: Fix issue with Firefox - } - } - if (foundAd) { - onFoundAd(false); - } else if (!foundAd && foundAdBanner) { - if (disabledVideo) { - disabledVideo.volume = originalVolume; - disabledVideo.style.filter = ""; - disabledVideo = null; - foundAdContainer = false; - foundAdBanner = false; - if (tempVideo) { - tempVideo.hls.stopLoad(); - tempVideo.remove(); - tempVideo = null; - } - } - } - //setTimeout(pollForAds,100); - } - function pollForAdsObserver() { - pollForAds(); - var vids = document.getElementsByClassName('video-player'); - for (var i = 0; i < vids.length; i++) { - var observer = new MutationObserver(pollForAds); - observer.observe(vids[i], { - childList: true, - subtree: true, - attributes: false, - characterData: false - }); - } - } - function reloadTwitchPlayer(isPausePlay) { + function reloadTwitchPlayer(isSeek, isPausePlay) { // Taken from ttv-tools / ffz // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx @@ -967,6 +588,11 @@ if (player.paused) { return; } + if (isSeek) { + console.log('Force seek to reset player (hopefully fixing any audio desync)'); + player.seekTo(0); + return; + } if (isPausePlay) { player.pause(); player.play(); @@ -990,28 +616,5 @@ playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false } window.reloadTwitchPlayer = reloadTwitchPlayer; - function onContentLoaded() { - // These modes use polling of the ad elements (e.g. ad banner text) to show/hide content - if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) { - return; - } - if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') { - var script = document.createElement('script'); - script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest"; - script.onload = function() { - pollForAdsObserver(); - } - document.head.appendChild(script); - } else { - pollForAdsObserver(); - } - } hookFetch(); - if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { - onContentLoaded(); - } else { - window.addEventListener("DOMContentLoaded", function() { - onContentLoaded(); - }); - } })(); \ No newline at end of file